Browse Source

fix(console):固件和产品图按产品绑定的应用分目录,不再写死 EAIMAR

Co-authored-by: Cursor <cursoragent@cursor.com>
along-test
Rodger-Wang 7 days ago
parent
commit
ed73e96376
  1. 8
      apps/admin/app/composables/useApi.ts
  2. 45
      apps/admin/app/pages/product.vue
  3. 7
      apps/services/comm/ossconf.go
  4. 186
      apps/services/modules/console/api_upload.go
  5. 78
      apps/services/modules/console/api_upload_test.go

8
apps/admin/app/composables/useApi.ts

@ -99,10 +99,10 @@ export function useApi() {
opts: { target?: string; appName?: string; onProgress?: (percent: number) => void } = {},
): Promise<string> {
const contentType = file.type || 'application/octet-stream'
// ⚠️ opts.appName 是页面上选中的应用名,安装包上传必须带:
// X-App-Id 头来自登录态的 currentAppId,只有绑定了应用的运营/代理账号才有;
// 超管在页面顶部选的应用只是个 app_name 字符串,不会进请求头,
// 不带 app_name 的话超管传包必然被后端拒成「必须先选择应用」。
// opts.appName:安装包是页面上选中的应用;固件/产品图是产品绑定的应用名。
// 安装包这条:X-App-Id 只有绑定了应用的运营/代理账号才有,超管页面上选的应用
// 不会进请求头,不带 app_name 就会被拒成「必须先选择应用」。
// 固件/产品图的目录由服务端按产品 appnames 决定,不看登录态的 X-App-Id。
const token = await webApi<{
upload_url?: string
url?: string

45
apps/admin/app/pages/product.vue

@ -216,6 +216,9 @@
<input v-model="form.productimage" type="text" class="input input-bordered w-full input-sm mb-2" placeholder="图片URL(可手动输入或上传)" />
<div class="flex items-center gap-2">
<input type="file" accept="image/*" class="file-input file-input-bordered file-input-sm flex-1" @change="onImagePick" />
<select v-if="form.appnames.length > 1" v-model="imageApp" class="select select-bordered select-sm w-36">
<option v-for="n in form.appnames" :key="n" :value="n">{{ n }}</option>
</select>
<button type="button" class="btn btn-outline btn-sm btn-primary" :disabled="imgUploading" @click="uploadImage">
<span v-if="imgUploading" class="loading loading-spinner loading-xs"></span>上传
</button>
@ -370,6 +373,12 @@
<label class="label"><span class="label-text text-xs">OTA 更新包 *</span></label>
<input type="file" class="file-input file-input-bordered file-input-sm w-full" accept=".bin,.hex,.zip,.gz,.tar,.ota,.ufw,.fot,.upd" @change="onOtaPick" />
</div>
<div v-if="productApps.length > 1" class="form-control mb-3 max-w-xs">
<label class="label"><span class="label-text text-xs">上传到应用 *</span></label>
<select v-model="firmwareApp" class="select select-bordered select-sm w-full">
<option v-for="n in productApps" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer"><input v-model="ver.isuse" type="checkbox" class="checkbox checkbox-primary checkbox-sm" /><span class="text-sm">设为当前使用版本</span></label>
<button type="submit" class="btn btn-primary btn-sm text-white ml-auto" :disabled="!canAddVersion || versionSubmitting">
@ -412,7 +421,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useAuthStore } from '~/stores/auth'
@ -610,6 +619,7 @@ function showForm(p?: Product) {
form.devicecodecount = p.devicecodecount === 2 ? 2 : 1
form.productimage = p.productimage || ''
form.appnames = appNamesOf(p)
imageApp.value = form.appnames[0] || ''
} else {
isEditing.value = false
form.id = 0
@ -622,6 +632,7 @@ function showForm(p?: Product) {
form.devicecodecount = 1
form.productimage = ''
form.appnames = []
imageApp.value = ''
}
view.value = 'form'
}
@ -808,15 +819,36 @@ function delProduct(p: Product) {
// ── 图片上传 ──
const imgFile = ref<File | null>(null)
const imgUploading = ref(false)
const imageApp = ref('')
watch(() => form.appnames.slice(), (names) => {
if (!names.includes(imageApp.value)) imageApp.value = names[0] || ''
})
function onImagePick(e: Event) {
imgFile.value = (e.target as HTMLInputElement).files?.[0] ?? null
}
// 上传目录用产品绑定的应用名。只绑一个时不用再选;绑了多个必须点名,否则服务端拒绝。
function pickUploadApp(names: string[], chosen: string): string | null {
const list = names.map((s) => s.trim()).filter(Boolean)
if (!list.length) {
warn('请先绑定应用,再上传')
return null
}
if (list.length === 1) return list[0]
const pick = chosen.trim()
if (!list.includes(pick)) {
warn('该产品绑定了多个应用,请选择要上传到哪个应用')
return null
}
return pick
}
async function uploadImage() {
if (!imgFile.value) { warn('请先选择图片'); return }
const appName = pickUploadApp(form.appnames, imageApp.value)
if (!appName) return
imgUploading.value = true
try {
const path = form.id ? `product/${form.id}/images/` : 'product/images/'
const url = await uploadFile(imgFile.value, path)
const url = await uploadFile(imgFile.value, path, { appName })
form.productimage = url
success('上传成功')
} catch (e: any) {
@ -832,6 +864,8 @@ const versions = ref<Version[]>([])
const versionSubmitting = ref(false)
const ver = reactive({ version: '', desc: '', isuse: false })
const otaFile = ref<File | null>(null)
const firmwareApp = ref('')
const productApps = computed(() => appNamesOf(currentProduct.value ?? { appnames: '' } as Product))
const canAddVersion = computed(() => !!ver.version.trim() && !!otaFile.value)
@ -846,6 +880,7 @@ async function showVersions(p: Product) {
const d = await webApi<{ product: Product }>('getproduct', { id: p.id })
currentProduct.value = d?.product ?? p
} catch { currentProduct.value = p }
firmwareApp.value = appNamesOf(currentProduct.value)[0] || ''
await loadVersions(p.id)
}
@ -910,10 +945,12 @@ async function addVersion() {
if (!pid) { error('产品ID不存在'); return }
if (!ver.version.trim()) { warn('请输入版本号'); return }
if (!otaFile.value) { warn('请选择固件文件'); return }
const appName = pickUploadApp(productApps.value, firmwareApp.value)
if (!appName) return
versionSubmitting.value = true
try {
// 固件包:目录传 product/<产品id>/,后端强制置于 EAIMAR/ 根下 → EAIMAR/product/<id>/<文件名>
const url = await uploadFile(otaFile.value, `product/${pid}/`)
// 目录根是产品绑定的应用名,由服务端按 appnames 写入,不再固定 EAIMAR/
const url = await uploadFile(otaFile.value, `product/${pid}/`, { appName })
const d = await webApi<{ version: Version }>('addproductversion', {
version: { productid: pid, version: ver.version.trim(), description: ver.desc, updatepackageaddress: url },
isuse: false,

7
apps/services/comm/ossconf.go

@ -58,10 +58,11 @@ func (oc *AliyunOSSConf) ForDist() *AliyunOSSConf {
// AliyunOSSProvider svc_config / sys_service_config 中阿里云对象存储的 provider 值。
const AliyunOSSProvider = "aliyun_oss"
// OSSUploadRoot 所有上传对象强制置于该根前缀下(服务端兜底,调用方传什么路径都逃不出去)。
// OSSUploadRoot App 端用户上传(录音、头像)的对象根前缀。
//
// ⚠️ 改这个值只影响【之后】上传的文件:已经存进库里的历史地址是死的绝对 URL,
// 不会跟着搬家,OSS 上的旧目录也还在。要改就得连同库里那些 URL 一起改。
// 管理后台的固件和产品图不走这里:那些按产品绑定的应用名分目录
// (见 console.buildObjectKey)。改这个值只影响之后的 App 上传,
// 已经存进库里的历史地址是绝对 URL,不会跟着搬家。
const OSSUploadRoot = "EAIMAR"
// LoadAliyunOSSConf 读取阿里云 OSS 有效配置。

186
apps/services/modules/console/api_upload.go

@ -6,16 +6,20 @@ package console
// 1. OSS 凭证来自【第三方服务配置(svc_config) → 存储 → 阿里云 OSS】(provider=aliyun_oss),
// 即 serviceconfig.vue 里落库、并按应用分离(app_name='' 为全局默认)的那份配置。
// ——不再走腾讯云 COS / 也不读那份纯演示未落库的「系统配置」页。
// 2. object key 由服务端强制置于 uploadRoot("EAIMAR") 根前缀下,形如
// EAIMAR/product/<产品id>/<文件名>,并做防路径穿越清洗。
// 2. 固件 / 产品图的 object key 以「产品绑定的应用名」为根,形如
// <应用名>/product/<产品id>/<文件名>。应用名取自产品的 appnames,
// 不写死、也不跟登录态里的 X-App-Id。并做防路径穿越清洗。
// 3. 预签名 PUT 会把 Content-Type 计入签名,故前端 PUT 必须带同样的 Content-Type(uploadFile 已对齐)。
import (
"errors"
"fmt"
"path"
"strconv"
"strings"
"yunyan/comm"
"yunyan/lego/sys/postgres"
"yunyan/pb"
"yunyan/sys/aliyun/oss"
@ -26,9 +30,6 @@ import (
// storage_aliyun_oss 模板 provider 一致)。
const ossStorageProvider = comm.AliyunOSSProvider
// uploadRoot 定义已抽到 comm.OSSUploadRoot,与 App 上传(user_getuploadurl)共用。
const uploadRoot = comm.OSSUploadRoot
// aliyunOSSConf 定义已抽到 comm,供 console 后台直传与 App 上传(user_getuploadurl)
// 共用同一份读取逻辑——两边必须落在同一个桶里,否则后台传的固件和 App 传的录音会分家。
type aliyunOSSConf = comm.AliyunOSSConf
@ -38,23 +39,135 @@ func (this *serverComp) loadAliyunOSSConf(appName string) (*aliyunOSSConf, error
return comm.LoadAliyunOSSConf(appName, this.options.EncryptKey)
}
// buildObjectKey 生成对象存储 key,强制置于 uploadRoot 根下并做防路径穿越清洗。
// buildObjectKey 生成主桶对象 key:<应用名>/<目录>/<文件名>。
//
// dir 调用方给的逻辑目录(如 "product/12/"),可空。
// app 产品绑定的应用名(如 deepGlass),先经 distAppSeg 清洗。
// dir 调用方给的逻辑目录(如 "product/47632/")。
// name 文件名(取 base 去掉任何目录部分)。
//
// 例:buildObjectKey("product/12/", "fw_v1.bin") => "EAIMAR/product/12/fw_v1.bin"
func buildObjectKey(dir, name string) string {
name = path.Base(strings.TrimSpace(name)) // 去掉任何目录部分,防止 name 里夹带 ../
// 例:buildObjectKey("deepGlass", "product/47632/", "fw.bin")
//
// => "deepGlass/product/47632/fw.bin"
func buildObjectKey(app, dir, name string) (string, error) {
seg := distAppSeg(app)
if seg == "" {
return "", fmt.Errorf("上传必须指定应用")
}
dir = strings.Trim(path.Clean("/"+strings.TrimSpace(dir)), "/")
if dir == "" || dir == "." || strings.HasPrefix(dir, "..") {
return "", fmt.Errorf("上传目录无效")
}
name = path.Base(strings.TrimSpace(name))
if name == "" || name == "." || name == "/" {
name = "file"
return "", fmt.Errorf("文件名无效")
}
key := path.Clean(path.Join(uploadRoot, dir, name)) // Join+Clean 合并 //、解析 ..
// 兜底:若 Clean 后逃逸出根前缀(dir 里塞了过量 ../),强制退回根下只保留文件名。
if key != uploadRoot && !strings.HasPrefix(key, uploadRoot+"/") {
key = uploadRoot + "/" + name
key := path.Join(seg, dir, name)
if !strings.HasPrefix(key, seg+"/") {
return "", fmt.Errorf("上传目录无效")
}
return key
return key, nil
}
// parseMainUploadDir 清洗主桶目录,并认出 product/<产品id>/… 里的产品 id。
//
// 没有产品 id 的只有「新建产品、还没保存」时的产品图:product/images/。
// 其它目录一律拒绝——主桶这条入口现在只给固件和产品图。
func parseMainUploadDir(dir string) (clean string, productID uint32, err error) {
clean = strings.Trim(path.Clean("/"+strings.TrimSpace(dir)), "/")
if clean == "" || clean == "." || strings.HasPrefix(clean, "..") {
return "", 0, fmt.Errorf("上传目录无效")
}
parts := strings.Split(clean, "/")
if parts[0] != "product" {
return "", 0, fmt.Errorf("不支持的上传目录: %s", parts[0])
}
if len(parts) < 2 {
return "", 0, fmt.Errorf("上传目录无效")
}
if parts[1] == "images" {
return clean, 0, nil
}
id, nerr := strconv.ParseUint(parts[1], 10, 32)
if nerr != nil || id == 0 {
return "", 0, fmt.Errorf("产品目录无效")
}
return clean, uint32(id), nil
}
func splitAppNames(csv string) []string {
parts := strings.Split(csv, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// pickBoundApp 从产品已绑定的应用里挑上传目录用的那一个。
//
// requested 非空时必须真在绑定列表里,避免前端把 deepGlass 的固件写进别的应用目录。
// 没指定时:只绑了一个就用它;一个都没有、或绑了多个却不说用哪个,都拒绝。
func pickBoundApp(bound []string, requested string) (string, error) {
req := strings.TrimSpace(requested)
if req != "" {
for _, a := range bound {
if a == req {
return a, nil
}
}
return "", fmt.Errorf("该产品未绑定应用 %s", req)
}
switch len(bound) {
case 0:
return "", fmt.Errorf("产品未绑定应用,请先绑定后再上传")
case 1:
return bound[0], nil
default:
return "", fmt.Errorf("该产品绑定了多个应用,上传时必须指定应用")
}
}
// resolveMainUploadApp 决定固件/产品图放在哪个应用目录下。
//
// 已有产品:读产品行上的 appnames(现查库,不走缓存,刚改的绑定马上生效)。
// 还没保存的新产品:目录是 product/images/,应用名必须是应用中心里真实存在的名字。
func (this *serverComp) resolveMainUploadApp(dir, requested string) (app, cleanDir string, err error) {
cleanDir, pid, err := parseMainUploadDir(dir)
if err != nil {
return "", "", err
}
if pid > 0 {
p, e := dvProduct(consoleDeviceConn(), pid)
if e != nil {
if errors.Is(e, postgres.ErrNoDocuments) {
return "", "", fmt.Errorf("产品不存在")
}
return "", "", e
}
if p == nil || p.Id == 0 {
return "", "", fmt.Errorf("产品不存在")
}
app, err = pickBoundApp(splitAppNames(p.Appnames), requested)
return app, cleanDir, err
}
req := strings.TrimSpace(requested)
if req == "" {
return "", "", fmt.Errorf("请先绑定应用,再上传产品图")
}
exist, e := this.module.model.getProductByName(req)
if e != nil {
if errors.Is(e, postgres.ErrNoDocuments) {
return "", "", fmt.Errorf("应用不存在: %s", req)
}
return "", "", e
}
if exist == nil || exist.Id == 0 || exist.Name == "" {
return "", "", fmt.Errorf("应用不存在: %s", req)
}
return exist.Name, cleanDir, nil
}
// distContentTypes 分发桶里按扩展名强制的 Content-Type。
@ -135,13 +248,10 @@ func (this *serverComp) getUploadURL(c *gin.Context) {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Target string `json:"target"`
// AppName 页面上选中的应用名(app_registry 的 name / app_name)。
//
// ⚠️ 只在没有 X-App-Id 时才用它。X-App-Id 来自登录态里的 currentAppId,
// 只有运营/代理这种「绑定了应用」的账号才有;超管在「版本与分发」页顶部选了应用,
// 那个选择只是页面里的一个 app_name 字符串,根本不会进请求头——于是超管传安装包
// 必然被拒成「必须先选择应用」,且看起来像是没选。前端现在把页面上选的应用名
// 一并放进请求体,这里按名字反查注册表补上作用域。
// AppName 两种用法:
// - target=dist:页面上选中的应用(安装包目录)。没有 X-App-Id 时用它补作用域。
// - 主桶(固件/产品图):产品绑了多个应用时,点名文件放进哪一个应用的目录。
// 必须是该产品 appnames 里的名字;登录态的 X-App-Id 不参与这段路径。
AppName string `json:"app_name"`
}
_ = c.ShouldBindJSON(&req)
@ -163,20 +273,38 @@ func (this *serverComp) getUploadURL(c *gin.Context) {
}
}
}
conf, err := this.loadAliyunOSSConf(appName)
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, err.Error())
return
}
var conf *aliyunOSSConf
var key string
var err error
if strings.EqualFold(strings.TrimSpace(req.Target), "dist") {
conf, err = this.loadAliyunOSSConf(appName)
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, err.Error())
return
}
conf = conf.ForDist()
if key, err = buildDistObjectKey(appName, req.Path, req.Filename); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
} else {
key = buildObjectKey(req.Path, req.Filename)
// 目录根取产品绑定的应用名,不看登录态里的 X-App-Id。
// 请求体 app_name 只在产品绑了多个应用时用来点名用哪一个,
// 而且这个名字必须真在绑定列表里。
uploadApp, dir, rerr := this.resolveMainUploadApp(req.Path, req.AppName)
if rerr != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, rerr.Error())
return
}
conf, err = this.loadAliyunOSSConf(uploadApp)
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, err.Error())
return
}
if key, err = buildObjectKey(uploadApp, dir, req.Filename); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
}
presigner, err := oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket)
if err != nil {

78
apps/services/modules/console/api_upload_test.go

@ -47,6 +47,84 @@ func TestBuildDistObjectKey(t *testing.T) {
}
// apk 的 Content-Type 必须由服务端强制:它计入预签名,签错了要等真机装包才发现。
func TestBuildObjectKey(t *testing.T) {
ok := map[string][3]string{
"固件": {"deepGlass", "product/47632/", "fw.bin"},
"产品图": {"deepGlass", "product/47632/images/", "a.png"},
"未保存的产品图": {"EAIMAR", "product/images/", "a.png"},
"目录带斜杠": {"deepGlass", "/product/47632/", "fw.bin"},
"文件名夹带目录": {"deepGlass", "product/47632/", "../../etc/passwd"},
"应用名带斜杠": {"a/../b", "product/1/", "fw.bin"},
}
want := map[string]string{
"固件": "deepGlass/product/47632/fw.bin",
"产品图": "deepGlass/product/47632/images/a.png",
"未保存的产品图": "EAIMAR/product/images/a.png",
"目录带斜杠": "deepGlass/product/47632/fw.bin",
"文件名夹带目录": "deepGlass/product/47632/passwd",
"应用名带斜杠": "a____b/product/1/fw.bin",
}
for name, in := range ok {
got, err := buildObjectKey(in[0], in[1], in[2])
if err != nil {
t.Errorf("%s: 本该通过,却报错 %v", name, err)
continue
}
if got != want[name] {
t.Errorf("%s: 期望 %q,得到 %q", name, want[name], got)
}
}
if _, err := buildObjectKey("", "product/1/", "a.bin"); err == nil {
t.Error("没指定应用本该被拒")
}
// .. 会被 path.Clean 吃掉,文件仍留在应用目录下,不会逃到桶根。
got, err := buildObjectKey("deepGlass", "../secret", "a.bin")
if err != nil || got != "deepGlass/secret/a.bin" {
t.Fatalf("目录里的 .. 应被折回应用目录下,得到 %q err=%v", got, err)
}
}
func TestParseMainUploadDir(t *testing.T) {
dir, id, err := parseMainUploadDir("product/47632/")
if err != nil || dir != "product/47632" || id != 47632 {
t.Fatalf("固件目录: dir=%q id=%d err=%v", dir, id, err)
}
dir, id, err = parseMainUploadDir("product/47632/images/")
if err != nil || dir != "product/47632/images" || id != 47632 {
t.Fatalf("产品图目录: dir=%q id=%d err=%v", dir, id, err)
}
dir, id, err = parseMainUploadDir("product/images/")
if err != nil || dir != "product/images" || id != 0 {
t.Fatalf("未保存产品图: dir=%q id=%d err=%v", dir, id, err)
}
if _, _, err = parseMainUploadDir("android"); err == nil {
t.Fatal("非 product 目录本该被拒")
}
if _, _, err = parseMainUploadDir("product/0/"); err == nil {
t.Fatal("产品 id 为 0 本该被拒")
}
}
func TestPickBoundApp(t *testing.T) {
got, err := pickBoundApp([]string{"deepGlass"}, "")
if err != nil || got != "deepGlass" {
t.Fatalf("只绑一个: got=%q err=%v", got, err)
}
got, err = pickBoundApp([]string{"EAIMAR", "deepGlass"}, "deepGlass")
if err != nil || got != "deepGlass" {
t.Fatalf("点名已绑定的应用: got=%q err=%v", got, err)
}
if _, err = pickBoundApp([]string{"deepGlass"}, "EAIMAR"); err == nil {
t.Fatal("点名未绑定的应用本该被拒")
}
if _, err = pickBoundApp(nil, ""); err == nil {
t.Fatal("未绑定本该被拒")
}
if _, err = pickBoundApp([]string{"EAIMAR", "deepGlass"}, ""); err == nil {
t.Fatal("绑了多个却不指定本该被拒")
}
}
func TestDistContentTypeForced(t *testing.T) {
if distContentTypes[".apk"] != "application/vnd.android.package-archive" {
t.Fatalf("apk 的 Content-Type 不对: %q", distContentTypes[".apk"])

Loading…
Cancel
Save