package user import ( "crypto/rand" "fmt" "math/big" "os" "path" "strings" "time" "yunyan/comm" "yunyan/lego/sys/log" "yunyan/pb" "yunyan/sys/aliyun/oss" ) // 客户端文件上传:服务端签发阿里云 OSS 预签名 PUT URL,客户端拿着它直传。 // // 为什么不让客户端自己传: // 旧实现是客户端直连腾讯云 COS,bucket 与 **SecretId/SecretKey 明文随 // user_getappconfig 的 env 下发给每一个 App 用户**(config 表里的 COS_* 那几个键)。 // 抓包或反编译就能拿到这对长期密钥,而它对整个桶有读写权限——不只是自己那部分, // User/ 下所有人的录音都能被拉走或删掉。 // // 现在:密钥只留在服务端,客户端拿到的是一个 15 分钟有效、且**只对某一个 // object key 生效**的预签名 URL。object key 由服务端按会话里的 uid 拼死, // 客户端连自己传到哪个目录都决定不了。 // // 存储位置与 console 后台直传(固件/产品图)同一个桶、同一份配置, // 见 comm.LoadAliyunOSSConf——两边分家会让运维要盯两套存储。 // uploadScenes 允许的用途白名单。scene 决定 object key 里的那一段目录, // 不做白名单的话客户端可以塞任意字符串进来,在桶里造出无穷多的垃圾目录。 // ⚠️ 加新场景前先把客户端 addUpload / UploadOss.upload 的调用点全捞一遍: // 少一个值就是那条上传链路整条报「不支持的上传用途」,而且只在真机跑到才发现。 var uploadScenes = map[string]bool{ "LocalAudio": true, // 录音归档(现场/通话/翻译,见客户端 RecordingArchive) "TranslatAudio": true, // 翻译音频(会议页 archiveToLocal=false 时走它) "ExternalAudio": true, // 外部导入的音频(非 LOCAL 类型的记录) "ChatImages": true, // 助手对话里的图片 } // uploadURLExpireSec 预签名有效期。够传一个大录音文件,又不至于泄漏后被长期利用。 const uploadURLExpireSec = 900 // uploadExtWhitelist 允许的扩展名 → Content-Type。 // // 不在表里的一律拒绝,而不是回退成 application/octet-stream:这个接口只服务 // 录音和图片两种场景,放开任意扩展名等于把桶变成任人上传的网盘。 var uploadExtWhitelist = map[string]string{ ".wav": "audio/wav", ".m4a": "audio/mp4", ".mp3": "audio/mpeg", ".aac": "audio/aac", ".amr": "audio/amr", ".opus": "audio/opus", ".ogg": "audio/ogg", ".pcm": "application/octet-stream", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif", } // @Summary 申请文件上传地址 // @Description 服务端签发阿里云 OSS 预签名 PUT URL,客户端直传;密钥不出网关 // @Tags User // @Accept json // @Produce json // @Security BearerAuth // @Param user body pb.UserGetUploadUrlReq true "上传请求" // @Success 200 {object} comm.HttpResult{data=pb.UserGetUploadUrlResp} "成功返回" // @Router /api/home/user_getuploadurl [post] func (this *apiComp) GetUploadUrl(session comm.IUserSession, req *pb.UserGetUploadUrlReq) (resp *pb.UserGetUploadUrlResp, errdata *pb.ErrorData) { uid := session.GetUserId() if strings.TrimSpace(uid) == "" { errdata = &pb.ErrorData{Code: pb.ErrorCode_NoLogin, Message: "未登录"} return } scene := strings.TrimSpace(req.Scene) if !uploadScenes[scene] { errdata = &pb.ErrorData{ Code: pb.ErrorCode_ReqParameterError, Message: fmt.Sprintf("不支持的上传用途: %s", scene), } return } // 只取扩展名,目录部分一律丢弃——filename 可能夹带 ../ 或整条路径。 ext := strings.ToLower(path.Ext(path.Base(strings.TrimSpace(req.Filename)))) ct, ok := uploadExtWhitelist[ext] if !ok { errdata = &pb.ErrorData{ Code: pb.ErrorCode_ReqParameterError, Message: fmt.Sprintf("不支持的文件类型: %s", ext), } return } // 客户端可以指定 Content-Type,但必须与扩展名推断出的一致: // 它计入预签名,两边对不上 PUT 会 403,不如在这里直接挡住并回带正确值。 if c := strings.TrimSpace(req.Contenttype); c != "" && !strings.EqualFold(c, ct) { this.module.Debug("GetUploadUrl: 客户端 Content-Type 与扩展名不符,以服务端推断为准", log.Field{Key: "uid", Value: uid}, log.Field{Key: "client", Value: c}, log.Field{Key: "server", Value: ct}) } // object key 全部由服务端拼:uid 取自会话,客户端决定不了自己传到谁的目录下。 // 形如 EAIMAR/User//LocalAudio/2026/09/04/aB3xK9pQ7mZt.wav name, err := randomName(12) if err != nil { errdata = &pb.ErrorData{Code: pb.ErrorCode_SystemError, Message: err.Error()} return } key := path.Join(comm.OSSUploadRoot, "User", uid, scene, time.Now().Format("2006/01/02"), name+ext) conf, err := comm.LoadAliyunOSSConf(comm.AppName(), os.Getenv("FIELD_ENCRYPT_KEY")) if err != nil { this.module.Error("GetUploadUrl: 取阿里云 OSS 配置失败", log.Field{Key: "uid", Value: uid}, log.Field{Key: "err", Value: err.Error()}) errdata = &pb.ErrorData{Code: pb.ErrorCode_SystemError, Message: err.Error()} return } presigner, err := oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket) if err != nil { errdata = &pb.ErrorData{Code: pb.ErrorCode_SystemError, Message: "初始化阿里云 OSS 失败: " + err.Error()} return } uploadURL, err := presigner.SignPutURL(key, ct, uploadURLExpireSec) if err != nil { errdata = &pb.ErrorData{Code: pb.ErrorCode_SystemError, Message: "生成上传地址失败: " + err.Error()} return } resp = &pb.UserGetUploadUrlResp{ Uploadurl: uploadURL, Url: conf.PublicURL(uploadURL, key), Key: key, Contenttype: ct, Expiresec: uploadURLExpireSec, } return } // randomName 生成 n 位随机字母数字。用 crypto/rand:object key 可被枚举就等于 // 别人能猜到你的录音地址(桶里的对象没有额外鉴权)。 func randomName(n int) (string, error) { const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, n) for i := range b { idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) if err != nil { return "", err } b[i] = alphabet[idx.Int64()] } return string(b), nil }