Browse Source
客户端不再持有对象存储的长期密钥。
旧实现用腾讯云 COS SDK 直连,bucket 与 SecretId/SecretKey 明文随
user_getappconfig 的 env 下发给每一个 App 用户(config 表的 COS_* 那几个键)。
抓包或反编译就能拿到这对长期密钥,而它对整个桶有读写权限——不只是自己那部分,
User/ 下所有人的录音都能被拉走或删掉。
现在:新增 user_getuploadurl,服务端签发 15 分钟有效、且只对某一个 object key
生效的阿里云 OSS 预签名 PUT URL,客户端拿着它直传。存储与 console 后台传固件/
产品图同一个桶(ymaioss),不再是两个云厂商两套存储。
服务端
- 新接口 modules/user/api_getuploadurl.go:
· object key 由服务端拼死 EAIMAR/User/<uid>/<scene>/<yyyy/MM/dd>/<12位随机>.<ext>,
uid 取自会话不采信客户端,客户端连自己传到谁的目录下都决定不了;
· scene 白名单 LocalAudio / TranslatAudio / ExternalAudio / ChatImages
——加新场景前要把客户端 addUpload 与 UploadOss.upload 的调用点全捞一遍,
少一个值就是那条链路整条报错,且只在真机跑到才发现;
· 扩展名白名单决定 Content-Type,不在表里直接拒,不回退成
application/octet-stream(那等于把桶变成任人上传的网盘);
· 随机名用 crypto/rand 而非 math/rand:key 可枚举就等于别人能猜到录音地址。
- 抽出 comm/ossconf.go:OSS 配置读取、上传根前缀、publicURL 拼接从 console 挪来,
console 改为调用它。两边必须落同一个桶,各写一份迟早漂移。
解密用 ${FIELD_ENCRYPT_KEY},console 与业务服务本就必须配同一把。
客户端
- upload_oss.dart 重写为「要 URL → dio 流式 PUT」,upload() 签名与返回值不变,
MeetingUploadService 不用改。用独立 Dio 实例:项目那个挂着 AuthInterceptor,
会加 token 与业务签名头、还会把响应按业务 JSON 解析,直传 OSS 时全是多余,
签名头还可能与预签名冲突。
- Content-Type 必须用服务端回带的值,它计入了签名,写别的会 403。
- agent 图片的 rootDir 从 'User/<uid>/ChatImages' 改成 'ChatImages':
前缀由服务端加,客户端再拼一层就是重复路径。
真机验证:录音落到
https://oss.ymaikj.com/EAIMAR/User/<uid>/LocalAudio/2026/09/04/howXMScPdsNP.wav,
下载 212524B 与库里 size 逐字节一致,Content-Type=audio/wav,文件头 RIFF/WAVE。
其余三个 scene 走同一段代码但尚未实跑。
⚠️ 新老客户端并存:老包仍读 COS_* 直传腾讯云,config 表里那 5 个键先别删;
历史录音的 audiourl 是 COS 绝对地址,不会自动搬家。
⚠️ 录音目前仍是公共可读的(bucket 公共读,实测匿名 GET 206、不存在的 key 404),
只靠 12 位随机 key 不可猜测来保护。要锁住得转私有桶 + 播放时也签临时读 URL,未做。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
10 changed files with 686 additions and 265 deletions
@ -1,117 +1,99 @@ |
|||
import 'dart:async'; |
|||
import 'dart:math'; |
|||
import 'dart:io'; |
|||
|
|||
import 'package:common_utils/common_utils.dart'; |
|||
import 'package:dio/dio.dart'; |
|||
import 'package:get_storage/get_storage.dart'; |
|||
import 'package:tencentcloud_cos_sdk_plugin/cos.dart'; |
|||
import 'package:tencentcloud_cos_sdk_plugin/cos_transfer_manger.dart'; |
|||
import 'package:tencentcloud_cos_sdk_plugin/pigeon.dart'; |
|||
|
|||
import '../../data/models/appconfig.dart'; |
|||
import '../../data/services/network/api.dart'; |
|||
import 'logger.dart'; |
|||
|
|||
/// 客户端文件上传:**服务端签发预签名 URL,客户端直传阿里云 OSS**。 |
|||
/// |
|||
/// ## 为什么不再客户端直连对象存储 |
|||
/// |
|||
/// 旧实现用腾讯云 COS SDK,bucket 与 SecretId/SecretKey 明文随 |
|||
/// `user_getappconfig` 的 env 下发给每一个 App 用户(`config` 表里的 `COS_*`)。 |
|||
/// 抓包或反编译就能拿到这对**长期密钥**,而它对整个桶有读写权限—— |
|||
/// 不只是自己那部分,`User/` 下所有人的录音都能被拉走或删掉。 |
|||
/// |
|||
/// 现在客户端手里只有一个 15 分钟有效、且**只对某一个 object key 生效**的 |
|||
/// 预签名 PUT URL。密钥不出网关。 |
|||
/// |
|||
/// ## 契约没变 |
|||
/// |
|||
/// [upload] 的签名与返回值(最终访问 URL)与旧实现一致,调用方不用改。 |
|||
/// 变的只有 [rootDir] 的语义:它现在是服务端认的 **scene 白名单**里的值 |
|||
/// (`LocalAudio` / `ChatImages`),**不要再自己拼 `User/<uid>/` 前缀**—— |
|||
/// 目录由服务端按会话里的 uid 拼死,客户端拼的那一层只会变成重复路径。 |
|||
class UploadOss { |
|||
static final UploadOss _shared = UploadOss._internal(); |
|||
factory UploadOss() => _shared; |
|||
UploadOss._(); |
|||
|
|||
static String bucket = ''; |
|||
static String region = ''; |
|||
static String secretId = ''; |
|||
static String secretKey = ''; |
|||
static String userId = ''; |
|||
static const String _tag = 'UploadOss'; |
|||
|
|||
static CosTransferManger? _cosTransferManger; |
|||
|
|||
UploadOss._internal(); |
|||
|
|||
static initOss() { |
|||
bucket = AppConfig.env('COS_BUCKET_NAME') ?? ''; |
|||
region = AppConfig.env('COS_REGION') ?? ''; |
|||
secretId = AppConfig.env('COS_SECRET_ID') ?? ''; |
|||
secretKey = AppConfig.env('COS_SECRET_KEY') ?? ''; |
|||
|
|||
final GetStorage storage = GetStorage(); |
|||
Map? userInfo = storage.read("user_info"); |
|||
if (userInfo != null) { |
|||
userId = userInfo['user']['uid']; |
|||
} else { |
|||
userId = ''; |
|||
} |
|||
} |
|||
|
|||
static Future<CosTransferManger> getTransferManger() async { |
|||
if (_cosTransferManger == null) { |
|||
await Cos().initWithPlainSecret(secretId, secretKey); |
|||
if (Cos().hasTransferManger(region)) { |
|||
_cosTransferManger = Cos().getTransferManger(region); |
|||
} else { |
|||
CosXmlServiceConfig serviceConfig = CosXmlServiceConfig( |
|||
region: region, |
|||
isHttps: true, |
|||
); |
|||
_cosTransferManger = await Cos().registerTransferManger( |
|||
region, serviceConfig..region = region, TransferConfig()); |
|||
} |
|||
} |
|||
return _cosTransferManger!; |
|||
} |
|||
/// 上传用的独立 Dio:**绝不能复用项目那个实例**。 |
|||
/// 那个挂了 AuthInterceptor,会往请求上加 token 与业务签名头、还会把响应 |
|||
/// 按业务 JSON 解析——直传 OSS 时这些都是多余的,签名头还可能与预签名冲突。 |
|||
static final Dio _raw = Dio(BaseOptions( |
|||
connectTimeout: const Duration(seconds: 30), |
|||
// 录音文件可能几十兆,发送超时给宽一点;接收的是空响应体,短一点就够。 |
|||
sendTimeout: const Duration(minutes: 10), |
|||
receiveTimeout: const Duration(seconds: 60), |
|||
)); |
|||
|
|||
/// 上传 [filepath] 指向的文件,返回可访问的 URL。失败抛异常。 |
|||
/// |
|||
/// [rootDir] 用途,服务端 scene 白名单里的值:`LocalAudio` / `ChatImages`。 |
|||
static Future<String> upload({ |
|||
String? filepath, |
|||
String rootDir = 'file', |
|||
String rootDir = 'LocalAudio', |
|||
String? fileType, |
|||
Function? callback, |
|||
ProgressCallback? onSendProgress, |
|||
}) async { |
|||
if (bucket.isEmpty || |
|||
region.isEmpty || |
|||
secretId.isEmpty || |
|||
secretKey.isEmpty || |
|||
userId.isEmpty) { |
|||
initOss(); |
|||
if (filepath == null || filepath.isEmpty) { |
|||
throw ArgumentError('filepath 不能为空'); |
|||
} |
|||
String pathName = userId.isNotEmpty |
|||
? 'User/$userId/$rootDir/${getDate()}/${getRandom(12)}.${fileType ?? getFileType(filepath!)}' |
|||
: 'User/$rootDir/${getDate()}/${getRandom(12)}.${fileType ?? getFileType(filepath!)}'; |
|||
CosTransferManger cosTransferManger = await getTransferManger(); |
|||
final completer = Completer<String>(); |
|||
await cosTransferManger.upload( |
|||
bucket, |
|||
pathName, |
|||
filePath: filepath, |
|||
progressCallBack: onSendProgress, |
|||
resultListener: ResultListener( |
|||
(Map<String?, String?>? header, CosXmlResult? result) { |
|||
final url = result?.accessUrl ?? |
|||
'https://$bucket.cos.$region.myqcloud.com/$pathName'; |
|||
completer.complete(url); |
|||
}, |
|||
(clientException, serviceException) { |
|||
completer.completeError(clientException ?? |
|||
serviceException ?? |
|||
Exception('Unknown error')); |
|||
}, |
|||
), |
|||
); |
|||
return completer.future; |
|||
} |
|||
|
|||
static String getDate() { |
|||
DateTime now = DateTime.now(); |
|||
return DateUtil.formatDate(now, format: 'yyyy/MM/dd'); |
|||
final file = File(filepath); |
|||
if (!await file.exists()) { |
|||
throw FileSystemException('待上传文件不存在', filepath); |
|||
} |
|||
|
|||
static String getRandom(int num) { |
|||
String alphabet = 'qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM'; |
|||
String left = ''; |
|||
for (var i = 0; i < num; i++) { |
|||
left = left + alphabet[Random().nextInt(alphabet.length)]; |
|||
// 1) 向服务端要一个预签名 PUT URL。 |
|||
// filename 只用于让服务端取扩展名——真正的 object key 由服务端拼。 |
|||
final fileName = fileType != null && fileType.isNotEmpty |
|||
? 'f.$fileType' |
|||
: filepath.split('/').last; |
|||
final data = await Api.getUploadUrl({ |
|||
'scene': rootDir, |
|||
'filename': fileName, |
|||
}); |
|||
if (data is! Map) { |
|||
throw Exception('取上传地址失败:服务端返回异常'); |
|||
} |
|||
return left; |
|||
final uploadUrl = (data['uploadurl'] as String?) ?? ''; |
|||
final publicUrl = (data['url'] as String?) ?? ''; |
|||
// ⚠️ 必须用服务端回带的 Content-Type:它计入了预签名,写别的值会被 OSS 判 403。 |
|||
final contentType = |
|||
(data['contenttype'] as String?) ?? 'application/octet-stream'; |
|||
if (uploadUrl.isEmpty || publicUrl.isEmpty) { |
|||
throw Exception('取上传地址失败:uploadurl/url 为空'); |
|||
} |
|||
|
|||
static String getFileType(String path) { |
|||
List<String> array = path.split('.'); |
|||
return array[array.length - 1]; |
|||
// 2) 直传。用流式读取,避免把几十兆的录音整个读进内存。 |
|||
final length = await file.length(); |
|||
await _raw.put( |
|||
uploadUrl, |
|||
data: file.openRead(), |
|||
options: Options( |
|||
headers: { |
|||
Headers.contentTypeHeader: contentType, |
|||
// 流式上传时 dio 不会自动算长度,OSS 又要求 Content-Length,必须显式给。 |
|||
Headers.contentLengthHeader: length, |
|||
}, |
|||
), |
|||
onSendProgress: onSendProgress, |
|||
); |
|||
|
|||
Logger.i(_tag, '上传完成 scene=$rootDir size=$length key=${data['key']}'); |
|||
callback?.call(); |
|||
return publicUrl; |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,148 @@ |
|||
package comm |
|||
|
|||
import ( |
|||
"fmt" |
|||
"net/url" |
|||
"strings" |
|||
|
|||
"yunyan/lego/sys/postgres" |
|||
) |
|||
|
|||
// AliyunOSSConf 一份可用的阿里云 OSS 凭证。
|
|||
type AliyunOSSConf struct { |
|||
Endpoint string |
|||
AccessKeyId string |
|||
AccessSecret string |
|||
Bucket string |
|||
Domain string // 可选:自定义 CDN 域名,配了则访问 URL 走该域名
|
|||
} |
|||
|
|||
// AliyunOSSProvider svc_config / sys_service_config 中阿里云对象存储的 provider 值。
|
|||
const AliyunOSSProvider = "aliyun_oss" |
|||
|
|||
// OSSUploadRoot 所有上传对象强制置于该根前缀下(服务端兜底,调用方传什么路径都逃不出去)。
|
|||
//
|
|||
// ⚠️ 改这个值只影响【之后】上传的文件:已经存进库里的历史地址是死的绝对 URL,
|
|||
// 不会跟着搬家,OSS 上的旧目录也还在。要改就得连同库里那些 URL 一起改。
|
|||
const OSSUploadRoot = "EAIMAR" |
|||
|
|||
// LoadAliyunOSSConf 读取阿里云 OSS 有效配置。
|
|||
//
|
|||
// 取值顺序与 console 后台直传一致,两边**必须**是同一份配置,否则后台上传的固件
|
|||
// 和 App 上传的录音会落到不同的桶里:
|
|||
// 1. 【系统配置 → 对象存储】(sys_service_config, category=storage, provider=aliyun_oss);
|
|||
// 2. 该表无有效配置时回退【第三方服务配置 → 存储】(svc_config, provider=aliyun_oss),
|
|||
// 应用行(app_name=<app>) 优先于全局行(app_name='')。
|
|||
//
|
|||
// encKey 为 ${FIELD_ENCRYPT_KEY}——console 与各业务服务本就必须配同一把,
|
|||
// 加密字段(access_key_id/access_key_secret)用它解密。
|
|||
func LoadAliyunOSSConf(appName, encKey string) (*AliyunOSSConf, error) { |
|||
if oc := loadOSSFromSysConfig(encKey); oc != nil { |
|||
return oc, nil |
|||
} |
|||
find := func(app string) *ThirdSvcConfig { |
|||
rows := make([]*ThirdSvcConfig, 0) |
|||
if err := postgres.Find(TableSvcConfig, &rows, |
|||
"app_name=? AND provider=? AND enable=?", app, AliyunOSSProvider, true); err != nil || len(rows) == 0 { |
|||
return nil |
|||
} |
|||
return rows[0] |
|||
} |
|||
cfg := find(appName) |
|||
if cfg == nil && appName != "" { |
|||
cfg = find("") // 应用无专属配置则回退全局默认
|
|||
} |
|||
if cfg == nil { |
|||
return nil, fmt.Errorf("未配置阿里云 OSS:请在【第三方服务配置 → 存储】新增并启用「阿里云 OSS」服务") |
|||
} |
|||
get := func(key string) string { |
|||
for _, f := range cfg.Fields { |
|||
if f.Key != key { |
|||
continue |
|||
} |
|||
v := f.DefValue |
|||
if f.Encrypted && v != "" { |
|||
if plain, err := Decrypt(encKey, v); err == nil { |
|||
v = plain |
|||
} |
|||
} |
|||
return strings.TrimSpace(v) |
|||
} |
|||
return "" |
|||
} |
|||
oc := &AliyunOSSConf{ |
|||
Endpoint: get("endpoint"), |
|||
AccessKeyId: get("access_key_id"), |
|||
AccessSecret: get("access_key_secret"), |
|||
Bucket: get("bucket"), |
|||
Domain: get("domain"), |
|||
} |
|||
if err := oc.normalize(); err != nil { |
|||
return nil, err |
|||
} |
|||
return oc, nil |
|||
} |
|||
|
|||
func loadOSSFromSysConfig(encKey string) *AliyunOSSConf { |
|||
rows := make([]*PlatformService, 0) |
|||
if err := postgres.Find(TableSysServiceConfig, &rows, |
|||
"category=? AND provider=? AND enable=?", SysCatStorage, AliyunOSSProvider, true); err != nil || len(rows) == 0 { |
|||
return nil |
|||
} |
|||
// 有 is_default 优先取默认,否则取第一条启用的。
|
|||
cfg := rows[0] |
|||
for _, r := range rows { |
|||
if r.IsDefault { |
|||
cfg = r |
|||
break |
|||
} |
|||
} |
|||
oc := &AliyunOSSConf{ |
|||
Endpoint: strings.TrimSpace(cfg.SysFieldPlain("endpoint", encKey)), |
|||
AccessKeyId: strings.TrimSpace(cfg.SysFieldPlain("access_key_id", encKey)), |
|||
AccessSecret: strings.TrimSpace(cfg.SysFieldPlain("access_key_secret", encKey)), |
|||
Bucket: strings.TrimSpace(cfg.SysFieldPlain("bucket", encKey)), |
|||
Domain: strings.TrimSpace(cfg.SysFieldPlain("domain", encKey)), |
|||
} |
|||
if oc.normalize() != nil { |
|||
return nil |
|||
} |
|||
return oc |
|||
} |
|||
|
|||
// normalize 校验必填并规范化 endpoint 的 scheme。
|
|||
//
|
|||
// SDK 对无 scheme 的 endpoint 默认拼 http://,会让 https 页面直传时被浏览器按
|
|||
// 「混合内容」拦掉;统一升到 https。
|
|||
func (oc *AliyunOSSConf) normalize() error { |
|||
if oc.Endpoint == "" || oc.AccessKeyId == "" || oc.AccessSecret == "" || oc.Bucket == "" { |
|||
return fmt.Errorf("阿里云 OSS 配置不完整:endpoint / access_key_id / access_key_secret / bucket 均为必填") |
|||
} |
|||
if !strings.HasPrefix(oc.Endpoint, "http://") && !strings.HasPrefix(oc.Endpoint, "https://") { |
|||
oc.Endpoint = "https://" + oc.Endpoint |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// PublicURL 把预签名 URL 换算成可直接访问的裸地址。
|
|||
//
|
|||
// 配了自定义域名 → https://<domain>/<key>(走 CDN);
|
|||
// 否则把预签名的 query 去掉,留裸 OSS 地址。
|
|||
func (oc *AliyunOSSConf) PublicURL(signedURL, key string) string { |
|||
if d := strings.TrimSpace(oc.Domain); d != "" { |
|||
d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://") |
|||
d = strings.Trim(d, "/") |
|||
// 只取第一段做 host:运营常把域名连着路径一起填(cdn.ymaikj.com/),
|
|||
// 那样直接当 Host 用会拼出 https://cdn.ymaikj.com//EAIMAR/... 这种取不到的地址。
|
|||
if i := strings.Index(d, "/"); i >= 0 { |
|||
d = d[:i] |
|||
} |
|||
// 用 url.URL 拼而不是字符串相加:文件名可能带中文或空格,Path 会按段转义(`/` 保留)。
|
|||
return (&url.URL{Scheme: "https", Host: d, Path: "/" + key}).String() |
|||
} |
|||
if u, e := url.Parse(signedURL); e == nil { |
|||
u.RawQuery = "" |
|||
return u.String() |
|||
} |
|||
return signedURL |
|||
} |
|||
@ -0,0 +1,162 @@ |
|||
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 |
|||
} |
|||
Loading…
Reference in new issue