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:io'; |
||||
import 'dart:math'; |
|
||||
|
|
||||
import 'package:common_utils/common_utils.dart'; |
|
||||
import 'package:dio/dio.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 { |
class UploadOss { |
||||
static final UploadOss _shared = UploadOss._internal(); |
UploadOss._(); |
||||
factory UploadOss() => _shared; |
|
||||
|
|
||||
static String bucket = ''; |
static const String _tag = 'UploadOss'; |
||||
static String region = ''; |
|
||||
static String secretId = ''; |
|
||||
static String secretKey = ''; |
|
||||
static String userId = ''; |
|
||||
|
|
||||
static CosTransferManger? _cosTransferManger; |
/// 上传用的独立 Dio:**绝不能复用项目那个实例**。 |
||||
|
/// 那个挂了 AuthInterceptor,会往请求上加 token 与业务签名头、还会把响应 |
||||
UploadOss._internal(); |
/// 按业务 JSON 解析——直传 OSS 时这些都是多余的,签名头还可能与预签名冲突。 |
||||
|
static final Dio _raw = Dio(BaseOptions( |
||||
static initOss() { |
connectTimeout: const Duration(seconds: 30), |
||||
bucket = AppConfig.env('COS_BUCKET_NAME') ?? ''; |
// 录音文件可能几十兆,发送超时给宽一点;接收的是空响应体,短一点就够。 |
||||
region = AppConfig.env('COS_REGION') ?? ''; |
sendTimeout: const Duration(minutes: 10), |
||||
secretId = AppConfig.env('COS_SECRET_ID') ?? ''; |
receiveTimeout: const Duration(seconds: 60), |
||||
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!; |
|
||||
} |
|
||||
|
|
||||
|
/// 上传 [filepath] 指向的文件,返回可访问的 URL。失败抛异常。 |
||||
|
/// |
||||
|
/// [rootDir] 用途,服务端 scene 白名单里的值:`LocalAudio` / `ChatImages`。 |
||||
static Future<String> upload({ |
static Future<String> upload({ |
||||
String? filepath, |
String? filepath, |
||||
String rootDir = 'file', |
String rootDir = 'LocalAudio', |
||||
String? fileType, |
String? fileType, |
||||
Function? callback, |
Function? callback, |
||||
ProgressCallback? onSendProgress, |
ProgressCallback? onSendProgress, |
||||
}) async { |
}) async { |
||||
if (bucket.isEmpty || |
if (filepath == null || filepath.isEmpty) { |
||||
region.isEmpty || |
throw ArgumentError('filepath 不能为空'); |
||||
secretId.isEmpty || |
|
||||
secretKey.isEmpty || |
|
||||
userId.isEmpty) { |
|
||||
initOss(); |
|
||||
} |
} |
||||
String pathName = userId.isNotEmpty |
final file = File(filepath); |
||||
? 'User/$userId/$rootDir/${getDate()}/${getRandom(12)}.${fileType ?? getFileType(filepath!)}' |
if (!await file.exists()) { |
||||
: 'User/$rootDir/${getDate()}/${getRandom(12)}.${fileType ?? getFileType(filepath!)}'; |
throw FileSystemException('待上传文件不存在', filepath); |
||||
CosTransferManger cosTransferManger = await getTransferManger(); |
} |
||||
final completer = Completer<String>(); |
|
||||
await cosTransferManger.upload( |
// 1) 向服务端要一个预签名 PUT URL。 |
||||
bucket, |
// filename 只用于让服务端取扩展名——真正的 object key 由服务端拼。 |
||||
pathName, |
final fileName = fileType != null && fileType.isNotEmpty |
||||
filePath: filepath, |
? 'f.$fileType' |
||||
progressCallBack: onSendProgress, |
: filepath.split('/').last; |
||||
resultListener: ResultListener( |
final data = await Api.getUploadUrl({ |
||||
(Map<String?, String?>? header, CosXmlResult? result) { |
'scene': rootDir, |
||||
final url = result?.accessUrl ?? |
'filename': fileName, |
||||
'https://$bucket.cos.$region.myqcloud.com/$pathName'; |
}); |
||||
completer.complete(url); |
if (data is! Map) { |
||||
}, |
throw Exception('取上传地址失败:服务端返回异常'); |
||||
(clientException, serviceException) { |
} |
||||
completer.completeError(clientException ?? |
final uploadUrl = (data['uploadurl'] as String?) ?? ''; |
||||
serviceException ?? |
final publicUrl = (data['url'] as String?) ?? ''; |
||||
Exception('Unknown error')); |
// ⚠️ 必须用服务端回带的 Content-Type:它计入了预签名,写别的值会被 OSS 判 403。 |
||||
|
final contentType = |
||||
|
(data['contenttype'] as String?) ?? 'application/octet-stream'; |
||||
|
if (uploadUrl.isEmpty || publicUrl.isEmpty) { |
||||
|
throw Exception('取上传地址失败:uploadurl/url 为空'); |
||||
|
} |
||||
|
|
||||
|
// 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, |
||||
); |
); |
||||
return completer.future; |
|
||||
} |
|
||||
|
|
||||
static String getDate() { |
|
||||
DateTime now = DateTime.now(); |
|
||||
return DateUtil.formatDate(now, format: 'yyyy/MM/dd'); |
|
||||
} |
|
||||
|
|
||||
static String getRandom(int num) { |
|
||||
String alphabet = 'qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM'; |
|
||||
String left = ''; |
|
||||
for (var i = 0; i < num; i++) { |
|
||||
left = left + alphabet[Random().nextInt(alphabet.length)]; |
|
||||
} |
|
||||
return left; |
|
||||
} |
|
||||
|
|
||||
static String getFileType(String path) { |
Logger.i(_tag, '上传完成 scene=$rootDir size=$length key=${data['key']}'); |
||||
List<String> array = path.split('.'); |
callback?.call(); |
||||
return array[array.length - 1]; |
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