From 8d580a545d3bf47c06e29403ca758fd6ae5a0a36 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Fri, 4 Sep 2026 13:32:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E6=94=B9?= =?UTF-8?q?=E8=B5=B0=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=AD=BE=E5=8F=91=E9=A2=84?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=20URL=EF=BC=8C=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E7=9B=B4=E4=BC=A0=E9=98=BF=E9=87=8C=E4=BA=91=20OSS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户端不再持有对象存储的长期密钥。 旧实现用腾讯云 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////<12位随机>., 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//ChatImages' 改成 'ChatImages': 前缀由服务端加,客户端再拼一层就是重复路径。 真机验证:录音落到 https://oss.ymaikj.com/EAIMAR/User//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 --- CLAUDE.md | 50 ++++ apps/client/lib/core/utils/upload_oss.dart | 174 ++++++------ .../client/lib/data/services/network/api.dart | 13 + .../agent/controllers/agent_controller.dart | 5 +- apps/proto/user/user_msg.proto | 20 ++ apps/services/comm/ossconf.go | 148 +++++++++++ .../services/modules/console/api_sysconfig.go | 33 --- apps/services/modules/console/api_upload.go | 95 +------ .../services/modules/user/api_getuploadurl.go | 162 +++++++++++ apps/services/pb/user_msg.pb.go | 251 ++++++++++++++---- 10 files changed, 686 insertions(+), 265 deletions(-) create mode 100644 apps/services/comm/ossconf.go create mode 100644 apps/services/modules/user/api_getuploadurl.go diff --git a/CLAUDE.md b/CLAUDE.md index 221f5fb0..1e12eba5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1075,6 +1075,56 @@ google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel - 限流:同账号 10 分钟内失败 5 次即拒(`idVerifyFailWindow`/`idVerifyFailLimit`)——服务商按次计费。 - 本地先校验身份证格式(GB 11643 校验位),把明显无效的输入挡在计费调用之前。 +## 文件上传:服务端签发预签名 URL,客户端直传阿里云 OSS(2026-09-04 改) + +**客户端不再持有对象存储的长期密钥。** + +| | 之前 | 现在 | +|---|---|---| +| 存储 | 腾讯云 COS `deepsound-1253517901`(广州) | 阿里云 OSS `ymaioss`(上海),与 console 后台传固件/产品图**同一个桶** | +| 凭据 | `COS_SECRET_ID/KEY` 随 `user_getappconfig` 的 env **明文下发给每个用户** | 密钥只留服务端,客户端拿 15 分钟有效的预签名 PUT URL | +| object key | 客户端自己拼 `User///<日期>/<随机>` | 服务端按会话 uid 拼死 `EAIMAR/User////<12位随机>.` | +| SDK | `tencentcloud_cos_sdk_plugin` | 无,dio 直接 PUT | + +旧方案的问题:那对 AK/SK 抓包或反编译就能拿到,而它对整个桶有读写权限—— +不只是自己那部分,`User/` 下所有人的录音都能被拉走或删掉。 + +链路:客户端 `UploadOss.upload()` → `user_getuploadurl` +([api_getuploadurl.go](apps/services/modules/user/api_getuploadurl.go))→ 拿到预签名 URL +→ dio 流式 PUT → 回写 `audiourl`。 + +- **OSS 配置的唯一读取点是 [comm/ossconf.go](apps/services/comm/ossconf.go)**, + console 后台直传与 App 上传共用。取值顺序:【系统配置 → 对象存储】(`sys_service_config`) + 优先,回退【第三方服务配置 → 存储】(`svc_config`, provider=`aliyun_oss`)。 + ⚠️ 别再各写一份——两边落到不同的桶,运维就要盯两套存储。 +- 解密用 `${FIELD_ENCRYPT_KEY}`,console 与各业务服务本就必须配同一把。 + +⚠️ **scene 是白名单**(`uploadScenes`):`LocalAudio` / `TranslatAudio` / +`ExternalAudio` / `ChatImages`。加新场景前把客户端 `addUpload` 与 +`UploadOss.upload` 的调用点**全捞一遍**——少一个值就是那条上传链路整条报 +「不支持的上传用途」,而且只在真机跑到那个场景才发现。 +(本次就差点漏掉 meeting_home_controller 里的 `TranslatAudio` / `ExternalAudio`。) + +⚠️ 扩展名也是白名单,不在表里直接拒,**不回退成 `application/octet-stream`**—— +那等于把桶变成任人上传的网盘。 + +⚠️ 客户端 PUT 必须带**服务端回带的 Content-Type**:它计入了预签名,写别的值 OSS 判 403。 + +⚠️ 上传用的是**独立的 Dio 实例**,不能复用项目那个:它挂着 `AuthInterceptor`, +会加 token 与业务签名头、还会把响应按业务 JSON 解析,直传 OSS 时这些都多余, +签名头还可能与预签名冲突。 + +⚠️ `rootDir` 参数现在是 scene,**不要再自己拼 `User//` 前缀**(agent 图片那处 +原来拼了),前缀由服务端加,客户端再拼一层就是重复路径。 + +⚠️ **新老客户端并存**:老包仍读 `COS_*` 直传腾讯云,所以业务库 `config` 表里那 5 个 +`COS_*` 键**先别删**。历史录音的 `audiourl` 是 COS 的绝对地址,不会自动搬家。 + +⚠️ **录音目前仍是公共可读的**:bucket 公共读(实测匿名 GET 返回 206,不存在的 key +返回 404 而非 403),谁拿到 URL 谁就能听,只靠 12 位随机 key 不可猜测来保护—— +所以随机名用的是 `crypto/rand` 不是 `math/rand`。要真正锁住得把 bucket 转私有 + +播放时也签临时读 URL,客户端所有播放入口都要改,还没做。 + ## 配置与密钥约定(重要) - 各 `conf/*.yaml` 与 `deploy//deploy.sh` 中**写死了真实的 DSN / 密钥 / 服务器 IP / 仓库密码,且纳入 git**——这是用户在私有仓库下**明确接受**的取舍。修改这些文件时注意它们会随仓库提交。 diff --git a/apps/client/lib/core/utils/upload_oss.dart b/apps/client/lib/core/utils/upload_oss.dart index b2f82509..50bbaf9c 100644 --- a/apps/client/lib/core/utils/upload_oss.dart +++ b/apps/client/lib/core/utils/upload_oss.dart @@ -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 拼死,客户端拼的那一层只会变成重复路径。 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 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 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(); - await cosTransferManger.upload( - bucket, - pathName, - filePath: filepath, - progressCallBack: onSendProgress, - resultListener: ResultListener( - (Map? 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')); + final file = File(filepath); + if (!await file.exists()) { + throw FileSystemException('待上传文件不存在', filepath); + } + + // 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('取上传地址失败:服务端返回异常'); + } + 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 为空'); + } + + // 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) { - List array = path.split('.'); - return array[array.length - 1]; + Logger.i(_tag, '上传完成 scene=$rootDir size=$length key=${data['key']}'); + callback?.call(); + return publicUrl; } } diff --git a/apps/client/lib/data/services/network/api.dart b/apps/client/lib/data/services/network/api.dart index 1d6b610c..58555015 100644 --- a/apps/client/lib/data/services/network/api.dart +++ b/apps/client/lib/data/services/network/api.dart @@ -452,6 +452,19 @@ class Api { ); } + // 申请文件上传地址:服务端签发阿里云 OSS 预签名 PUT URL,客户端拿着它直传。 + // + // 对象存储的长期密钥因此不必下发到客户端(旧的腾讯云 COS 那套是把 + // SecretId/SecretKey 明文发给每个用户的)。object key 由服务端按会话里的 + // uid 拼死,客户端只能给 scene(用途白名单)和文件名里的扩展名。 + static getUploadUrl(params) { + return DioManager().request( + NWMethod.post, + '/api/home/user_getuploadurl', + params: params, + ); + } + // 实时机器翻译:服务端代调后台「第三方服务配置」里启用的 MT 服务。 // 云账号凭据因此不必下发到客户端。 // diff --git a/apps/client/lib/modules/agent/controllers/agent_controller.dart b/apps/client/lib/modules/agent/controllers/agent_controller.dart index aedcc7e5..9fbb2410 100644 --- a/apps/client/lib/modules/agent/controllers/agent_controller.dart +++ b/apps/client/lib/modules/agent/controllers/agent_controller.dart @@ -1217,9 +1217,10 @@ class AgentController extends GetxController with WidgetsBindingObserver { final imagePathResult = await _copyImageToPermanentStorage(imagePath); final permanentImagePath = imagePathResult['fullPath']!; final relativeImagePath = imagePathResult['relativePath']!; + // rootDir 现在是服务端的 scene 白名单值,**不要自己拼 User// 前缀**—— + // 目录由服务端按会话里的 uid 拼死,客户端再拼一层只会变成重复路径。 var ImageUrl = await UploadOss.upload( - filepath: permanentImagePath, - rootDir: 'User/${User.instance.uid}/ChatImages'); + filepath: permanentImagePath, rootDir: 'ChatImages'); // 显示的文本,如果没有提供则使用默认值 final displayText = text?.isNotEmpty == true ? text! : '[图片]'; diff --git a/apps/proto/user/user_msg.proto b/apps/proto/user/user_msg.proto index dd268805..359c3485 100644 --- a/apps/proto/user/user_msg.proto +++ b/apps/proto/user/user_msg.proto @@ -342,3 +342,23 @@ message UserTranslateResp{ string svcid = 3; //实际使用的服务配置 id string detected_from = 4;//from 传空(自动检测)时,服务商检测出的源语种短码;否则为空 } + +//请求文件上传地址(录音、聊天图片等一切客户端产生的文件) +// +//客户端**不再持有对象存储的长期密钥**:由服务端读【第三方服务配置 → 存储】 +//里启用的阿里云 OSS 配置,签发一个短时效的预签名 PUT URL,客户端拿着它直传。 +//object key 由服务端强制拼成 EAIMAR/User///<日期>/<随机>., +//uid 取自会话不采信客户端,所以一个用户传不到别人的目录下。 +//路由 user_getuploadurl +message UserGetUploadUrlReq{ + string scene = 1; //用途,决定 object key 里那一段目录:LocalAudio(录音) / ChatImages(聊天图片) + string filename = 2; //原文件名,服务端只取它的扩展名,目录部分一律丢弃 + string contenttype = 3; //可选。留空则由服务端按扩展名推断,推断结果随响应回带 +} +message UserGetUploadUrlResp{ + string uploadurl = 1; //预签名 PUT URL,客户端直接 PUT 文件体到这个地址 + string url = 2; //上传成功后的访问地址(配了自定义域名就走域名) + string key = 3; //object key,排查用 + string contenttype = 4; //⚠️ PUT 时必须带这个 Content-Type,它计入了签名,不一致会 403 + int64 expiresec = 5; //预签名有效期(秒),超时要重新申请 +} diff --git a/apps/services/comm/ossconf.go b/apps/services/comm/ossconf.go new file mode 100644 index 00000000..0dfe4ca6 --- /dev/null +++ b/apps/services/comm/ossconf.go @@ -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_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:///(走 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 +} diff --git a/apps/services/modules/console/api_sysconfig.go b/apps/services/modules/console/api_sysconfig.go index 259a8c6f..dbd31349 100644 --- a/apps/services/modules/console/api_sysconfig.go +++ b/apps/services/modules/console/api_sysconfig.go @@ -132,39 +132,6 @@ func (this *serverComp) delSysConfig(c *gin.Context) { writeOK(c, gin.H{"id": req.Id}) } -// loadSysStorageAliyun 读平台对象存储里启用的阿里云 OSS 配置,解密为可用凭据; -// 无有效配置返回 nil(调用方据此回退旧的 svc_config 存储配置)。 -func (this *serverComp) loadSysStorageAliyun() *aliyunOSSConf { - rows := make([]*PlatformService, 0) - if err := postgres.Find(comm.TableSysServiceConfig, &rows, - "category=? AND provider=? AND enable=?", comm.SysCatStorage, "aliyun_oss", true); err != nil || len(rows) == 0 { - return nil - } - // 有 is_default 优先取默认,否则取第一条启用的。 - cfg := rows[0] - for _, r := range rows { - if r.IsDefault { - cfg = r - break - } - } - key := this.options.EncryptKey - oc := &aliyunOSSConf{ - Endpoint: strings.TrimSpace(cfg.SysFieldPlain("endpoint", key)), - AccessKeyId: strings.TrimSpace(cfg.SysFieldPlain("access_key_id", key)), - AccessSecret: strings.TrimSpace(cfg.SysFieldPlain("access_key_secret", key)), - Bucket: strings.TrimSpace(cfg.SysFieldPlain("bucket", key)), - Domain: strings.TrimSpace(cfg.SysFieldPlain("domain", key)), - } - if oc.Endpoint == "" || oc.AccessKeyId == "" || oc.AccessSecret == "" || oc.Bucket == "" { - return nil - } - if !strings.HasPrefix(oc.Endpoint, "http://") && !strings.HasPrefix(oc.Endpoint, "https://") { - oc.Endpoint = "https://" + oc.Endpoint - } - return oc -} - // loadSysEmail 取平台邮件配置(启用的那条;无则 nil)。 func (this *serverComp) loadSysEmail() *PlatformService { rows := make([]*PlatformService, 0) diff --git a/apps/services/modules/console/api_upload.go b/apps/services/modules/console/api_upload.go index bb6dd439..d8385a31 100644 --- a/apps/services/modules/console/api_upload.go +++ b/apps/services/modules/console/api_upload.go @@ -11,13 +11,10 @@ package console // 3. 预签名 PUT 会把 Content-Type 计入签名,故前端 PUT 必须带同样的 Content-Type(uploadFile 已对齐)。 import ( - "fmt" - "net/url" "path" "strings" "yunyan/comm" - "yunyan/lego/sys/postgres" "yunyan/pb" "yunyan/sys/aliyun/oss" @@ -26,78 +23,18 @@ import ( // ossStorageProvider svc_config 中对象存储服务的 provider 值(与 api_svctemplate.go 的 // storage_aliyun_oss 模板 provider 一致)。 -const ossStorageProvider = "aliyun_oss" +const ossStorageProvider = comm.AliyunOSSProvider -// uploadRoot 所有上传对象强制置于该根前缀下(服务端兜底,前端传什么 path 都逃不出去)。 -// -// ⚠️ 改这个值只影响【之后】上传的文件:已经存进 productimage / updatepackageaddress 的 -// 历史地址是死的绝对 URL,不会跟着搬家,OSS 上的旧 yunyan/ 目录也还在。要么保留旧目录, -// 要么在 OSS 控制台把对象拷过来、再把库里那些 URL 一起改,两件事缺一不可。 -const uploadRoot = "EAIMAR" +// uploadRoot 定义已抽到 comm.OSSUploadRoot,与 App 上传(user_getuploadurl)共用。 +const uploadRoot = comm.OSSUploadRoot -// aliyunOSSConf 从 svc_config 解出的一份可用的阿里云 OSS 凭证。 -type aliyunOSSConf struct { - Endpoint string - AccessKeyId string - AccessSecret string - Bucket string - Domain string // 可选:自定义 CDN 域名,配了则文件下载 URL 走该域名 -} +// aliyunOSSConf 定义已抽到 comm,供 console 后台直传与 App 上传(user_getuploadurl) +// 共用同一份读取逻辑——两边必须落在同一个桶里,否则后台传的固件和 App 传的录音会分家。 +type aliyunOSSConf = comm.AliyunOSSConf -// loadAliyunOSSConf 读取阿里云 OSS 有效配置。 -// 优先读【系统配置 → 对象存储】(平台自用表 sys_service_config,category=storage, provider=aliyun_oss); -// 该表无有效配置时,回退旧的【第三方服务配置 → 存储】(svc_config) 以兼容存量部署。 -// 加密字段(access_key_id/access_key_secret) 用 console 的 EncryptKey 解密。 +// loadAliyunOSSConf 读取阿里云 OSS 有效配置(取值顺序见 comm.LoadAliyunOSSConf)。 func (this *serverComp) loadAliyunOSSConf(appName string) (*aliyunOSSConf, error) { - if oc := this.loadSysStorageAliyun(); oc != nil { - return oc, nil - } - find := func(app string) *ThirdSvcConfig { - rows := make([]*ThirdSvcConfig, 0) - if err := postgres.Find(comm.TableSvcConfig, &rows, - "app_name=? AND provider=? AND enable=?", app, ossStorageProvider, 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 := svcDecrypt(this.options.EncryptKey, 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 oc.Endpoint == "" || oc.AccessKeyId == "" || oc.AccessSecret == "" || oc.Bucket == "" { - return nil, fmt.Errorf("阿里云 OSS 配置不完整:endpoint / access_key_id / access_key_secret / bucket 均为必填") - } - // 规范化 endpoint 的 scheme:SDK 对无 scheme 的 endpoint 默认拼 http://, - // 会导致 https 后台页直传时被浏览器按「混合内容」拦截;这里统一升到 https。 - if !strings.HasPrefix(oc.Endpoint, "http://") && !strings.HasPrefix(oc.Endpoint, "https://") { - oc.Endpoint = "https://" + oc.Endpoint - } - return oc, nil + return comm.LoadAliyunOSSConf(appName, this.options.EncryptKey) } // buildObjectKey 生成对象存储 key,强制置于 uploadRoot 根下并做防路径穿越清洗。 @@ -163,20 +100,6 @@ func (this *serverComp) getUploadURL(c *gin.Context) { } // 公网访问 URL(存进 updatepackageaddress 的裸地址): // 配了自定义域名 → https:///(走 CDN);否则去掉预签名 query 的裸 OSS 地址。 - publicURL := uploadURL - if d := strings.TrimSpace(conf.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 会按段转义(`/` 保留)。 - publicURL = (&url.URL{Scheme: "https", Host: d, Path: "/" + key}).String() - } else if u, e := url.Parse(uploadURL); e == nil { - u.RawQuery = "" - publicURL = u.String() - } + publicURL := conf.PublicURL(uploadURL, key) writeOK(c, gin.H{"upload_url": uploadURL, "url": publicURL, "key": key}) } diff --git a/apps/services/modules/user/api_getuploadurl.go b/apps/services/modules/user/api_getuploadurl.go new file mode 100644 index 00000000..e43a2a4f --- /dev/null +++ b/apps/services/modules/user/api_getuploadurl.go @@ -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//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 +} diff --git a/apps/services/pb/user_msg.pb.go b/apps/services/pb/user_msg.pb.go index bf0f0d60..00aa5d3f 100644 --- a/apps/services/pb/user_msg.pb.go +++ b/apps/services/pb/user_msg.pb.go @@ -2920,6 +2920,149 @@ func (x *UserTranslateResp) GetDetectedFrom() string { return "" } +// 请求文件上传地址(录音、聊天图片等一切客户端产生的文件) +// +// 客户端**不再持有对象存储的长期密钥**:由服务端读【第三方服务配置 → 存储】 +// 里启用的阿里云 OSS 配置,签发一个短时效的预签名 PUT URL,客户端拿着它直传。 +// object key 由服务端强制拼成 EAIMAR/User///<日期>/<随机>., +// uid 取自会话不采信客户端,所以一个用户传不到别人的目录下。 +// 路由 user_getuploadurl +type UserGetUploadUrlReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Scene string `protobuf:"bytes,1,opt,name=scene,proto3" json:"scene,omitempty"` //用途,决定 object key 里那一段目录:LocalAudio(录音) / ChatImages(聊天图片) + Filename string `protobuf:"bytes,2,opt,name=filename,proto3" json:"filename,omitempty"` //原文件名,服务端只取它的扩展名,目录部分一律丢弃 + Contenttype string `protobuf:"bytes,3,opt,name=contenttype,proto3" json:"contenttype,omitempty"` //可选。留空则由服务端按扩展名推断,推断结果随响应回带 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserGetUploadUrlReq) Reset() { + *x = UserGetUploadUrlReq{} + mi := &file_user_user_msg_proto_msgTypes[53] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserGetUploadUrlReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserGetUploadUrlReq) ProtoMessage() {} + +func (x *UserGetUploadUrlReq) ProtoReflect() protoreflect.Message { + mi := &file_user_user_msg_proto_msgTypes[53] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserGetUploadUrlReq.ProtoReflect.Descriptor instead. +func (*UserGetUploadUrlReq) Descriptor() ([]byte, []int) { + return file_user_user_msg_proto_rawDescGZIP(), []int{53} +} + +func (x *UserGetUploadUrlReq) GetScene() string { + if x != nil { + return x.Scene + } + return "" +} + +func (x *UserGetUploadUrlReq) GetFilename() string { + if x != nil { + return x.Filename + } + return "" +} + +func (x *UserGetUploadUrlReq) GetContenttype() string { + if x != nil { + return x.Contenttype + } + return "" +} + +type UserGetUploadUrlResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Uploadurl string `protobuf:"bytes,1,opt,name=uploadurl,proto3" json:"uploadurl,omitempty"` //预签名 PUT URL,客户端直接 PUT 文件体到这个地址 + Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` //上传成功后的访问地址(配了自定义域名就走域名) + Key string `protobuf:"bytes,3,opt,name=key,proto3" json:"key,omitempty"` //object key,排查用 + Contenttype string `protobuf:"bytes,4,opt,name=contenttype,proto3" json:"contenttype,omitempty"` //⚠️ PUT 时必须带这个 Content-Type,它计入了签名,不一致会 403 + Expiresec int64 `protobuf:"varint,5,opt,name=expiresec,proto3" json:"expiresec,omitempty"` //预签名有效期(秒),超时要重新申请 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserGetUploadUrlResp) Reset() { + *x = UserGetUploadUrlResp{} + mi := &file_user_user_msg_proto_msgTypes[54] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserGetUploadUrlResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserGetUploadUrlResp) ProtoMessage() {} + +func (x *UserGetUploadUrlResp) ProtoReflect() protoreflect.Message { + mi := &file_user_user_msg_proto_msgTypes[54] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserGetUploadUrlResp.ProtoReflect.Descriptor instead. +func (*UserGetUploadUrlResp) Descriptor() ([]byte, []int) { + return file_user_user_msg_proto_rawDescGZIP(), []int{54} +} + +func (x *UserGetUploadUrlResp) GetUploadurl() string { + if x != nil { + return x.Uploadurl + } + return "" +} + +func (x *UserGetUploadUrlResp) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *UserGetUploadUrlResp) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *UserGetUploadUrlResp) GetContenttype() string { + if x != nil { + return x.Contenttype + } + return "" +} + +func (x *UserGetUploadUrlResp) GetExpiresec() int64 { + if x != nil { + return x.Expiresec + } + return 0 +} + var File_user_user_msg_proto protoreflect.FileDescriptor const file_user_user_msg_proto_rawDesc = "" + @@ -3148,7 +3291,17 @@ const file_user_user_msg_proto_rawDesc = "" + "\x04text\x18\x01 \x01(\tR\x04text\x12\x1a\n" + "\bprovider\x18\x02 \x01(\tR\bprovider\x12\x14\n" + "\x05svcid\x18\x03 \x01(\tR\x05svcid\x12#\n" + - "\rdetected_from\x18\x04 \x01(\tR\fdetectedFromB\x06Z\x04.;pbb\x06proto3" + "\rdetected_from\x18\x04 \x01(\tR\fdetectedFrom\"i\n" + + "\x13UserGetUploadUrlReq\x12\x14\n" + + "\x05scene\x18\x01 \x01(\tR\x05scene\x12\x1a\n" + + "\bfilename\x18\x02 \x01(\tR\bfilename\x12 \n" + + "\vcontenttype\x18\x03 \x01(\tR\vcontenttype\"\x98\x01\n" + + "\x14UserGetUploadUrlResp\x12\x1c\n" + + "\tuploadurl\x18\x01 \x01(\tR\tuploadurl\x12\x10\n" + + "\x03url\x18\x02 \x01(\tR\x03url\x12\x10\n" + + "\x03key\x18\x03 \x01(\tR\x03key\x12 \n" + + "\vcontenttype\x18\x04 \x01(\tR\vcontenttype\x12\x1c\n" + + "\texpiresec\x18\x05 \x01(\x03R\texpiresecB\x06Z\x04.;pbb\x06proto3" var ( file_user_user_msg_proto_rawDescOnce sync.Once @@ -3162,7 +3315,7 @@ func file_user_user_msg_proto_rawDescGZIP() []byte { return file_user_user_msg_proto_rawDescData } -var file_user_user_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 58) +var file_user_user_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 60) var file_user_user_msg_proto_goTypes = []any{ (*UserSginReq)(nil), // 0: UserSginReq (*UserSginResp)(nil), // 1: UserSginResp @@ -3217,53 +3370,55 @@ var file_user_user_msg_proto_goTypes = []any{ (*UserGetIdVerifyResp)(nil), // 50: UserGetIdVerifyResp (*UserTranslateReq)(nil), // 51: UserTranslateReq (*UserTranslateResp)(nil), // 52: UserTranslateResp - nil, // 53: UserGetAppConfigResp.EnvEntry - nil, // 54: UserGetAppConfigResp.McpsEntry - nil, // 55: UserPushActionReq.ActionsEntry - nil, // 56: UserUsagesReq.UsagesEntry - nil, // 57: UserUsagesResp.UsagesEntry - (SginTyoe)(0), // 58: SginTyoe - (UserChannel)(0), // 59: UserChannel - (*DBUser)(nil), // 60: DBUser - (*DBUserDivice)(nil), // 61: DBUserDivice - (MCPServerType)(0), // 62: MCPServerType - (*DBAgent)(nil), // 63: DBAgent - (*DBProduct)(nil), // 64: DBProduct - (*DBProductVersion)(nil), // 65: DBProductVersion - (*DBChannelApp)(nil), // 66: DBChannelApp - (UsageType)(0), // 67: UsageType - (*DBWakeupVoice)(nil), // 68: DBWakeupVoice - (*DBUserStatistics)(nil), // 69: DBUserStatistics - (*DBUuserAppSetting)(nil), // 70: DBUuserAppSetting + (*UserGetUploadUrlReq)(nil), // 53: UserGetUploadUrlReq + (*UserGetUploadUrlResp)(nil), // 54: UserGetUploadUrlResp + nil, // 55: UserGetAppConfigResp.EnvEntry + nil, // 56: UserGetAppConfigResp.McpsEntry + nil, // 57: UserPushActionReq.ActionsEntry + nil, // 58: UserUsagesReq.UsagesEntry + nil, // 59: UserUsagesResp.UsagesEntry + (SginTyoe)(0), // 60: SginTyoe + (UserChannel)(0), // 61: UserChannel + (*DBUser)(nil), // 62: DBUser + (*DBUserDivice)(nil), // 63: DBUserDivice + (MCPServerType)(0), // 64: MCPServerType + (*DBAgent)(nil), // 65: DBAgent + (*DBProduct)(nil), // 66: DBProduct + (*DBProductVersion)(nil), // 67: DBProductVersion + (*DBChannelApp)(nil), // 68: DBChannelApp + (UsageType)(0), // 69: UsageType + (*DBWakeupVoice)(nil), // 70: DBWakeupVoice + (*DBUserStatistics)(nil), // 71: DBUserStatistics + (*DBUuserAppSetting)(nil), // 72: DBUuserAppSetting } var file_user_user_msg_proto_depIdxs = []int32{ - 58, // 0: UserSginReq.stype:type_name -> SginTyoe - 59, // 1: UserSginReq.channel:type_name -> UserChannel - 60, // 2: UserSginResp.user:type_name -> DBUser - 61, // 3: UserSginResp.devices:type_name -> DBUserDivice - 59, // 4: UserGetInfoReq.channel:type_name -> UserChannel - 60, // 5: UserGetInfoResp.user:type_name -> DBUser - 61, // 6: UserGetInfoResp.devices:type_name -> DBUserDivice - 61, // 7: UserBindDeviceResp.device:type_name -> DBUserDivice - 61, // 8: UserBindAuthCodeResp.device:type_name -> DBUserDivice - 62, // 9: McpServer.type:type_name -> MCPServerType - 53, // 10: UserGetAppConfigResp.env:type_name -> UserGetAppConfigResp.EnvEntry - 63, // 11: UserGetAppConfigResp.agents:type_name -> DBAgent - 54, // 12: UserGetAppConfigResp.mcps:type_name -> UserGetAppConfigResp.McpsEntry - 64, // 13: UserGetAppConfigResp.products:type_name -> DBProduct - 64, // 14: UserGetProductResp.product:type_name -> DBProduct - 65, // 15: UserGetProductVersionsResp.versions:type_name -> DBProductVersion - 66, // 16: UserGetChannelAppsResp.apps:type_name -> DBChannelApp - 66, // 17: UserGetChannelAppResp.app:type_name -> DBChannelApp - 55, // 18: UserPushActionReq.actions:type_name -> UserPushActionReq.ActionsEntry - 61, // 19: UserGetDevicesResp.devices:type_name -> DBUserDivice - 67, // 20: UserUsagesReq.usagetype:type_name -> UsageType - 56, // 21: UserUsagesReq.usages:type_name -> UserUsagesReq.UsagesEntry - 57, // 22: UserUsagesResp.usages:type_name -> UserUsagesResp.UsagesEntry - 68, // 23: UserGetWakeupVoicesResp.voices:type_name -> DBWakeupVoice - 69, // 24: UserGetStatisticsResp.statistics:type_name -> DBUserStatistics - 70, // 25: UserGetAppSettingResp.setting:type_name -> DBUuserAppSetting - 70, // 26: UserUpdateAppSettingReq.setting:type_name -> DBUuserAppSetting + 60, // 0: UserSginReq.stype:type_name -> SginTyoe + 61, // 1: UserSginReq.channel:type_name -> UserChannel + 62, // 2: UserSginResp.user:type_name -> DBUser + 63, // 3: UserSginResp.devices:type_name -> DBUserDivice + 61, // 4: UserGetInfoReq.channel:type_name -> UserChannel + 62, // 5: UserGetInfoResp.user:type_name -> DBUser + 63, // 6: UserGetInfoResp.devices:type_name -> DBUserDivice + 63, // 7: UserBindDeviceResp.device:type_name -> DBUserDivice + 63, // 8: UserBindAuthCodeResp.device:type_name -> DBUserDivice + 64, // 9: McpServer.type:type_name -> MCPServerType + 55, // 10: UserGetAppConfigResp.env:type_name -> UserGetAppConfigResp.EnvEntry + 65, // 11: UserGetAppConfigResp.agents:type_name -> DBAgent + 56, // 12: UserGetAppConfigResp.mcps:type_name -> UserGetAppConfigResp.McpsEntry + 66, // 13: UserGetAppConfigResp.products:type_name -> DBProduct + 66, // 14: UserGetProductResp.product:type_name -> DBProduct + 67, // 15: UserGetProductVersionsResp.versions:type_name -> DBProductVersion + 68, // 16: UserGetChannelAppsResp.apps:type_name -> DBChannelApp + 68, // 17: UserGetChannelAppResp.app:type_name -> DBChannelApp + 57, // 18: UserPushActionReq.actions:type_name -> UserPushActionReq.ActionsEntry + 63, // 19: UserGetDevicesResp.devices:type_name -> DBUserDivice + 69, // 20: UserUsagesReq.usagetype:type_name -> UsageType + 58, // 21: UserUsagesReq.usages:type_name -> UserUsagesReq.UsagesEntry + 59, // 22: UserUsagesResp.usages:type_name -> UserUsagesResp.UsagesEntry + 70, // 23: UserGetWakeupVoicesResp.voices:type_name -> DBWakeupVoice + 71, // 24: UserGetStatisticsResp.statistics:type_name -> DBUserStatistics + 72, // 25: UserGetAppSettingResp.setting:type_name -> DBUuserAppSetting + 72, // 26: UserUpdateAppSettingReq.setting:type_name -> DBUuserAppSetting 19, // 27: UserGetAppConfigResp.McpsEntry.value:type_name -> McpServer 28, // [28:28] is the sub-list for method output_type 28, // [28:28] is the sub-list for method input_type @@ -3286,7 +3441,7 @@ func file_user_user_msg_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_user_user_msg_proto_rawDesc), len(file_user_user_msg_proto_rawDesc)), NumEnums: 0, - NumMessages: 58, + NumMessages: 60, NumExtensions: 0, NumServices: 0, },