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