You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

162 lines
6.2 KiB

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/<uid>/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
}