Browse Source

文件上传改走服务端签发预签名 URL,客户端直传阿里云 OSS

客户端不再持有对象存储的长期密钥。

旧实现用腾讯云 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
Rodger-Wang 1 month ago
parent
commit
8d580a545d
  1. 50
      CLAUDE.md
  2. 172
      apps/client/lib/core/utils/upload_oss.dart
  3. 13
      apps/client/lib/data/services/network/api.dart
  4. 5
      apps/client/lib/modules/agent/controllers/agent_controller.dart
  5. 20
      apps/proto/user/user_msg.proto
  6. 148
      apps/services/comm/ossconf.go
  7. 33
      apps/services/modules/console/api_sysconfig.go
  8. 95
      apps/services/modules/console/api_upload.go
  9. 162
      apps/services/modules/user/api_getuploadurl.go
  10. 251
      apps/services/pb/user_msg.pb.go

50
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>/<rootDir>/<日期>/<随机>` | 服务端按会话 uid 拼死 `EAIMAR/User/<uid>/<scene>/<yyyy/MM/dd>/<12位随机>.<ext>` |
| 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/<uid>/` 前缀**(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/<svc>/deploy.sh` 中**写死了真实的 DSN / 密钥 / 服务器 IP / 仓库密码,且纳入 git**——这是用户在私有仓库下**明确接受**的取舍。修改这些文件时注意它们会随仓库提交。

172
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>/` 前缀**——
/// 目录由服务端按会话里的 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;
}
}

13
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 服务。
// 云账号凭据因此不必下发到客户端。
//

5
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>/ 前缀**——
// 目录由服务端按会话里的 uid 拼死,客户端再拼一层只会变成重复路径。
var ImageUrl = await UploadOss.upload(
filepath: permanentImagePath,
rootDir: 'User/${User.instance.uid}/ChatImages');
filepath: permanentImagePath, rootDir: 'ChatImages');
// 显示的文本,如果没有提供则使用默认值
final displayText = text?.isNotEmpty == true ? text! : '[图片]';

20
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>/<scene>/<日期>/<随机>.<ext>,
//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; //预签名有效期(秒),超时要重新申请
}

148
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>) 优先于全局行(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
}

33
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)

95
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://<domain>/<key>(走 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})
}

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

251
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>/<scene>/<日期>/<随机>.<ext>,
// 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,
},

Loading…
Cancel
Save