Browse Source

services,admin,client: 服务配置按应用隔离、应用参数下线、客户端凭据走加密 v3

后台
- 服务配置去掉「全局默认」层,svc_config / svc_region_override / echomeet_orch
  / echomeet_orch_setting / echomeet_template 五张表统一按 app_name 隔离,
  启动迁移 migrate_scope.go 幂等分配(首个应用原地改名保 id,其余拷贝);
  写接口 app_name 必填(scope_check.go)。
- 类别落库 svc_category,可增删改;内置 11 个 id 不可改不可删,运行时语义仍只挂 comm.SvcCat*。
- 「应用参数」标签页下线:业务库 config 表不再随 user_getappconfig 下发,
  migrate_appparams.go 把 AGENT_TYPE / MOBILE_ELF_* 迁到服务 llm_mobile_elf、
  iapCustomerServiceQQ 迁到 app_params,COMPUTE_*/NEWUSER_GIFT_* 留在 config 表不下发,
  无人读的音乐/公码/导航/COS_*/MeetServers 删除,其余键保留并在启动日志告警。
- 删除 console appcfg/* 与 api 模块的 get/add/update/delconfig、唤醒音四个接口(无调用方)。
- 修 getmcpservers 仍查全局层导致 MCP 类服务永远读不到。

客户端
- 凭据改走结构化通道:user_getappconfig_v3 整体 AES-CBC 加密(api_crypto.dart,
  拦截器须排在 AuthInterceptor 之前),AppConfig.cred(svcId, field, envKey) 取值,
  老键名只作兜底;lib 内 AppConfig.env( 已降到 0 处调用。
- 图片翻译的 ALIBABA_VL_MODEL / ENDPOINT 改挂 ast_alibaba。

启动日志
- 删除 sys/auth/firebase(google_auth 的重复实现,零调用方,且同一错误刷两行)。
- api 模块不再每次启动无条件插入默认超管(Duplicate entry 'admin')。
- sys/auth/* 五个包的 logger 名从复制来的 sys.tavily 各改各名;
  google_auth 启动时即判断配置是否可用。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 4 weeks ago
parent
commit
e00b617025
  1. 135
      CLAUDE.md
  2. 264
      apps/admin/app/components/AppEnvTab.vue
  3. 7
      apps/admin/app/pages/appconfig.vue
  4. 7
      apps/admin/app/pages/globalconfig.vue
  5. 52
      apps/admin/app/pages/meettemplates.vue
  6. 280
      apps/admin/app/pages/serviceconfig.vue
  7. 5
      apps/admin/app/utils/menus.ts
  8. 5
      apps/client/.env.example
  9. 4
      apps/client/lib/core/utils/ai_navigation.dart
  10. 45
      apps/client/lib/data/models/appconfig.dart
  11. 41
      apps/client/lib/data/models/appconfig_model.dart
  12. 24
      apps/client/lib/data/models/appconfig_model.g.dart
  13. 13
      apps/client/lib/data/services/alibaba_image_translation_service.dart
  14. 6
      apps/client/lib/data/services/bailian_multimodal_service.dart
  15. 6
      apps/client/lib/data/services/deapsound_ai_service.dart
  16. 6
      apps/client/lib/data/services/microsoft_translation_service.dart
  17. 19
      apps/client/lib/data/services/network/api.dart
  18. 86
      apps/client/lib/data/services/network/api_crypto.dart
  19. 4
      apps/client/lib/data/services/network/dio_manager.dart
  20. 10
      apps/client/lib/data/services/speech_impl/azure_asr_service.dart
  21. 28
      apps/client/lib/data/services/speech_impl/azure_ast_service.dart
  22. 4
      apps/client/lib/data/services/speech_impl/azure_tts_service.dart
  23. 3
      apps/client/lib/modules/goods/views/widgets/goods_footer_section.dart
  24. 3
      apps/client/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart
  25. 4
      apps/client/lib/modules/voice_replication/views/recorder_bottom_sheet.dart
  26. 32
      apps/client/pubspec.lock
  27. 1
      apps/client/pubspec.yaml
  28. 35
      apps/client/test/api_crypto_test.dart
  29. 44
      apps/client/test/appconfig_cred_test.dart
  30. 44
      apps/client/test/appconfig_model_test.dart
  31. 28
      apps/services/comm/appscope.go
  32. 31
      apps/services/comm/appscope_test.go
  33. 2
      apps/services/comm/const.go
  34. 3
      apps/services/comm/svcpool.go
  35. 27
      apps/services/modules/api/api_addconfig.go
  36. 21
      apps/services/modules/api/api_addwakeupvoice.go
  37. 27
      apps/services/modules/api/api_delconfig.go
  38. 21
      apps/services/modules/api/api_delwakeupvoice.go
  39. 28
      apps/services/modules/api/api_getconfig.go
  40. 28
      apps/services/modules/api/api_getwakeupvoice.go
  41. 28
      apps/services/modules/api/api_getwakeupvoices.go
  42. 27
      apps/services/modules/api/api_updateconfig.go
  43. 21
      apps/services/modules/api/api_updatewakeupvoice.go
  44. 3
      apps/services/modules/api/core.go
  45. 11
      apps/services/modules/api/interceptor_permission.go
  46. 155
      apps/services/modules/api/model.go
  47. 6
      apps/services/modules/api/model_test.go
  48. 155
      apps/services/modules/console/api_appconfig.go
  49. 81
      apps/services/modules/console/api_config.go
  50. 8
      apps/services/modules/console/api_device.go
  51. 171
      apps/services/modules/console/api_svccategory.go
  52. 113
      apps/services/modules/console/api_svcconfig.go
  53. 53
      apps/services/modules/console/api_svctemplate.go
  54. 44
      apps/services/modules/console/api_svctemplate_test.go
  55. 301
      apps/services/modules/console/migrate_appparams.go
  56. 9
      apps/services/modules/console/migrate_envtosvc.go
  57. 263
      apps/services/modules/console/migrate_scope.go
  58. 23
      apps/services/modules/console/migrate_scope_test.go
  59. 10
      apps/services/modules/console/model_registry.go
  60. 74
      apps/services/modules/console/model_svccategory.go
  61. 23
      apps/services/modules/console/registry.go
  62. 97
      apps/services/modules/console/scope_check.go
  63. 21
      apps/services/modules/console/server.go
  64. 15
      apps/services/modules/echomeet/model.go
  65. 7
      apps/services/modules/user/api_getappconfig.go
  66. 33
      apps/services/modules/user/api_getwakeupvoices.go
  67. 7
      apps/services/modules/user/api_v2_getappconfig.go
  68. 13
      apps/services/modules/user/api_v3_getappconfig.go
  69. 22
      apps/services/modules/user/model_cache.go
  70. 35
      apps/services/modules/user/model_config.go
  71. 77
      apps/services/modules/user/model_user.go
  72. 26
      apps/services/modules/user/svcresolve.go
  73. 10
      apps/services/services/home/main.go
  74. 4
      apps/services/sys/auth/apple/options.go
  75. 4
      apps/services/sys/auth/facebook/options.go
  76. 70
      apps/services/sys/auth/firebase/auth.go
  77. 41
      apps/services/sys/auth/firebase/core.go
  78. 44
      apps/services/sys/auth/firebase/options.go
  79. 20
      apps/services/sys/auth/firebase/sys_test.go
  80. 16
      apps/services/sys/auth/google/auth.go
  81. 4
      apps/services/sys/auth/google/options.go
  82. 4
      apps/services/sys/auth/wechat/options.go
  83. 6
      deploy/app/README.md
  84. 1
      deploy/app/confs/gateway.yaml.example
  85. 6
      deploy/app/confs/home.yaml.example
  86. 7
      deploy/app/env/env.example

135
CLAUDE.md

@ -461,8 +461,9 @@ MAC 导入后已实测打通(2026-09-10):`user_binddevice` 返回
- `dotenv.env['X']` = `apps/client/.env`,本机/渠道级,编译进包; - `dotenv.env['X']` = `apps/client/.env`,本机/渠道级,编译进包;
- `AppConfig.env('X')`([appconfig.dart](apps/client/lib/data/models/appconfig.dart))= 后端 - `AppConfig.env('X')`([appconfig.dart](apps/client/lib/data/models/appconfig.dart))= 后端
`user_getappconfig` 下发的配置(env / agents / mcps / products),由 **console 后台**维护。 `user_getappconfig` 下发的配置(env / agents / mcps / products),由 **console 后台**维护。
⚠️ 2026-09-12 起 `env` 里的**凭据来自 `svc_config` 的 `env_key`**,不再来自 `global_config`; ⚠️ 2026-09-14 起 `env` **只来自 `svc_config` 字段的 `env_key`**:`global_config`(9-12)和业务库
业务库 `config` 表只剩应用自有的业务参数。详见下文「凭据归一」。 `config` 表(9-14)都不再参与下发。客户端读的应用参数(AGENT_TYPE / MOBILE_ELF_* / iapCustomerServiceQQ)
也在 svc_config 里(服务 `llm_mobile_elf` / `app_params`)。详见下文「凭据归一」及「应用参数标签页下线」。
- 客户端 `UserGetAppConfigResp`、`DBAgent`、`DBProduct` 等模型是**手写镜像**服务端 - 客户端 `UserGetAppConfigResp`、`DBAgent`、`DBProduct` 等模型是**手写镜像**服务端
`pb.UserGetAppConfigResp`([user_msg.proto:168](apps/proto/user/user_msg.proto#L168))的, `pb.UserGetAppConfigResp`([user_msg.proto:168](apps/proto/user/user_msg.proto#L168))的,
**没有代码生成把两边绑在一起**。改 proto 字段后要手动同步 **没有代码生成把两边绑在一起**。改 proto 字段后要手动同步
@ -473,6 +474,31 @@ MAC 导入后已实测打通(2026-09-10):`user_binddevice` 返回
- `user` 模块的 v2/v3 api 把 `GetAppConfig`/`GetAgents` 列入 `EncryptMsgs`(加密协议), - `user` 模块的 v2/v3 api 把 `GetAppConfig`/`GetAgents` 列入 `EncryptMsgs`(加密协议),
抓包看到的是密文不是明文 JSON,别以为接口挂了。 抓包看到的是密文不是明文 JSON,别以为接口挂了。
### 客户端凭据走结构化通道:加密 v3 + `AppConfig.cred()`(2026-09-14)
EAIMAR 客户端不再从 `env` 兼容层拿第三方凭据,改走**自己的路**:
| 环节 | 位置 |
|---|---|
| 请求 | `Api.getappconfig()` → `.env` 配了 `GATEWAY_ENCRYPT_KEY` 就打 **`user_getappconfig_v3`**(整体 AES 加密),否则回退明文 v1 |
| 解密 | [api_crypto.dart](apps/client/lib/data/services/network/api_crypto.dart) 的 `ApiDecryptInterceptor`,**必须排在 `AuthInterceptor` 之前**(它一看到不是业务 JSON 就当失败) |
| 模型 | `UserGetAppConfigResp.thirdsvcs: List<ThirdSvc>`(id/name/provider/categories/fields),缺键 → 空列表 |
| 取值 | `AppConfig.cred(svcId, fieldKey, envKey)`:**thirdsvcs 的 (服务 id, 字段) 优先,退回 env[老键名],两边都没有 → null** |
- 服务 id / 字段名与服务端 `migrate_envtosvc.go` 的落点表一一对应(`stt_azure.subscription_key`、
`ast_alibaba.app_key`、`llm_doubao.api_key`…),**改哪边都要同改**;老键名只作兜底。
- 加密口径(`lego/utils/crypto/aes/cbc.go`):AES-CBC,IV 固定 16 个 `'0'`,PKCS5/7,密文 base64,
响应头 `X-Encrypted: 1`、`Content-Type: text/plain`。`test/api_crypto_test.dart` 的密文是 **openssl 按 Go 口径**
生成的金向量——用同一套 Dart 加密再解密只能证明自洽,证明不了与 Go 端一致。
- ⚠️ **`GATEWAY_ENCRYPT_KEY` 要进客户端 `.env`**(gitignore,值与服务器 `deploy/app` 的 `.env` 一致,测试/正式同一把);
没配就静默回退 v1,`thirdsvcs` 为空、`AppConfig.usingThirdSvcs == false`——排查「明明配了服务却读不到」先看这个。
密钥不一致的表现是解密后 PKCS7 去填充失败 → 拦截器 reject,报错文案指向密钥,别去查网络。
- ⚠️ **v3 必须在网关白名单**(`deploy/app/confs/gateway.yaml.example` 与各机 `confs/gateway.yaml`):splash 在**登录前**
就取配置,少这一行返回 `code:18` 然后整个启动流程走兜底。测试机已加;**正式机 ym-a11 的 gateway.yaml 尚未加**(要重启容器),
新客户端上正式前必须补。
- 未登录时 v3 的 `filterSvcsByVip` 拿不到 uid → 按有 VIP 放行;实名(11) 类照旧永不下发。
- `cred()` 返回 null 而不是空串:空串会把调用方的 `?? 默认值` 顶掉(`AZURE_TRANSLATION_ENDPOINT` 栽过)。
### 游客登录与功能闸门(2026-08-29 改) ### 游客登录与功能闸门(2026-08-29 改)
游客登录**走服务端** `api_sgin` 的 `Tourists` 分支(`stype=6` + `phonemac=设备id`),拿真实雪花 uid 与 JWT。 游客登录**走服务端** `api_sgin` 的 `Tourists` 分支(`stype=6` + `phonemac=设备id`),拿真实雪花 uid 与 JWT。
@ -1486,8 +1512,8 @@ apps/products/regions/channelid 四维,而它们这四项**本来就是空的*
| 原菜单项 | 处置 | 依据 | | 原菜单项 | 处置 | 依据 |
|---|---|---| |---|---|---|
| 全局环境配置 | **下线**(页面只留重定向,表保留待人工 DROP) | `global_config` 473 行按区域整份复制凭据,且**悄悄覆盖**业务库 `config` 表的同名键 | | 全局环境配置 | **下线**(页面只留重定向,表保留待人工 DROP) | `global_config` 473 行按区域整份复制凭据,且**悄悄覆盖**业务库 `config` 表的同名键 |
| 应用环境配置 | 并入「服务与环境配置 → 应用参数」标签页 | 凭据搬走后只剩应用自己的业务参数 | | 应用环境配置 | 先并入「服务与环境配置 → 应用参数」标签页;**2026-09-14 连标签页也下线**(见下文) | 凭据搬走后只剩应用自己的业务参数,而这些也搬进了 svc_config |
| 第三方服务配置 | 改名「服务与环境配置」,成为凭据唯一出处 | — | | 第三方服务配置 | 改名「服务与环境配置」,成为凭据唯一出处;2026-09-14 菜单改回「第三方服务配置」 | — |
| Agent 配置 / 通话翻译配置 | **删除**(页面 + console 接口) | `agent_config`、`call_translate_rule` 在测试机与正式机上**都是 0 行**;通话翻译选路全在端侧 `language_manager.findBestMatchingProvider` | | Agent 配置 / 通话翻译配置 | **删除**(页面 + console 接口) | `agent_config`、`call_translate_rule` 在测试机与正式机上**都是 0 行**;通话翻译选路全在端侧 `language_manager.findBestMatchingProvider` |
| 会议记录服务 | **删除页面**,⚠️ 数据与运行时保留 | 见下方警告 | | 会议记录服务 | **删除页面**,⚠️ 数据与运行时保留 | 见下方警告 |
@ -1499,7 +1525,7 @@ apps/products/regions/channelid 四维,而它们这四项**本来就是空的*
**现在的下发链路**([svcresolve.go](apps/services/modules/user/svcresolve.go) 的 `appEnvForUser`): **现在的下发链路**([svcresolve.go](apps/services/modules/user/svcresolve.go) 的 `appEnvForUser`):
``` ```
env = 业务库 config 表(应用自有业务参数) ← svc_config 按字段的 env_key 覆盖 env = svc_config 各服务字段按 env_key 平铺(2026-09-14 起;9-12 ~ 9-14 之间还有业务库 config 表打底)
``` ```
- `comm.SvcField` 多了一个 **`EnvKey`**(后台字段表里的「下发键名」列):非空时该字段的明文值 - `comm.SvcField` 多了一个 **`EnvKey`**(后台字段表里的「下发键名」列):非空时该字段的明文值
@ -1526,6 +1552,83 @@ console 每次启动跑、幂等(只建不存在的服务、只填空着的字
`migrateLicenseToDeviceMac` 的取舍)。业务库 `config` 里**已确认迁走**的键则会删除。 `migrateLicenseToDeviceMac` 的取舍)。业务库 `config` 里**已确认迁走**的键则会删除。
确认新链路跑稳后由人手工 `DROP TABLE global_config;`。 确认新链路跑稳后由人手工 `DROP TABLE global_config;`。
### 「应用参数」标签页下线:业务库 config 表不再下发(2026-09-14)
后台只剩「第三方服务配置」一个页面。原第二个标签页是业务库 `config` 表的裸 key/value 编辑器,
而 `user_getappconfig` 把这张表**整张**塞进 `env`——COS 密钥、灵犀产品密钥、连「会员与算力」页写进去的
`COMPUTE_RATE_*` / `COMPUTE_GATE` 都明文发到每个客户端。逐项 grep 过服务端/客户端/原生插件/后台后,
两台机上完全相同的 22 行处置如下(迁移在 [migrate_appparams.go](apps/services/modules/console/migrate_appparams.go),
console 启动跑、幂等、排在 `migrateGlobalScopeToApps` 之后):
| config 表的键 | 去向 |
|---|---|
| `AGENT_TYPE` / `MOBILE_ELF_AGENT_ID` / `MOBILE_ELF_PRODUCT_ID` / `MOBILE_ELF_PRODUCT_KEY`(加密) / `YIDONG_PID` | 服务 **`llm_mobile_elf`**(中国移动 灵犀 / 移动精灵,类别「文本大模型」只作分组),字段带同名 `env_key` |
| `iapCustomerServiceQQ` | 服务 **`app_params`**(内置类别 12「应用参数」),字段 `iap_customer_service_qq`;要加新的应用参数就往这个模板加字段 |
| `COMPUTE_*` / `NEWUSER_GIFT_*` / `VIP_WARN_DAYS` / `COMPUTE_WARN` / `COMPUTE_LEGACY_MIGRATED` | 留在 `config` 表,由「会员与算力」页维护,**不下发** |
| 音乐 ×5 / 公码 ×2 / 导航 / `COS_*` ×5 / `MeetServers` | 无人读,删 |
| 其它不认识的键 | 保留 + 启动日志告警(已无后台入口也不下发,要么加进落点表要么手工删) |
- 客户端四处读取改成 `AppConfig.cred('llm_mobile_elf','agent_type','AGENT_TYPE')` 这种形状,老键名作兜底。
`AppConfig.env(` 在 lib 里已是 **0 处**调用;新代码别再用它,凭据/参数都走 `cred()`。
- 图片翻译的 `ALIBABA_VL_MODEL` / `ALIBABA_VL_ENDPOINT` 原先哪张表都没有(客户端一直用写死的默认值),现在是
`ast_alibaba` 上的 `vl_model` / `vl_endpoint` 两个明文字段(留空=客户端默认)。**没挂到 `llmv_qwen`**:
那条是服务端会议总结用的 `qwen-plus`,`base_url` 是不带路径的根地址,与客户端要的视觉模型/完整地址对不上。
- `llm_mobile_elf` **停用 = 不下发 `AGENT_TYPE`** = 客户端 AI 球一律进 EMAI,这是预期行为。
- console 的 `appcfg/*` 四个接口、api 模块的 `api_getconfig/addconfig/updateconfig/delconfig` 已删(后台与客户端都没有调用方)。
- 顺手修了 [model_config.go](apps/services/modules/user/model_config.go) 的 `getmcpservers`:作用域归一后它还在查
`app_name=''` 的全局层,MCP 类服务永远读不到、且不报错。现按 `comm.AppName()` 读本应用。
### 服务配置按应用隔离 + 类别可增删改(2026-09-14)
**没有「全局默认」层了。** 原模型是 `app_name=''` 全局默认 + 应用覆盖(应用没配就回退全局行),
现在每个应用一套、**互不干涉**:应用 A 删掉某个服务,不会悄悄回退到别人那份。涉及五张表,
作用域键都是 `app_name`:`svc_config` / `svc_region_override` / `echomeet_orch` / `echomeet_orch_setting` /
`echomeet_template`(公共模板也按应用分了,后台「会议模板」页多了应用选择)。
- 迁移在 [migrate_scope.go](apps/services/modules/console/migrate_scope.go),console 启动跑、幂等:
**首个应用(`app_registry.id` 最小)拿原行改名**(id/row_id 保住,`echomeet_record` 里存的模板 id 不断),
其余应用各拷一份。跑完全局层为空;运行时那 16 处 `OR app_name=''` 回退分支再也匹配不到行,代码没删。
- ⚠️ **作用域键是 `app_registry.app_name`(应用中心的应用名,如 `EAIMAR` / `deepGlass`),不是
`app_registry.name`(部署名 `deepglass`)**。运行时 `ANALYZE_APP_NAME` 实测等于 `app_name`;
后台下拉用的也是它。用错列会让 deepGlass 一个服务都读不到,且不报错。
- ⚠️ `migrateEnvToSvcConfig` 在作用域分配之后**必须跳过**(已加守卫:`svc_config` 有非空 `app_name` 行即返回)。
否则它会从 `global_config` 把服务重新建回全局层,再被分配时与首个应用撞 `(app_name,id)` 唯一键。
新应用要凭据走后台「从其它应用复制」(`api_copysvcconfig`,密文原样搬不经前端),不再从 `global_config` 长出来。
- 后台所有写接口 `app_name` 必填(`svcScopeOK` / `meetTplScopeOK`),空值直接拒绝——前端漏传就会在谁也看不见的作用域里建出服务。
- 后台老账号的 `requireAppNameScope` 现在真的被调了(删 Agent 页之后它一度没有调用方)。
**类别落库**(`svc_category`,[model_svccategory.go](apps/services/modules/console/model_svccategory.go)),后台「类别管理」可增删改(内置 12 个,第 12 个「应用参数」是 9-14 加的)。
两种东西要分清:**展示属性**(名称/角标/颜色/排序/启用)任何类别都能改;**运行时语义**只挂在
`comm.SvcCat*`(MT=3 `user_translate`、AST=4 VIP 过期停发、MCP=10、实名=11 永不下发)和模板/会议编排引用的
内置 id(1/2/5/8/9)上——**内置 11 个 id 不可改不可删**,只能停用。自定义类别(id ≥ 100)只作分组:
叫「实名」也不会获得「不下发」的保护,`server_only` 是从 comm 读出来的只读属性,刻意不落库。
删自定义类别前查 `svc_config.categories` 与 `third_svc_template.category` 的引用,有就拒绝。
前端 `SCAT` 现在是从 `api_getsvccategories` 加载的 computed,形状与原写死数组一致;`VOICE_CATS`/`SERVER_ONLY_SCATS`
改为读类别的 `voice`/`server_only` 标记。
⚠️ **未升级到本版的应用服务会看到两份会议模板**:老代码查 `source='public'` 不带 `app_name`,
迁移后两个应用各 287 条 → 老 deepglass-a11 会拿到 574 条(重复)。升级到本版即消失。
### 服务商模板 ≠ 已接入:科大讯飞 AIUI(2026-09-14 加)
「服务商模板」只是**后台建服务时的字段清单**——加一条模板,运营就能在「第三方服务」里建出这个服务、
填凭据、按区域分叉,凭据也会随 `user_getthirdsvcs_v2` / `user_getappconfig_v3` 的 `thirdsvcs` 下发。
但**端侧有没有代码去用它是另一回事**。
`sts_iflytek_aiui`(科大讯飞 AIUI,类别 STS=7)当前就是这个状态:**模板有了,全项目没有任何一处调用**
(搜 `aiui` 零命中)。所以配完不会有任何效果,也不会报错——要真正用起来,客户端得按
`AppConfig.cred('sts_iflytek_aiui', '<字段>', '')` 取值并实现那条 WebSocket 链路。
- 归 STS 不是 STT:AIUI 是「唤醒→识别→语义→内容服务→合成」一整条,吐的是**答复**不是转写文本。
归错类别会被会议记录/通话翻译那些按类别选路的地方选中,然后拿不到期望的结果。
- 鉴权自成一套,别照别家套 key/secret:请求头 `X-Appid` / `X-CurTime` / `X-Param`(业务参数 base64)
/ `X-CheckSum = MD5(api_key + X-CurTime + X-Param)`。必填凭据只有 `app_id` 与 `api_key` 两个。
- ⚠️ `auth_id` 是**每用户/每设备一个**的标识(32 位小写字母+数字),AIUI 拿它存个性化数据与多轮上下文。
模板里默认留空(由端侧按设备生成),**填死等于全部用户共用一份上下文**,A 说的话会进 B 的对话历史。
- `languages` 预填为空是正常的:内置音色表只覆盖 azure / alibaba(`comm.TTSProviders()`),
讯飞不在表里,和 `sts_google` 同一情况,由运营自己填;巡检会提示「没配语言」。
- 契约由 `TestIflytekAIUITemplate` 守着(类别、必填字段、api_key 必须加密、默认 ws 地址)。
### svc_config 的四个坑(2026-09-12 阿龙测试环境实测踩出来的,都不报错) ### svc_config 的四个坑(2026-09-12 阿龙测试环境实测踩出来的,都不报错)
这四条对**任何**改第三方服务配置的人都成立,不限于那次迁移: 这四条对**任何**改第三方服务配置的人都成立,不限于那次迁移:
@ -1654,6 +1757,28 @@ console 每次启动跑、幂等(只建不存在的服务、只填空着的字
- ⚠️ **本仓库若要公开,必须先清理** 各 `*-deploy.sh` 的 `DEPLOY_HOST`、`deploy/registry-profiles.sh` 的 `REGISTRY_PASS`,以及各 `*.yaml` 里的真实凭据。 - ⚠️ **本仓库若要公开,必须先清理** 各 `*-deploy.sh` 的 `DEPLOY_HOST`、`deploy/registry-profiles.sh` 的 `REGISTRY_PASS`,以及各 `*.yaml` 里的真实凭据。
- 早期那套 `deploy.sh <env> [action]` 已删除(只指向灵谱、生产位留空),统一走 `dev-deploy.sh` / `prod-build.sh` / `prod-deploy.sh`。 - 早期那套 `deploy.sh <env> [action]` 已删除(只指向灵谱、生产位留空),统一走 `dev-deploy.sh` / `prod-build.sh` / `prod-deploy.sh`。
### 启动日志里的「未配置」是设计,ERROR 才要查(2026-09-14 清过一轮)
home/api 的各 `sys` 子系统都是「配不上就降级」:缺凭据只留一条
`init sys.xxx err: …(xx未配置,该功能不可用)` 的 **WARN**,包级函数返回 `ErrNotInited` 而不是 panic
(会 panic 的只有 `wordfilter` 与 `ip2region`,见 deploy/app/README)。阿龙测试机现在常态缺 4 项:
Google 语音、Google 登录、微信支付、支付宝、微软翻译——都是没放对应 json/pem 或没填 key,**不是故障**。
这轮修掉的两条真 ERROR:
- `firebase/auth.go:36 [sys.tavily] cannot read credentials file` —— `sys/auth/firebase` **整个包已删**。
它是 `google_auth` 的重复实现,`Auth()` 一处调用方都没有(登录类型里没有 Firebase;客户端拿的
Firebase ID Token 走 `stype=Google`)。它还在 `newSys` 里 `Log.Errorln` 一次、又把同一个 error 返回给
调用方再打一条 WARN,于是同一件事刷两行、其中一行是红的。
- `Error 1062 Duplicate entry 'admin' for key 'adnim.PRIMARY'` —— `modules/api/model.go` 每次启动无条件
`Insert` 一次默认超管、返回值还没接。改成**只在账号不存在时**插入。
⚠️ 别图省事改用 `Save`/Upsert:那会把运营改过的密码在每次重启时刷回配置文件里的初始值。
顺带两处:`sys/auth/*` 五个包的 logger 名全被复制成了 `sys.tavily`(所以 firebase 的错误显示成
`[sys.tavily]`,查的时候会被带偏),已各改各名;`google_auth` 原先 `newSys` 无条件返回成功、日志打
`init sys.google_auth success!`,而配置指的 json 根本不存在,直到有人点 Google 登录才失败——
现在启动时就判「配得上没有」(空配置 / 写的是路径但文件不在),`ApiKeyFile` 以 `{` 开头则视为内嵌 JSON 放行。
## 迁移上下文 ## 迁移上下文
源项目在 `/Users/liwei/work/go/yunyan/deep_server_up/`(及 `go_earphone/server/deep_server/`)。从旧代码移植接口时,注意 import 路径由 `earphone/...` 改为 `yunyan/...`,并核对接口签名是否符合上文反射注册约定。 源项目在 `/Users/liwei/work/go/yunyan/deep_server_up/`(及 `go_earphone/server/deep_server/`)。从旧代码移植接口时,注意 import 路径由 `earphone/...` 改为 `yunyan/...`,并核对接口签名是否符合上文反射注册约定。

264
apps/admin/app/components/AppEnvTab.vue

@ -1,264 +0,0 @@
<template>
<div>
<div class="rounded-lg px-3 py-2 mb-4 text-xs" style="background:#f8fafc;border:1px solid #e2e8f0;color:#475569">
这里只放<b>应用自己的业务参数</b>(导航方式、客服 QQ、智能体标识…),直接读写所选部署的业务库 config 表。
第三方服务的<b>凭据请到「第三方服务」标签页配</b>——2026-09-12 起凭据统一收在那里(带加密、区域分叉与巡检),
下发给客户端时按字段上的「下发键名」自动回填一份 env,老客户端无需升级。
</div>
<div class="flex items-center justify-between mb-4 flex-wrap gap-3">
<div class="flex items-center gap-3 flex-wrap">
<span class="text-sm text-slate-500">应用</span>
<select v-model="curAppName" class="select select-bordered select-sm min-w-40" @change="onAppNameChange">
<option value="" disabled>请选择应用</option>
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option>
</select>
<span class="text-sm text-slate-500">部署环境</span>
<select
v-model.number="curApp"
class="select select-bordered select-sm min-w-48"
:disabled="!curAppName"
@change="loadData"
>
<option :value="0" disabled>请选择环境</option>
<option v-for="d in appDeployments" :key="d.id" :value="d.id">{{ deployLabel(d) }}</option>
</select>
<input v-model="keyword" class="input input-bordered input-sm w-48" placeholder="按分组/键搜索" />
</div>
<button class="btn btn-primary btn-sm text-white" :disabled="!curApp" @click="openForm()">+ 新增参数</button>
</div>
<div v-if="!curApp" class="bg-white rounded-xl shadow-sm border border-slate-100 text-center text-slate-400 py-16">
<div class="text-4xl mb-2">🗂️</div>
<p class="text-sm">{{ curAppName ? '该应用还没有部署环境,请先在「应用中心」为它部署一个环境' : '请先在上方选择应用和部署环境' }}</p>
</div>
<div v-else class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr><th>键</th><th>值</th><th>描述</th><th class="text-right">操作</th></tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="4" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td></tr>
<tr v-else-if="!filteredRows.length"><td colspan="4" class="text-center text-slate-400 py-8">暂无参数</td></tr>
<template v-for="grp in groupedRows" :key="grp.key">
<tr class="bg-slate-100/80">
<td colspan="4" class="py-2">
<span class="font-semibold text-slate-600 text-sm">▸ {{ grp.label }}</span>
<span class="text-xs text-slate-400 ml-2">{{ grp.items.length }} 项</span>
</td>
</tr>
<tr v-for="c in grp.items" :key="c.id" class="hover">
<td class="font-mono text-sm font-medium">{{ c.key || '' }}</td>
<td class="text-sm text-slate-500 max-w-xs truncate" :title="c.value || ''">{{ c.value || '' }}</td>
<td class="text-sm text-slate-400">{{ c.description || '' }}</td>
<td class="text-right">
<div class="inline-flex gap-1">
<button class="btn btn-ghost btn-xs" @click="openForm(c)">编辑</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delCfg(c)">删除</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- 编辑弹窗:留在同一个标签页里改,不跳走第二屏 -->
<div v-if="formOpen" class="modal modal-open">
<div class="modal-box max-w-xl">
<h3 class="font-bold text-base mb-1">{{ form.id ? '编辑参数' : '新增参数' }}</h3>
<p class="text-xs text-slate-400 mb-4">配置目标:{{ curAppLabel }}</p>
<div class="space-y-3">
<div>
<label class="text-xs text-slate-500">分组 (group)</label>
<input v-model="form.group" class="input input-bordered input-sm w-full" placeholder="如 APP / AI / other" />
</div>
<div>
<label class="text-xs text-slate-500">键 (key) *</label>
<input v-model="form.key" class="input input-bordered input-sm w-full font-mono" placeholder="如 APP_NAVIGATION_MODE" />
</div>
<div>
<label class="text-xs text-slate-500">值 (value)</label>
<textarea v-model="form.value" class="textarea textarea-bordered w-full font-mono text-sm" rows="3"></textarea>
</div>
<div>
<label class="text-xs text-slate-500">描述</label>
<input v-model="form.description" class="input input-bordered input-sm w-full" />
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" @click="formOpen = false">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="saveCfg">保存</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { regionLabel } from '~/utils/regions'
// 「应用环境配置」原来是独立页面(pages/appconfig.vue),2026-09-12 并进「服务与环境配置」的第二个标签页。
// 合并的理由:凭据已统一收进 svc_config,这张表剩下的只是应用自己的业务参数,
// 单独占一个一级菜单项与「第三方服务配置」并列,只会让人分不清凭据该填哪儿。
// 部署应用(与应用中心 apps/list 同源;id 为部署注册 id,用于连接该应用业务库)。
// 同一个应用会在多个环境各有一条部署记录,靠 env_id 区分——参数按「应用 × 环境」独立。
interface Deployment { id: number; app_name: string; status: string; name: string; region: string; env_id: number }
interface EnvOption { id: number; name: string; code: string; enabled: boolean }
interface AppConfig { id: number; group: string; key: string; value: string; description: string }
const { consoleApi } = useApi()
const { success, error } = useToast()
const apps = ref<Deployment[]>([])
const envs = ref<EnvOption[]>([])
const curAppName = ref('')
const curApp = ref(0)
const items = ref<AppConfig[]>([])
const loading = ref(false)
const submitting = ref(false)
const keyword = ref('')
const formOpen = ref(false)
const form = reactive({ id: 0, group: '', key: '', value: '', description: '' })
const appNames = computed(() =>
[...new Set(apps.value.map((a) => a.app_name || a.name).filter(Boolean))].sort()
)
const appDeployments = computed(() =>
apps.value.filter((a) => (a.app_name || a.name) === curAppName.value)
)
// 部署对应的环境名;区域不同也一并标出,避免同环境多区域时分不清
function deployLabel(d: Deployment) {
const e = envs.value.find((x) => x.id === d.env_id)
const envName = e ? (e.name || e.code) : d.env_id ? `环境#${d.env_id}` : '未关联环境'
const suffix = d.status === 'pending' ? '(待部署)' : ''
return d.region ? `${envName} · ${regionLabel(d.region)}${suffix}` : `${envName}${suffix}`
}
const curAppLabel = computed(() => {
const a = apps.value.find((x) => x.id === curApp.value)
return a ? `${a.app_name || a.name} · ${deployLabel(a)}` : ''
})
const filteredRows = computed(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return items.value
return items.value.filter((c) => ((c.group || '') + (c.key || '')).toLowerCase().includes(kw))
})
// 按 group 分组:相同组归在一起(未分组排最前)
const groupedRows = computed(() => {
const groups: Record<string, AppConfig[]> = {}
filteredRows.value.forEach((c) => {
const g = c.group || ''
;(groups[g] = groups[g] || []).push(c)
})
const names = Object.keys(groups).sort((a, b) => {
if (a === '') return -1
if (b === '') return 1
return a < b ? -1 : 1
})
return names.map((g) => ({ key: g, label: g || '未分组', items: groups[g] }))
})
async function loadApps() {
try {
const [list, es] = await Promise.all([
consoleApi<Deployment[]>('apps/list', {}),
consoleApi<EnvOption[]>('envs/list', {}),
])
apps.value = list ?? []
envs.value = es ?? []
if (!curAppName.value && appNames.value.length) {
curAppName.value = appNames.value[0]!
pickDefaultDeployment()
await loadData()
}
} catch (e: any) {
error(e?.message ?? '获取应用列表失败')
apps.value = []
envs.value = []
}
}
// 在当前应用下挑一个默认部署环境;优先已接入的(待部署的业务库通常还连不上)
function pickDefaultDeployment() {
const list = appDeployments.value
const first = list.find((d) => d.status !== 'pending') ?? list[0]
curApp.value = first?.id ?? 0
}
function onAppNameChange() {
pickDefaultDeployment()
loadData()
}
async function loadData() {
if (!curApp.value) {
items.value = []
return
}
loading.value = true
try {
const data = await consoleApi<{ items: AppConfig[] }>('appcfg/list', { app_id: curApp.value })
items.value = data?.items ?? []
} catch (e: any) {
error(e?.message ?? '获取参数失败')
items.value = []
} finally {
loading.value = false
}
}
function openForm(cfg?: AppConfig) {
form.id = cfg?.id ?? 0
form.group = cfg?.group ?? ''
form.key = cfg?.key ?? ''
form.value = cfg?.value ?? ''
form.description = cfg?.description ?? ''
formOpen.value = true
}
async function saveCfg() {
if (!curApp.value) { error('请先选择应用和部署环境'); return }
if (!form.key.trim()) { error('键(key) 必填'); return }
submitting.value = true
try {
const data: Record<string, unknown> = {
app_id: curApp.value,
group: form.group.trim(),
key: form.key.trim(),
value: form.value,
description: form.description.trim(),
}
if (form.id) {
data.id = Number(form.id)
await consoleApi('appcfg/update', data)
} else {
await consoleApi('appcfg/add', data)
}
success('保存成功')
formOpen.value = false
loadData()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
function delCfg(c: AppConfig) {
if (!window.confirm(`确定删除参数 ${c.key} 吗?`)) return
consoleApi('appcfg/del', { app_id: curApp.value, ids: [c.id] })
.then(() => { success('删除成功'); loadData() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
onMounted(loadApps)
</script>

7
apps/admin/app/pages/appconfig.vue

@ -1,15 +1,16 @@
<template> <template>
<div class="py-16 text-center text-slate-400"> <div class="py-16 text-center text-slate-400">
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
<p class="text-sm mt-3">本页已并入「服务与环境配置」,正在跳转…</p> <p class="text-sm mt-3">本页已并入「第三方服务配置」,正在跳转…</p>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// 2026-09-12:「全局环境配置」下线(凭据统一收进第三方服务配置), // 2026-09-12:「全局环境配置」下线(凭据统一收进第三方服务配置),
// 「应用环境配置」并入「服务与环境配置」的「应用参数」标签页。 // 「应用环境配置」先并入「服务与环境配置」的「应用参数」标签页,2026-09-14 连标签页也下线:
// 客户端读的键搬进 svc_config(服务 llm_mobile_elf / app_params),这里只剩重定向。
// 保留本路由做重定向,兼容旧书签/直链。 // 保留本路由做重定向,兼容旧书签/直链。
definePageMeta({ title: '服务与环境配置' }) definePageMeta({ title: '第三方服务配置' })
onMounted(() => { onMounted(() => {
navigateTo('/serviceconfig') navigateTo('/serviceconfig')
}) })

7
apps/admin/app/pages/globalconfig.vue

@ -1,15 +1,16 @@
<template> <template>
<div class="py-16 text-center text-slate-400"> <div class="py-16 text-center text-slate-400">
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
<p class="text-sm mt-3">本页已并入「服务与环境配置」,正在跳转…</p> <p class="text-sm mt-3">本页已并入「第三方服务配置」,正在跳转…</p>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// 2026-09-12:「全局环境配置」下线(凭据统一收进第三方服务配置), // 2026-09-12:「全局环境配置」下线(凭据统一收进第三方服务配置),
// 「应用环境配置」并入「服务与环境配置」的「应用参数」标签页。 // 「应用环境配置」先并入「服务与环境配置」的「应用参数」标签页,2026-09-14 连标签页也下线:
// 客户端读的键搬进 svc_config(服务 llm_mobile_elf / app_params),这里只剩重定向。
// 保留本路由做重定向,兼容旧书签/直链。 // 保留本路由做重定向,兼容旧书签/直链。
definePageMeta({ title: '服务与环境配置' }) definePageMeta({ title: '第三方服务配置' })
onMounted(() => { onMounted(() => {
navigateTo('/serviceconfig') navigateTo('/serviceconfig')
}) })

52
apps/admin/app/pages/meettemplates.vue

@ -6,11 +6,18 @@
<div> <div>
<h2 class="text-lg font-semibold text-slate-800">会议模板</h2> <h2 class="text-lg font-semibold text-slate-800">会议模板</h2>
<p class="text-xs text-slate-400 mt-1"> <p class="text-xs text-slate-400 mt-1">
会议纪要公共模板(source=public),所有应用共享。同一 tid 下每种语言一份,卡面展示 会议纪要公共模板(source=public),<b>按应用各一套</b>。同一 tid 下每种语言一份,卡面展示
{{ PRIMARY_LANG }} 版本。修改后各服务约 10 分钟内自动同步。 {{ PRIMARY_LANG }} 版本。修改后各服务约 10 分钟内自动同步。
</p> </p>
</div> </div>
<button class="btn btn-primary btn-sm text-white" @click="openCreate()">+ 新增模板</button> <div class="flex items-center gap-2">
<span class="text-sm text-slate-500">应用</span>
<select v-model="scopeApp" class="select select-bordered select-sm min-w-40" @change="loadData">
<option value="" disabled>请选择应用</option>
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
</select>
<button class="btn btn-primary btn-sm text-white" :disabled="!scopeApp" @click="openCreate()">+ 新增模板</button>
</div>
</div> </div>
<!-- 筛选条 --> <!-- 筛选条 -->
@ -308,9 +315,31 @@ const SYNC_FIELDS = [
{ key: 'template', label: '模板内容 template', translated: true }, { key: 'template', label: '模板内容 template', translated: true },
] as const ] as const
const { webApi } = useApi() const { webApi, consoleApi } = useApi()
const { success, error } = useToast() const { success, error } = useToast()
// 2026-09-14 起会议模板按应用隔离(与第三方服务同一套规矩)。应用名来源同 appversion.vue:
// products/list ∪ apps/list,无权读时走 getmyapps。用的是应用名(app_name),运行时 ANALYZE_APP_NAME 就是它。
const scopeApp = ref('')
const scopeApps = ref<string[]>([])
async function loadScopeApps() {
const names = new Set<string>()
const [products, deploys] = await Promise.all([
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>),
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>),
])
;(products ?? []).forEach((p) => p.name && names.add(p.name))
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name))
if (!names.size) {
try {
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {})
;(d?.apps ?? []).forEach((a) => { const n = a.app_name || a.name; if (n) names.add(n) })
} catch { /* 空列表,页面提示先选应用 */ }
}
scopeApps.value = [...names].sort()
if (!scopeApp.value && scopeApps.value.length) scopeApp.value = scopeApps.value[0]!
}
const view = ref<'list' | 'detail'>('list') const view = ref<'list' | 'detail'>('list')
const rows = ref<MeetTemplate[]>([]) const rows = ref<MeetTemplate[]>([])
const loading = ref(false) const loading = ref(false)
@ -427,7 +456,8 @@ function fillForm(t: Partial<MeetTemplate>) {
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const d = await webApi<{ templates: MeetTemplate[] }>('getmeettemplates', {}) if (!scopeApp.value) { rows.value = []; loading.value = false; return }
const d = await webApi<{ templates: MeetTemplate[] }>('getmeettemplates', { app_name: scopeApp.value })
rows.value = d?.templates ?? [] rows.value = d?.templates ?? []
} catch (e: any) { } catch (e: any) {
error(e?.message ?? '加载失败') error(e?.message ?? '加载失败')
@ -485,6 +515,7 @@ async function saveCurrent() {
submitting.value = true submitting.value = true
try { try {
await webApi('updatemeettemplate', { await webApi('updatemeettemplate', {
app_name: scopeApp.value,
id: Number(form.id), id: Number(form.id),
title: form.title.trim(), title: form.title.trim(),
tid: form.tid.trim(), tid: form.tid.trim(),
@ -533,6 +564,7 @@ async function doSync() {
submitting.value = true submitting.value = true
try { try {
const res = await webApi<{ updated: number }>('syncmeettemplate', { const res = await webApi<{ updated: number }>('syncmeettemplate', {
app_name: scopeApp.value,
src_id: Number(form.id), src_id: Number(form.id),
fields: syncFields.value, fields: syncFields.value,
languages: syncLangs.value, languages: syncLangs.value,
@ -569,6 +601,7 @@ async function doAddLang() {
submitting.value = true submitting.value = true
try { try {
await webApi('addmeettemplate', { await webApi('addmeettemplate', {
app_name: scopeApp.value,
title: form.title.trim(), title: form.title.trim(),
tid: form.tid.trim(), tid: form.tid.trim(),
language: lang, language: lang,
@ -613,7 +646,7 @@ async function doCreate() {
} }
submitting.value = true submitting.value = true
try { try {
await webApi('addmeettemplate', { tid, title, language: lang }) await webApi('addmeettemplate', { app_name: scopeApp.value, tid, title, language: lang })
createOpen.value = false createOpen.value = false
success('已创建') success('已创建')
await loadData() await loadData()
@ -636,7 +669,7 @@ async function delCurrentLang() {
if (!window.confirm(`确定删除「${current.value.tid}」的 ${curLang.value} 版本吗?`)) return if (!window.confirm(`确定删除「${current.value.tid}」的 ${curLang.value} 版本吗?`)) return
submitting.value = true submitting.value = true
try { try {
await webApi('delmeettemplate', { ids: [Number(form.id)] }) await webApi('delmeettemplate', { app_name: scopeApp.value, ids: [Number(form.id)] })
detailCache.delete(form.id) detailCache.delete(form.id)
success('已删除') success('已删除')
await refreshCurrentCard(PRIMARY_LANG) await refreshCurrentCard(PRIMARY_LANG)
@ -653,7 +686,7 @@ async function delWholeTid() {
if (!window.confirm(`确定删除「${c.tid}」的全部 ${c.langs.length} 个语言版本吗?此操作不可恢复。`)) return if (!window.confirm(`确定删除「${c.tid}」的全部 ${c.langs.length} 个语言版本吗?此操作不可恢复。`)) return
submitting.value = true submitting.value = true
try { try {
await webApi('delmeettemplate', { ids: c.langs.map((t) => Number(t.id)) }) await webApi('delmeettemplate', { app_name: scopeApp.value, ids: c.langs.map((t) => Number(t.id)) })
detailCache.clear() detailCache.clear()
success('已删除整组') success('已删除整组')
backToList() backToList()
@ -664,7 +697,10 @@ async function delWholeTid() {
} }
} }
onMounted(loadData) onMounted(async () => {
await loadScopeApps()
await loadData()
})
</script> </script>
<style scoped> <style scoped>

280
apps/admin/app/pages/serviceconfig.vue

@ -1,32 +1,22 @@
<template> <template>
<div> <div>
<!-- 标签页:第三方服务(凭据,结构化)/ 应用参数(业务库 key-value)。 <!-- 2026-09-14 起只剩「第三方服务」这一个页面:原第二个标签页「应用参数」(业务库 config 表的裸 key/value)
2026-09-12 由两个一级菜单项合并而来,凭据只有「第三方服务」一个出处。 --> 已下线——客户端真正读的那几个键(AGENT_TYPE / MOBILE_ELF_* / iapCustomerServiceQQ)由 console 启动迁移
<div role="tablist" class="tabs tabs-boxed bg-slate-100 mb-5 w-fit"> 搬进了 svc_config(服务 llm_mobile_elf / app_params,字段带下发键名),config 表只剩服务端自用的
<a role="tab" class="tab" :class="{ 'tab-active': tab === 'svc' }" @click="tab = 'svc'">第三方服务</a> 算力/运营参数(在「会员与算力」页配)。凭据与应用参数都只有这一处出处。 -->
<a role="tab" class="tab" :class="{ 'tab-active': tab === 'env' }" @click="tab = 'env'">应用参数</a>
</div>
<AppEnvTab v-if="tab === 'env'" />
<div v-show="tab === 'svc'">
<div v-if="!backendReady" class="mb-4 rounded-lg px-4 py-3 text-sm" style="background:#fffbeb;border:1px solid #fde68a;color:#92400e"> <div v-if="!backendReady" class="mb-4 rounded-lg px-4 py-3 text-sm" style="background:#fffbeb;border:1px solid #fde68a;color:#92400e">
⚠ 「第三方服务配置」后端接口暂不可用(连接失败),请检查 console 服务是否运行。 ⚠ 「第三方服务配置」后端接口暂不可用(连接失败),请检查 console 服务是否运行。
</div> </div>
<!-- 顶部 --> <!-- 顶部操作条。标题与大段说明已去掉(2026-09-14):上面的标签页已经写着「第三方服务」,
<div class="flex items-center justify-between mb-5"> 说明文字每次进页面都要跨过去才能点到控件。控件整体左对齐,窄屏自动换行。 -->
<div> <div class="mb-5">
<h2 class="text-lg font-semibold text-slate-800">第三方服务</h2> <div class="flex flex-wrap items-center gap-3">
<p class="text-xs text-slate-400 mt-1">
所有第三方凭据的<b>唯一出处</b>,按服务类型分组(火山引擎、阿里云、Azure 等)。默认展示<b>全局默认</b>配置,可切换到具体应用;
区域默认<b>中国</b>,切换区域后点击卡片可为该区域覆盖字段值。字段上的「下发键名」用于兼容只读 env 的老客户端。
</p>
</div>
<div class="flex items-center gap-3">
<select v-model="scopeApp" class="select select-bordered select-sm" @change="onScopeChange"> <select v-model="scopeApp" class="select select-bordered select-sm" @change="onScopeChange">
<option value="">全局默认</option> <option value="" disabled>请选择应用</option>
<option v-for="a in scopeApps" :key="a" :value="a">应用:{{ a }}</option> <option v-for="a in scopeApps" :key="a" :value="a">应用:{{ a }}</option>
</select> </select>
<button class="btn btn-ghost btn-sm" :disabled="!scopeApp || scopeApps.length < 2" title="把另一个应用的服务(含区域分叉、密文)复制到当前应用" @click="openCopyModal">从其它应用复制</button>
<button class="btn btn-ghost btn-sm" @click="openCatModal">类别管理</button>
<select v-model.number="curRegion" class="select select-bordered select-sm" @change="onRegionChange"> <select v-model.number="curRegion" class="select select-bordered select-sm" @change="onRegionChange">
<option :value="0">默认(全区域)</option> <option :value="0">默认(全区域)</option>
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option> <option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
@ -42,8 +32,13 @@
✦ 当前处于「{{ curRegionLabel }}」区域视图:点击服务卡片编辑该区域的字段分叉(覆盖值 / 停用字段 / 区域专属字段)。要改服务本身的默认配置,请切回「默认(全区域)」。 ✦ 当前处于「{{ curRegionLabel }}」区域视图:点击服务卡片编辑该区域的字段分叉(覆盖值 / 停用字段 / 区域专属字段)。要改服务本身的默认配置,请切回「默认(全区域)」。
</div> </div>
<!-- 未选应用:作用域是必填的(2026-09-14 起没有全局层) -->
<div v-if="!scopeApp" class="bg-white rounded-2xl border border-slate-100 shadow-sm text-center text-slate-400 py-16">
<div class="text-4xl mb-2">🗂️</div>
<p class="text-sm">{{ scopeApps.length ? '请先在右上角选择应用' : '还没有应用,请先到「应用中心」创建' }}</p>
</div>
<!-- 服务卡片:按服务类型分组 --> <!-- 服务卡片:按服务类型分组 -->
<div v-if="groupedSvcs.length" class="flex flex-col gap-6"> <div v-else-if="groupedSvcs.length" class="flex flex-col gap-6">
<section v-for="g in groupedSvcs" :key="g.v"> <section v-for="g in groupedSvcs" :key="g.v">
<div class="group-head" @click="toggleGroup(g.v)"> <div class="group-head" @click="toggleGroup(g.v)">
<span class="fold-arrow" :class="{ folded: folded[g.v] }">▾</span> <span class="fold-arrow" :class="{ folded: folded[g.v] }">▾</span>
@ -251,7 +246,7 @@
<template v-if="wizardStep === 1"> <template v-if="wizardStep === 1">
<p class="text-sm text-slate-500 mb-3">请选择要接入的服务类型(每个服务归属单一类型,独立配置):</p> <p class="text-sm text-slate-500 mb-3">请选择要接入的服务类型(每个服务归属单一类型,独立配置):</p>
<div class="type-grid"> <div class="type-grid">
<div v-for="sc in SCAT" :key="sc.v" class="type-card" :class="{ on: editType === sc.v }" @click="chooseType(sc.v)"> <div v-for="sc in SCAT.filter((c) => c.enabled || c.v === editType)" :key="sc.v" class="type-card" :class="{ on: editType === sc.v }" @click="chooseType(sc.v)">
<span class="cat-chip" :style="{ color: sc.color, background: sc.bg }">{{ sc.short }}</span> <span class="cat-chip" :style="{ color: sc.color, background: sc.bg }">{{ sc.short }}</span>
<span class="text-sm text-slate-700 mt-2">{{ sc.label }}</span> <span class="text-sm text-slate-700 mt-2">{{ sc.label }}</span>
<span v-if="isServerOnlyCat(sc.v)" class="text-[11px] text-rose-600 mt-1">仅服务端调用</span> <span v-if="isServerOnlyCat(sc.v)" class="text-[11px] text-rose-600 mt-1">仅服务端调用</span>
@ -558,6 +553,84 @@
</div> </div>
<!-- 服务商模板管理弹窗 --> <!-- 服务商模板管理弹窗 -->
<!-- ===== 从其它应用复制 ===== -->
<div v-if="copyModalVisible" class="modal modal-open" style="z-index:135">
<div class="modal-box max-w-md">
<h3 class="font-bold text-base mb-1">从其它应用复制服务</h3>
<p class="text-xs text-slate-400 mb-4">把源应用的全部服务(含区域分叉与密文)复制到 <b>{{ scopeApp }}</b>。各应用互不干涉,复制后各改各的。</p>
<div class="space-y-3">
<div>
<label class="text-xs text-slate-500">源应用</label>
<select v-model="copySrcApp" class="select select-bordered select-sm w-full">
<option v-for="a in scopeApps.filter((x) => x !== scopeApp)" :key="a" :value="a">{{ a }}</option>
</select>
</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="copyOverwrite" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
覆盖当前应用已有的同名服务(默认跳过)
</label>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" @click="copyModalVisible = false">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="!copySrcApp || copySubmitting" @click="doCopyFromApp">复制</button>
</div>
</div>
</div>
<!-- ===== 类别管理 ===== -->
<div v-if="catModalVisible" class="modal modal-open" style="z-index:135">
<div class="modal-box max-w-2xl">
<template v-if="!catEditing">
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="font-bold text-base">服务类别</h3>
<p class="text-xs text-slate-400 mt-1">内置类别的 id 被运行时与内置模板引用,不能删、不能改 id,可改名称/颜色/排序或停用;自定义类别只作分组。</p>
</div>
<div class="flex gap-2">
<button class="btn btn-primary btn-sm text-white" @click="catShowForm()">+ 新增类别</button>
<button class="btn btn-ghost btn-sm" @click="catModalVisible = false">关闭</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead><tr><th>ID</th><th>角标</th><th>名称</th><th class="text-center">音色预填</th><th class="text-center">仅服务端</th><th class="text-center">启用</th><th></th></tr></thead>
<tbody>
<tr v-for="c in categories" :key="c.id" :class="{ 'opacity-50': !c.enabled }">
<td class="font-mono text-xs">{{ c.id }}<span v-if="c.builtin" class="badge badge-xs badge-ghost ml-1">内置</span></td>
<td><span class="cat-chip" :style="{ color: c.color, background: c.bg }">{{ c.short }}</span></td>
<td class="text-sm">{{ c.label }}</td>
<td class="text-center text-xs">{{ c.voice ? '✓' : '' }}</td>
<td class="text-center text-xs text-rose-600">{{ c.server_only ? '✓' : '' }}</td>
<td class="text-center text-xs">{{ c.enabled ? '✓' : '停用' }}</td>
<td class="text-right whitespace-nowrap">
<button class="btn btn-ghost btn-xs" @click="catShowForm(c)">编辑</button>
<button class="btn btn-ghost btn-xs text-error" :disabled="c.builtin" :title="c.builtin ? '内置类别不能删除' : ''" @click="delCat(c)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<template v-else>
<h3 class="font-bold text-base mb-3">{{ catForm.id ? '编辑类别' : '新增类别' }}<span v-if="catEditing.builtin" class="badge badge-xs badge-ghost ml-2">内置</span></h3>
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2"><label class="text-xs text-slate-500">名称 *</label><input v-model="catForm.label" class="input input-bordered input-sm w-full" placeholder="如 OCR 文字识别" /></div>
<div><label class="text-xs text-slate-500">角标 *(≤8 字)</label><input v-model="catForm.short" class="input input-bordered input-sm w-full" placeholder="如 OCR" /></div>
<div><label class="text-xs text-slate-500">排序</label><input v-model.number="catForm.sort" type="number" class="input input-bordered input-sm w-full" /></div>
<div><label class="text-xs text-slate-500">前景色</label><div class="flex gap-2"><input v-model="catForm.color" type="color" class="w-10 h-8 p-0 border rounded" /><input v-model="catForm.color" class="input input-bordered input-sm flex-1 font-mono" /></div></div>
<div><label class="text-xs text-slate-500">背景色</label><div class="flex gap-2"><input v-model="catForm.bg" type="color" class="w-10 h-8 p-0 border rounded" /><input v-model="catForm.bg" class="input input-bordered input-sm flex-1 font-mono" /></div></div>
<label class="flex items-center gap-2 text-sm cursor-pointer"><input v-model="catForm.voice" type="checkbox" class="checkbox checkbox-sm checkbox-primary" :disabled="catEditing.builtin" />音频输出类(新建时按音色表预填 languages)</label>
<label class="flex items-center gap-2 text-sm cursor-pointer"><input v-model="catForm.enabled" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />启用(停用后新增向导里不再出现)</label>
</div>
<p class="text-xs text-slate-400 mt-3">预览:<span class="cat-chip" :style="{ color: catForm.color, background: catForm.bg }">{{ catForm.short || '角标' }}</span></p>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" @click="catEditing = null">返回</button>
<button class="btn btn-primary btn-sm text-white" :disabled="catSubmitting" @click="saveCat">保存</button>
</div>
</template>
</div>
</div>
<div v-show="tplModalVisible" class="modal-overlay" style="z-index:130"> <div v-show="tplModalVisible" class="modal-overlay" style="z-index:130">
<div class="tpl-box"> <div class="tpl-box">
<!-- 模板列表 --> <!-- 模板列表 -->
@ -654,7 +727,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -662,11 +734,7 @@ import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
definePageMeta({ title: '服务与环境配置' }) definePageMeta({ title: '第三方服务配置' })
// 两个标签页:第三方服务(凭据,带加密/区域分叉/巡检)与应用参数(业务库 config 表的 key-value)。
// 合并前它们是并列的两个一级菜单项,运营常把凭据填错地方——凭据现在只认「第三方服务」这一处。
const tab = ref<'svc' | 'env'>('svc')
// 区域枚举(与 pb.Region 一一对应) // 区域枚举(与 pb.Region 一一对应)
const REGIONS = [ const REGIONS = [
@ -677,30 +745,43 @@ const REGIONS = [
{ value: 13, label: '巴西' }, { value: 14, label: '印度' }, { value: 13, label: '巴西' }, { value: 14, label: '印度' },
] ]
// 服务类别常量(与后端 svcCatXxx 一一对应)。categories 支持逗号分隔多值,一个服务可同属多类。 // 服务类别:2026-09-14 起落库(svc_category),后台「类别管理」可增删改。
const SCAT = [ // 这里的 SCAT 形状与原来写死的数组一致(v/label/short/color/bg),下面的分组/角标代码不用改。
{ v: 1, label: 'STT 语音识别', short: 'STT', color: '#0369a1', bg: '#dbeafe' }, // 内置 11 个的 id 与后端 comm.SvcCat* / console svcCat* 同一套数字,后端不允许改 id 或删除;
{ v: 8, label: '录音文件识别', short: '录音识别', color: '#0e7490', bg: '#cffafe' }, // server_only / voice 两个语义标记也由后端给,前端只负责展示与预填行为。
{ v: 2, label: 'TTS 语音合成', short: 'TTS', color: '#7c3aed', bg: '#ede9fe' }, interface SvcCategory {
{ v: 3, label: 'MT 机器翻译', short: 'MT', color: '#059669', bg: '#dcfce7' }, id: number
{ v: 4, label: 'AST 端到端翻译', short: 'AST', color: '#c2410c', bg: '#fff7ed' }, label: string
{ v: 5, label: 'LLM 文本大模型', short: '文本LLM', color: '#b45309', bg: '#fef9c3' }, short: string
{ v: 9, label: '多媒体大模型', short: '多媒体LLM', color: '#9333ea', bg: '#f3e8ff' }, color: string
{ v: 7, label: 'STS 端到端对话', short: 'STS', color: '#be185d', bg: '#fce7f3' }, bg: string
{ v: 6, label: '存储 OSS/COS', short: '存储', color: '#475569', bg: '#f1f5f9' }, sort: number
{ v: 10, label: 'MCP 服务', short: 'MCP', color: '#0d9488', bg: '#ccfbf1' }, builtin: boolean
{ v: 11, label: '身份证校验', short: '实名', color: '#9f1239', bg: '#ffe4e6' }, voice: boolean
] enabled: boolean
server_only: boolean
}
const categories = ref<SvcCategory[]>([])
const SCAT = computed(() =>
categories.value.map((c) => ({ v: c.id, label: c.label, short: c.short, color: c.color, bg: c.bg,
enabled: c.enabled, builtin: c.builtin, voice: c.voice, server_only: c.server_only })),
)
async function loadCategories() {
try {
const d = await webApi<{ items: SvcCategory[] }>('getsvccategories', {})
categories.value = d?.items ?? []
} catch (e: any) {
error(e?.message ?? '加载服务类别失败')
}
}
// MCP 服务类别(与后端 svcCatMCP 一致):字段固定为 url/type/tools,type 用 HTTP/SSE 下拉。
const SCAT_MCP = 10 const SCAT_MCP = 10
// 服务端专用类别(与后端 comm.serverOnlySvcCats 一致)。 // 服务端专用类别(与后端 comm.serverOnlySvcCats 一致)。
// 这类服务的凭据是云账号主 AK/SK,只由服务端调用;后端 resolveThirdSvcs 会整条拦掉, // 这类服务的凭据是云账号主 AK/SK,只由服务端调用;后端 resolveThirdSvcs 会整条拦掉,
// 不随 user_getthirdsvcs / user_getappconfig 下发客户端。这里只负责在界面上说清楚, // 不随 user_getthirdsvcs / user_getappconfig 下发客户端。这里只负责在界面上说清楚,
// 真正的拦截在后端 —— 改这里不会改变下发行为。 // 真正的拦截在后端 —— 改这里不会改变下发行为。
const SERVER_ONLY_SCATS = [11] const isServerOnlyCat = (v: number) => (SCAT.value.find((s) => s.v === v)?.server_only ?? (v === 11))
const isServerOnlyCat = (v: number) => SERVER_ONLY_SCATS.includes(v)
interface SvcField { interface SvcField {
key: string key: string
@ -765,23 +846,104 @@ const { success, error, warn } = useToast()
// ===== 作用域:全局默认('') 或 某应用(app_name) ===== // ===== 作用域:全局默认('') 或 某应用(app_name) =====
// 第三方服务/区域覆盖均按应用分离:应用有自己的就用应用的、没有回退全局。此选择器切换正在管理的作用域。 // 第三方服务/区域覆盖均按应用分离:应用有自己的就用应用的、没有回退全局。此选择器切换正在管理的作用域。
// 2026-09-14 起没有「全局默认」层:每个应用一套配置互不干涉,作用域必选。
// 应用名来源与 appversion.vue 同一套:应用中心 products/list ∪ 部署注册 apps/list,无权读时走 getmyapps。
// ⚠️ 用的是应用名(app_name,如 EAIMAR / deepGlass),运行时 ANALYZE_APP_NAME 就是它;别用部署名。
const scopeApp = ref('') const scopeApp = ref('')
const scopeApps = ref<string[]>([]) const scopeApps = ref<string[]>([])
async function loadScopeApps() { async function loadScopeApps() {
const names = new Set<string>()
const [products, deploys] = await Promise.all([
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>),
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>),
])
;(products ?? []).forEach((p) => p.name && names.add(p.name))
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name))
if (!names.size) {
try { try {
const list = await consoleApi<Array<{ name: string }>>('products/list', {}) const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {})
scopeApps.value = (list ?? []).map((p: { name: string }) => p.name).filter(Boolean) ;(d?.apps ?? []).forEach((a) => { const n = a.app_name || a.name; if (n) names.add(n) })
} catch { } catch { /* 拿不到就空列表,页面给出提示 */ }
scopeApps.value = []
} }
scopeApps.value = [...names].sort()
if (!scopeApp.value && scopeApps.value.length) scopeApp.value = scopeApps.value[0]!
} }
function onScopeChange() { function onScopeChange() {
closeSvcModal() closeSvcModal()
loadSvcs() loadSvcs()
} }
// ───── 从其它应用复制 ─────
const copyModalVisible = ref(false)
const copySrcApp = ref('')
const copyOverwrite = ref(false)
const copySubmitting = ref(false)
function openCopyModal() {
copySrcApp.value = scopeApps.value.find((a) => a !== scopeApp.value) || ''
copyOverwrite.value = false
copyModalVisible.value = true
}
async function doCopyFromApp() {
if (!copySrcApp.value || !scopeApp.value) return
copySubmitting.value = true
try {
const r = await webApi<{ copied: number; skipped: number }>('copysvcconfig', {
src_app: copySrcApp.value, dst_app: scopeApp.value, overwrite: copyOverwrite.value,
})
success(`已复制 ${r?.copied ?? 0} 个服务${r?.skipped ? `,跳过已存在的 ${r.skipped} 个` : ''}`)
copyModalVisible.value = false
loadSvcs()
} catch (e: any) {
error(e?.message ?? '复制失败')
} finally {
copySubmitting.value = false
}
}
// ───── 类别管理 ─────
const catModalVisible = ref(false)
const catEditing = ref<SvcCategory | null>(null)
const catForm = reactive({ id: 0, label: '', short: '', color: '#475569', bg: '#f1f5f9', sort: 0, voice: false, enabled: true })
const catSubmitting = ref(false)
function openCatModal() { catEditing.value = null; catModalVisible.value = true; loadCategories() }
function catShowForm(c?: SvcCategory) {
catEditing.value = c ?? ({ id: 0 } as SvcCategory)
catForm.id = c?.id ?? 0
catForm.label = c?.label ?? ''
catForm.short = c?.short ?? ''
catForm.color = c?.color ?? '#475569'
catForm.bg = c?.bg ?? '#f1f5f9'
catForm.sort = c?.sort ?? (categories.value.length ? Math.max(...categories.value.map((x) => x.sort)) + 1 : 0)
catForm.voice = c?.voice ?? false
catForm.enabled = c?.enabled ?? true
}
async function saveCat() {
if (!catForm.label.trim() || !catForm.short.trim()) { error('名称与角标必填'); return }
catSubmitting.value = true
try {
await webApi('savesvccategory', { ...catForm, label: catForm.label.trim(), short: catForm.short.trim() })
success('已保存')
catEditing.value = null
await loadCategories()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
catSubmitting.value = false
}
}
async function delCat(c: SvcCategory) {
if (!window.confirm(`确定删除类别「${c.label}」?被服务或模板引用时会被拒绝。`)) return
try {
await webApi('delsvccategory', { id: c.id })
success('已删除')
await loadCategories()
} catch (e: any) {
error(e?.message ?? '删除失败')
}
}
function scatInfo(v: number) { function scatInfo(v: number) {
return SCAT.find((s) => s.v === v) || { short: '?', color: '#999', bg: '#eee', v: 0, label: '' } return SCAT.value.find((s) => s.v === v) || { short: '?', color: '#999', bg: '#eee', v: 0, label: '' }
} }
// 当前编辑的是否 MCP 服务:新增向导看 editType,默认/区域编辑看当前服务 categories。 // 当前编辑的是否 MCP 服务:新增向导看 editType,默认/区域编辑看当前服务 categories。
// ── 按模板补齐字段 ── // ── 按模板补齐字段 ──
@ -893,10 +1055,11 @@ interface TTSVoice {
name: string name: string
} }
// 会出声的类别:TTS / AST 端到端翻译 / STS 端到端对话——它们的音色都按服务商区分。 // 会出声的类别:TTS / AST 端到端翻译 / STS 端到端对话——它们的音色都按服务商区分。
const VOICE_CATS = [2, 4, 7] // 音频输出类(原写死 [2,4,7]):现在由类别表的 voice 标记决定,新建时按音色表预填 languages。
const isVoiceCatId = (v: number) => !!SCAT.value.find((s) => s.v === v)?.voice
const voiceRows = ref<TTSVoice[]>([]) const voiceRows = ref<TTSVoice[]>([])
const voicesLoading = ref(false) const voicesLoading = ref(false)
const isVoiceCat = computed(() => VOICE_CATS.includes(editType.value)) const isVoiceCat = computed(() => isVoiceCatId(editType.value))
// 按语言分组,组内保持后端顺序(第一个即默认音色,与运行时 comm.PickVoice 口径一致) // 按语言分组,组内保持后端顺序(第一个即默认音色,与运行时 comm.PickVoice 口径一致)
const voiceLangGroups = computed(() => { const voiceLangGroups = computed(() => {
const map = new Map<string, TTSVoice[]>() const map = new Map<string, TTSVoice[]>()
@ -986,7 +1149,7 @@ const curRegionLabel = computed(() => {
// 服务卡片按服务类型分组;未归类的服务归入末尾的「未分类」组 // 服务卡片按服务类型分组;未归类的服务归入末尾的「未分类」组
const groupedSvcs = computed(() => { const groupedSvcs = computed(() => {
const groups = SCAT.map((sc) => ({ const groups = SCAT.value.map((sc) => ({
...sc, ...sc,
items: allSvcs.value.filter((s) => parseCats(s.categories).includes(sc.v)), items: allSvcs.value.filter((s) => parseCats(s.categories).includes(sc.v)),
})).filter((g) => g.items.length) })).filter((g) => g.items.length)
@ -1012,7 +1175,7 @@ function toggleAllGroups() {
// 模板按类别分组(供下拉与模板管理列表) // 模板按类别分组(供下拉与模板管理列表)
const templatesByCat = computed(() => { const templatesByCat = computed(() => {
return SCAT.map((sc) => ({ return SCAT.value.map((sc) => ({
cat: sc.v, cat: sc.v,
label: sc.label, label: sc.label,
items: templates.value.filter((t) => t.category === sc.v), items: templates.value.filter((t) => t.category === sc.v),
@ -1021,6 +1184,7 @@ const templatesByCat = computed(() => {
// ===== 加载服务列表 ===== // ===== 加载服务列表 =====
async function loadSvcs() { async function loadSvcs() {
if (!scopeApp.value) { allSvcs.value = []; return }
try { try {
const data = await webApi<{ items: ThirdSvcConfig[]; regions?: Record<string, number[]>; cred_missing?: Record<string, string[]> }>('getsvcconfigs', { app_name: scopeApp.value }) const data = await webApi<{ items: ThirdSvcConfig[]; regions?: Record<string, number[]>; cred_missing?: Record<string, string[]> }>('getsvcconfigs', { app_name: scopeApp.value })
allSvcs.value = data?.items ?? [] allSvcs.value = data?.items ?? []
@ -1132,7 +1296,7 @@ function chooseProvider(t: SvcTemplate | null) {
wizardStep.value = 3 wizardStep.value = 3
// 新建语音输出类服务时,languages 用音色表自动填好——有音色才说得出那门语言, // 新建语音输出类服务时,languages 用音色表自动填好——有音色才说得出那门语言,
// 让运营对着文档手抄一遍既费事又容易漏。填完仍可增删。 // 让运营对着文档手抄一遍既费事又容易漏。填完仍可增删。
if (editForm.provider && VOICE_CATS.includes(editType.value)) { if (editForm.provider && isVoiceCatId(editType.value)) {
loadVoicesForProvider(editForm.provider).then(() => fillLanguagesFromVoices(true)) loadVoicesForProvider(editForm.provider).then(() => fillLanguagesFromVoices(true))
} }
} }
@ -1434,8 +1598,8 @@ watch([() => editForm.provider, isVoiceCat, svcModalVisible], ([provider, isVoic
loadVoicesForProvider(provider as string) loadVoicesForProvider(provider as string)
}, { immediate: false }) }, { immediate: false })
onMounted(() => { onMounted(async () => {
loadScopeApps() await Promise.all([loadCategories(), loadScopeApps()])
loadSvcs() loadSvcs()
loadTemplates() loadTemplates()
}) })

5
apps/admin/app/utils/menus.ts

@ -65,14 +65,15 @@ export const MENU_GROUPS: MenuGroup[] = [
// · 全局环境配置(globalconfig) —— 473 行按区域复制的凭据,且**悄悄覆盖**应用环境配置里的同名键, // · 全局环境配置(globalconfig) —— 473 行按区域复制的凭据,且**悄悄覆盖**应用环境配置里的同名键,
// 同一个 AZURE_SPEECH_REGION/OPENAI_API_KEY 两边值不一样、运营改哪边都像没生效。 // 同一个 AZURE_SPEECH_REGION/OPENAI_API_KEY 两边值不一样、运营改哪边都像没生效。
// 凭据已归一到 svc_config(见 services 的 migrate_envtosvc.go),页面下线只留重定向。 // 凭据已归一到 svc_config(见 services 的 migrate_envtosvc.go),页面下线只留重定向。
// · 应用环境配置(appconfig) —— 并入「服务与环境配置」的「应用参数」标签页,只剩非凭据的业务参数。 // · 应用环境配置(appconfig) —— 先并入「服务与环境配置」的「应用参数」标签页,2026-09-14 连标签页也下线:
// 客户端读的键搬进 svc_config(服务 llm_mobile_elf / app_params),config 表不再有后台裸编辑入口。
// · Agent 配置(agents) / 通话翻译配置(calltranslate) —— agent_config、call_translate_rule // · Agent 配置(agents) / 通话翻译配置(calltranslate) —— agent_config、call_translate_rule
// 两张表在测试机与正式机上**都是 0 行**,客户端的通话翻译选路全在端侧 // 两张表在测试机与正式机上**都是 0 行**,客户端的通话翻译选路全在端侧
// (language_manager.findBestMatchingProvider),后台这两页从未产生过数据。 // (language_manager.findBestMatchingProvider),后台这两页从未产生过数据。
// · 会议记录服务(meetingsvc) —— 按要求下架。⚠️ 它背后的 echomeet_orch 仍有 4 行在跑 // · 会议记录服务(meetingsvc) —— 按要求下架。⚠️ 它背后的 echomeet_orch 仍有 4 行在跑
// (语音纪要的识别/翻译/总结选型),**数据与运行时保留**,只是后台不再提供编辑入口; // (语音纪要的识别/翻译/总结选型),**数据与运行时保留**,只是后台不再提供编辑入口;
// 要换服务商得直接改库(见 services/modules/echomeet/orch_resolver.go)。 // 要换服务商得直接改库(见 services/modules/echomeet/orch_resolver.go)。
{ id: 'svcconfig', label: '服务与环境配置', to: '/serviceconfig', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' }, { id: 'svcconfig', label: '第三方服务配置', to: '/serviceconfig', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
{ id: 'appgoods', label: '应用商品配置', to: '/appgoods', icon: 'M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z' }, { id: 'appgoods', label: '应用商品配置', to: '/appgoods', icon: 'M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z' },
{ id: 'compute', label: '会员与算力', to: '/compute', icon: 'M13 10V3L4 14h7v7l9-11h-7z' }, { id: 'compute', label: '会员与算力', to: '/compute', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
{ id: 'meettemplate', label: '会议模板', to: '/meettemplates', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' }, { id: 'meettemplate', label: '会议模板', to: '/meettemplates', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },

5
apps/client/.env.example

@ -74,3 +74,8 @@ APP_IOS_IAP_SUFFIX=
APP_IOS_IAP_ID_MAP= APP_IOS_IAP_ID_MAP=
# 请求签名密钥(可选,不配用内置默认值;须与服务端一致) # 请求签名密钥(可选,不配用内置默认值;须与服务端一致)
API_SIGN_KEY= API_SIGN_KEY=
# 网关加密接口(user_getappconfig_v3 等)的 AES-CBC 密钥,16/24/32 字节,
# 须与服务器 deploy/app 的 .env 里 GATEWAY_ENCRYPT_KEY 一致(IV 固定 16 个 '0',PKCS7,密文 base64,响应头 X-Encrypted:1)。
# 留空 → 客户端回退到明文 v1 user_getappconfig(凭据只走 env 兼容层,thirdsvcs 拿不到)。
GATEWAY_ENCRYPT_KEY=

4
apps/client/lib/core/utils/ai_navigation.dart

@ -13,8 +13,10 @@ import 'id_verify_guard.dart';
/// 「Azure STT → 火山 LLM/TTS」的老链路。 /// 「Azure STT → 火山 LLM/TTS」的老链路。
void navigateToAIAssistant() { void navigateToAIAssistant() {
if (IdVerifyGuard.blocked()) return; if (IdVerifyGuard.blocked()) return;
// 服务端落点:svc_config 的 llm_mobile_elf.agent_type(老键名 AGENT_TYPE 作兜底)。
// 后台停用那条服务 = 两边都没有 = 一律进 EMAI。
final agentType = GetStorage().read<String>('device_agent_type') ?? final agentType = GetStorage().read<String>('device_agent_type') ??
AppConfig.env('AGENT_TYPE') ?? AppConfig.cred('llm_mobile_elf', 'agent_type', 'AGENT_TYPE') ??
'stt_llm_tts'; 'stt_llm_tts';
if (agentType == 'yidong') { if (agentType == 'yidong') {
Get.toNamed(Routes.mobileElf); Get.toNamed(Routes.mobileElf);

45
apps/client/lib/data/models/appconfig.dart

@ -1,4 +1,3 @@
import 'dart:convert';
import '../../../data/models/appconfig_model.dart'; import '../../../data/models/appconfig_model.dart';
@ -20,11 +19,53 @@ class AppConfig {
_isInitialized = true; // <-- 新增此行 _isInitialized = true; // <-- 新增此行
} }
//环境变量 //环境变量(应用参数 + 老客户端兼容层)
static String? env(String key) { static String? env(String key) {
return config.env[key]; return config.env[key];
} }
// ───── 第三方服务(结构化,2026-09-14 起凭据的首选来源)─────
//
// 后台「服务与环境配置 → 第三方服务」每个应用一套;服务端按用户区域合并区域分叉、解密后经加密的
// user_getappconfig_v3 下发为 thirdsvcs。各业务服务应改用 [cred] 取凭据:先查 thirdsvcs 的
// 「服务 id + 字段名」,取不到再退回 env[老键名]——这样对没配 GATEWAY_ENCRYPT_KEY、或还在跑旧版
// 服务端的环境也能继续工作,而不是一刀切断。
/// 按服务 id 取一条(如 'stt_azure')。未初始化或不存在返回 null。
static ThirdSvc? svc(String id) {
if (!_isInitialized) return null;
for (final s in config.thirdsvcs) {
if (s.id == id) return s;
}
return null;
}
/// 某类别下的全部服务(类别 id 见 ThirdSvc 注释)。
static List<ThirdSvc> svcsByCategory(int cat) {
if (!_isInitialized) return const [];
return config.thirdsvcs.where((s) => s.hasCategory(cat)).toList();
}
/// 取某服务的某个字段;服务不存在、字段不存在或为空串都返回 null(空串等同没配)。
static String? field(String svcId, String fieldKey) {
final v = svc(svcId)?.fields[fieldKey]?.trim();
return (v == null || v.isEmpty) ? null : v;
}
/// 凭据读取入口:thirdsvcs 的 (svcId, fieldKey) 优先,退回 env[envKey]。
///
/// 返回 null 表示两边都没配。调用方自己决定 `?? ''` 还是 `?? 默认值`——
/// 别在这里给空串,那会把调用方的 `?? 默认值` 顶掉(AZURE_TRANSLATION_ENDPOINT 栽过)。
static String? cred(String svcId, String fieldKey, String envKey) {
final v = field(svcId, fieldKey);
if (v != null) return v;
final e = _isInitialized ? config.env[envKey]?.trim() : null;
return (e == null || e.isEmpty) ? null : e;
}
/// 当前是否已从结构化通道拿到凭据(用于日志/诊断:为 false 说明还在走 env 兼容层)。
static bool get usingThirdSvcs => _isInitialized && config.thirdsvcs.isNotEmpty;
//mcp配置 //mcp配置
static dynamic mcpConfig() { static dynamic mcpConfig() {
return {"mcpServers": config.mcps}; return {"mcpServers": config.mcps};

41
apps/client/lib/data/models/appconfig_model.dart

@ -18,17 +18,56 @@ class UserGetAppConfigResp {
@JsonKey(defaultValue: <DBProduct>[]) @JsonKey(defaultValue: <DBProduct>[])
final List<DBProduct> products; final List<DBProduct> products;
/// 第三方服务(结构化,凭据已按用户区域合并并解密为明文)——2026-09-14 起客户端凭据的**首选来源**。
/// 只有加密的 `user_getappconfig_v3` 下发;走明文 v1 时为空,各服务退回 [env] 兼容层。
/// 形状镜像服务端 pb.ThirdSvcItem(apps/proto/user/user_msg_v2.proto)。
@JsonKey(defaultValue: <ThirdSvc>[])
final List<ThirdSvc> thirdsvcs;
UserGetAppConfigResp( UserGetAppConfigResp(
{required this.env, {required this.env,
required this.agents, required this.agents,
required this.mcps, required this.mcps,
required this.products}); required this.products,
this.thirdsvcs = const <ThirdSvc>[]});
factory UserGetAppConfigResp.fromJson(Map<String, dynamic> json) => factory UserGetAppConfigResp.fromJson(Map<String, dynamic> json) =>
_$UserGetAppConfigRespFromJson(json); _$UserGetAppConfigRespFromJson(json);
Map<String, dynamic> toJson() => _$UserGetAppConfigRespToJson(this); Map<String, dynamic> toJson() => _$UserGetAppConfigRespToJson(this);
} }
/// 第三方服务条目(镜像服务端 pb.ThirdSvcItem)。
/// - [categories] 逗号分隔的类别 id(1 STT / 2 TTS / 3 MT / 4 AST / 5 LLM / 6 存储 / 7 STS / 8 录音识别 / 9 多媒体 LLM / 10 MCP)
/// - [fields] 字段名 → 明文值,字段名以后台「第三方服务」里配置的为准(如 stt_azure 的 subscription_key / region)
@JsonSerializable()
class ThirdSvc {
@JsonKey(defaultValue: '')
final String id;
@JsonKey(defaultValue: '')
final String name;
@JsonKey(defaultValue: '')
final String provider;
@JsonKey(defaultValue: '')
final String categories;
@JsonKey(defaultValue: <String, String>{})
final Map<String, String> fields;
ThirdSvc({
required this.id,
required this.name,
required this.provider,
required this.categories,
required this.fields,
});
/// 是否属于某个类别(categories 是逗号分隔多值,别用 contains('1') —— 会命中 10/11)。
bool hasCategory(int cat) =>
categories.split(',').map((e) => e.trim()).contains(cat.toString());
factory ThirdSvc.fromJson(Map<String, dynamic> json) => _$ThirdSvcFromJson(json);
Map<String, dynamic> toJson() => _$ThirdSvcToJson(this);
}
@JsonSerializable() @JsonSerializable()
class DBAgent { class DBAgent {
@JsonKey(defaultValue: '') @JsonKey(defaultValue: '')

24
apps/client/lib/data/models/appconfig_model.g.dart

@ -26,6 +26,10 @@ UserGetAppConfigResp _$UserGetAppConfigRespFromJson(
?.map((e) => DBProduct.fromJson(e as Map<String, dynamic>)) ?.map((e) => DBProduct.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
[], [],
thirdsvcs: (json['thirdsvcs'] as List<dynamic>?)
?.map((e) => ThirdSvc.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
); );
Map<String, dynamic> _$UserGetAppConfigRespToJson( Map<String, dynamic> _$UserGetAppConfigRespToJson(
@ -35,6 +39,26 @@ Map<String, dynamic> _$UserGetAppConfigRespToJson(
'agents': instance.agents, 'agents': instance.agents,
'mcps': instance.mcps, 'mcps': instance.mcps,
'products': instance.products, 'products': instance.products,
'thirdsvcs': instance.thirdsvcs,
};
ThirdSvc _$ThirdSvcFromJson(Map<String, dynamic> json) => ThirdSvc(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
provider: json['provider'] as String? ?? '',
categories: json['categories'] as String? ?? '',
fields: (json['fields'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
) ??
{},
);
Map<String, dynamic> _$ThirdSvcToJson(ThirdSvc instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'provider': instance.provider,
'categories': instance.categories,
'fields': instance.fields,
}; };
DBAgent _$DBAgentFromJson(Map<String, dynamic> json) => DBAgent( DBAgent _$DBAgentFromJson(Map<String, dynamic> json) => DBAgent(

13
apps/client/lib/data/services/alibaba_image_translation_service.dart

@ -49,14 +49,11 @@ class AlibabaImageTranslationService extends GetxService {
Future<bool> initialize() async { Future<bool> initialize() async {
if (_isInitialized) return true; if (_isInitialized) return true;
try { try {
_apiKey = AppConfig.env('ALIBABA_OPENSPEECH_APP_KEY') ?? ''; _apiKey = AppConfig.cred('ast_alibaba', 'app_key', 'ALIBABA_OPENSPEECH_APP_KEY') ?? '';
_endpoint = // 模型/接口地址挂在 ast_alibaba 服务上(vl_model / vl_endpoint),和上面的 API Key 同一条;
(AppConfig.env('ALIBABA_VL_ENDPOINT') ?? '').trim().isNotEmpty // cred() 对空串返回 null,所以留空自然落到默认值。
? AppConfig.env('ALIBABA_VL_ENDPOINT')! _endpoint = AppConfig.cred('ast_alibaba', 'vl_endpoint', 'ALIBABA_VL_ENDPOINT') ?? _defaultEndpoint;
: _defaultEndpoint; _model = AppConfig.cred('ast_alibaba', 'vl_model', 'ALIBABA_VL_MODEL') ?? _defaultModel;
_model = (AppConfig.env('ALIBABA_VL_MODEL') ?? '').trim().isNotEmpty
? AppConfig.env('ALIBABA_VL_MODEL')!
: _defaultModel;
if (_apiKey.trim().isEmpty) { if (_apiKey.trim().isEmpty) {
Logger.e(_tag, '未配置 ALIBABA_OPENSPEECH_APP_KEY,图片翻译不可用'); Logger.e(_tag, '未配置 ALIBABA_OPENSPEECH_APP_KEY,图片翻译不可用');
return false; return false;

6
apps/client/lib/data/services/bailian_multimodal_service.dart

@ -148,19 +148,19 @@ class BailianMultimodalService extends GetxService {
final Rx<BailianDialogState> state = BailianDialogState.idle.obs; final Rx<BailianDialogState> state = BailianDialogState.idle.obs;
final RxBool isConnected = false.obs; final RxBool isConnected = false.obs;
String get _apiKey => AppConfig.env('ALIBABA_OPENSPEECH_APP_KEY') ?? ''; String get _apiKey => AppConfig.cred('ast_alibaba', 'app_key', 'ALIBABA_OPENSPEECH_APP_KEY') ?? '';
String get _workspaceId { String get _workspaceId {
final o = _workspaceIdOverride?.trim() ?? ''; final o = _workspaceIdOverride?.trim() ?? '';
if (o.isNotEmpty) return o; if (o.isNotEmpty) return o;
final v = (AppConfig.env('ALIBABA_BAILIAN_WORKSPACE_ID') ?? '').trim(); final v = (AppConfig.cred('sts_bailian', 'workspace_id', 'ALIBABA_BAILIAN_WORKSPACE_ID') ?? '').trim();
return v.isEmpty ? _defaultWorkspaceId : v; return v.isEmpty ? _defaultWorkspaceId : v;
} }
String get _appId { String get _appId {
final o = _appIdOverride?.trim() ?? ''; final o = _appIdOverride?.trim() ?? '';
if (o.isNotEmpty) return o; if (o.isNotEmpty) return o;
final v = (AppConfig.env('ALIBABA_BAILIAN_APP_ID') ?? '').trim(); final v = (AppConfig.cred('sts_bailian', 'app_id', 'ALIBABA_BAILIAN_APP_ID') ?? '').trim();
return v.isEmpty ? _defaultAppId : v; return v.isEmpty ? _defaultAppId : v;
} }

6
apps/client/lib/data/services/deapsound_ai_service.dart

@ -11,9 +11,9 @@ class DeapsoundAIService implements AiService {
late final String model; late final String model;
DeapsoundAIService() { DeapsoundAIService() {
apiKey = AppConfig.env('OPENAI_API_KEY') ?? ''; apiKey = AppConfig.cred('llm_doubao', 'api_key', 'OPENAI_API_KEY') ?? '';
baseUrl = AppConfig.env('OPENAI_BASE_URL') ?? ''; baseUrl = AppConfig.cred('llm_doubao', 'base_url', 'OPENAI_BASE_URL') ?? '';
model = AppConfig.env('OPENAI_MODEL') ?? ''; model = AppConfig.cred('llm_doubao', 'model', 'OPENAI_MODEL') ?? '';
} }
@override @override

6
apps/client/lib/data/services/microsoft_translation_service.dart

@ -38,10 +38,10 @@ class MicrosoftTranslationService extends GetxService {
Future<bool> initialize() async { Future<bool> initialize() async {
if (_isInitialized) return true; if (_isInitialized) return true;
try { try {
_subscriptionKey = AppConfig.env('AZURE_TRANSLATION_KEY') ?? ''; _subscriptionKey = AppConfig.cred('mt_azure', 'subscription_key', 'AZURE_TRANSLATION_KEY') ?? '';
_region = AppConfig.env('AZURE_TRANSLATION_REGION') ?? ''; _region = AppConfig.cred('mt_azure', 'region', 'AZURE_TRANSLATION_REGION') ?? '';
_endpoint = _endpoint =
AppConfig.env('AZURE_TRANSLATION_ENDPOINT') ?? _defaultEndpoint; AppConfig.cred('mt_azure', 'endpoint', 'AZURE_TRANSLATION_ENDPOINT') ?? _defaultEndpoint;
if (_subscriptionKey.isEmpty) { if (_subscriptionKey.isEmpty) {
print( print(

19
apps/client/lib/data/services/network/api.dart

@ -1,3 +1,4 @@
import 'api_crypto.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'dio_manager.dart'; import 'dio_manager.dart';
import 'nw_method.dart'; import 'nw_method.dart';
@ -227,10 +228,18 @@ class Api {
} }
//app配置 //app配置
//
// 2026-09-14 起首选加密的 v3:多下发 thirdsvcs(结构化第三方服务,凭据明文、按区域合并),
// 各业务服务经 AppConfig.cred() 优先从它取凭据。整个响应体 AES-CBC 加密(见 api_crypto.dart),
// 密钥 .env 的 GATEWAY_ENCRYPT_KEY;没配则回退明文 v1——功能不断,只是凭据仍走 env 兼容层。
// ⚠️ v3 必须在网关白名单里(splash 未登录就要取配置),见 deploy/app/confs/gateway.yaml.example。
static getappconfig([params]) { static getappconfig([params]) {
final path = ApiCrypto.enabled
? '/api/home/user_getappconfig_v3'
: '/api/home/user_getappconfig';
return DioManager().request( return DioManager().request(
NWMethod.post, NWMethod.post,
'/api/home/user_getappconfig', path,
params: params, params: params,
); );
} }
@ -477,14 +486,6 @@ class Api {
); );
} }
static getwakeupvoices([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/user_getwakeupvoices',
params: params,
);
}
static addRecordEchomeet([params]) { static addRecordEchomeet([params]) {
return DioManager().request( return DioManager().request(
NWMethod.post, NWMethod.post,

86
apps/client/lib/data/services/network/api_crypto.dart

@ -0,0 +1,86 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:encrypt/encrypt.dart' as enc;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../../../core/utils/logger.dart';
/// 网关「加密接口」的响应解密。
///
/// 服务端(modules/gateway/wservice_comp.go `writeReply`)对声明在 `EncryptMsgs` 里的接口
/// (`user_getappconfig_v2/v3`、`user_getthirdsvcs_v2`、`user_getagents_v2/v3`)把**整个响应体**
/// 做 AES-CBC 加密:key = 网关的 `GATEWAY_ENCRYPT_KEY`(16/24/32 字节),IV 固定 16 个 `'0'`,
/// PKCS5/7 填充,密文 base64;`Content-Type` 改成 `text/plain`,并加响应头 `X-Encrypted: 1`。
/// 这些响应带着第三方服务的**明文凭据**,所以整体加密后才出网关。
///
/// 密钥来自客户端 `.env` 的 `GATEWAY_ENCRYPT_KEY`,须与服务器一致。没配时 [enabled] 为 false,
/// `Api.getappconfig` 会回退到明文 v1——凭据只能走 env 兼容层,`thirdsvcs` 拿不到。
class ApiCrypto {
ApiCrypto._();
static const String _iv = '0000000000000000';
static String get _key => (dotenv.env['GATEWAY_ENCRYPT_KEY'] ?? '').trim();
/// 密钥是否配置且长度合法(AES 只接受 16/24/32 字节)。
static bool get enabled {
final k = _key;
return k.length == 16 || k.length == 24 || k.length == 32;
}
/// 解 base64(AES-CBC-PKCS7) 密文为明文字符串。密钥不合法或密文损坏时抛异常,由调用方决定退路。
static String decrypt(String cipherBase64) {
final key = enc.Key.fromUtf8(_key);
final iv = enc.IV.fromUtf8(_iv);
final aes = enc.Encrypter(enc.AES(key, mode: enc.AESMode.cbc, padding: 'PKCS7'));
return aes.decrypt64(cipherBase64.trim(), iv: iv);
}
}
/// Dio 拦截器:把 `X-Encrypted: 1` 的响应体解密并 JSON 解析,再交给后面的 [AuthInterceptor]
/// 按业务 JSON 处理。**必须排在 AuthInterceptor 之前**——它一看到不是 JSON 的 body 就当解析失败了。
///
/// 密文的 Content-Type 是 text/plain,Dio 的 `ResponseType.json` 不会去解析它,这里拿到的就是 String。
class ApiDecryptInterceptor extends Interceptor {
static const String _tag = 'ApiCrypto';
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final flag = response.headers.value('x-encrypted');
if (flag != '1') {
handler.next(response);
return;
}
final body = response.data;
if (body is! String || body.isEmpty) {
handler.next(response);
return;
}
if (!ApiCrypto.enabled) {
// 服务端加密了、客户端却没配密钥:这不是网络问题,得让人一眼看到。
Logger.e(_tag, '响应已加密但 .env 未配置 GATEWAY_ENCRYPT_KEY:${response.requestOptions.path}');
handler.reject(DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: '响应已加密但客户端未配置 GATEWAY_ENCRYPT_KEY',
));
return;
}
try {
final plain = ApiCrypto.decrypt(body);
response.data = jsonDecode(plain);
handler.next(response);
} catch (e) {
// 密钥不一致时 AES 解出来是乱码、PKCS7 去填充就会失败——报错信息要指向密钥,别让人去查网络。
Logger.e(_tag, '解密失败(多半是 GATEWAY_ENCRYPT_KEY 与服务器不一致):${response.requestOptions.path} $e');
handler.reject(DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: '响应解密失败,请检查 GATEWAY_ENCRYPT_KEY 是否与服务器一致',
));
}
}
}

4
apps/client/lib/data/services/network/dio_manager.dart

@ -8,6 +8,7 @@ import 'package:get/get.dart' hide Response, FormData;
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import '../../models/channel_app_model.dart'; import '../../models/channel_app_model.dart';
import '../version_update_service.dart'; import '../version_update_service.dart';
import 'api_crypto.dart';
import 'auth_interceptor.dart'; import 'auth_interceptor.dart';
import 'nw_method.dart'; import 'nw_method.dart';
@ -66,6 +67,9 @@ class DioManager {
// 日志拦截器必须在 AuthInterceptor 前面, // 日志拦截器必须在 AuthInterceptor 前面,
// 否则 AuthInterceptor 的 handler.resolve() 会跳过后续拦截器 // 否则 AuthInterceptor 的 handler.resolve() 会跳过后续拦截器
dio.interceptors.add(_ApiLogInterceptor()); dio.interceptors.add(_ApiLogInterceptor());
// 解密拦截器必须排在 AuthInterceptor 之前:加密接口(user_getappconfig_v3 等)的 body 是 base64 密文,
// AuthInterceptor 一看到不是业务 JSON 就当失败。见 api_crypto.dart。
dio.interceptors.add(ApiDecryptInterceptor());
dio.interceptors.add(AuthInterceptor()); dio.interceptors.add(AuthInterceptor());
} }

10
apps/client/lib/data/services/speech_impl/azure_asr_service.dart

@ -68,12 +68,12 @@ class AzureAsrService extends GetxService implements AsrService {
/// 从环境变量加载配置 /// 从环境变量加载配置
void _loadConfig() { void _loadConfig() {
// final _env = _storage.read("ENV") as Map<String, String>; // final _env = _storage.read("ENV") as Map<String, String>;
_subscriptionKey = AppConfig.env('AZURE_SPEECH_KEY') ?? ''; _subscriptionKey = AppConfig.cred('stt_azure', 'subscription_key', 'AZURE_SPEECH_KEY') ?? '';
_serviceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? ''; _serviceRegion = AppConfig.cred('stt_azure', 'region', 'AZURE_SPEECH_REGION') ?? '';
_xunfeiAppId = AppConfig.env('XUNFEI_ASR_APP_ID') ?? ''; _xunfeiAppId = AppConfig.cred('stt_iflytek', 'app_id', 'XUNFEI_ASR_APP_ID') ?? '';
_xunfeiAccessKeyId = AppConfig.env('XUNFEI_ASR_ACCESS_KEY_ID') ?? ''; _xunfeiAccessKeyId = AppConfig.cred('stt_iflytek', 'access_key_id', 'XUNFEI_ASR_ACCESS_KEY_ID') ?? '';
_xunfeiAccessKeySecret = _xunfeiAccessKeySecret =
AppConfig.env('XUNFEI_ASR_ACCESS_KEY_SECRET') ?? ''; AppConfig.cred('stt_iflytek', 'access_key_secret', 'XUNFEI_ASR_ACCESS_KEY_SECRET') ?? '';
if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) { if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) {
throw Exception( throw Exception(

28
apps/client/lib/data/services/speech_impl/azure_ast_service.dart

@ -246,29 +246,29 @@ class AzureAstService extends GetxService implements AstService {
/// 从环境变量加载配置 /// 从环境变量加载配置
void _loadConfig() { void _loadConfig() {
_subscriptionKey = AppConfig.env('AZURE_SPEECH_KEY') ?? ''; _subscriptionKey = AppConfig.cred('stt_azure', 'subscription_key', 'AZURE_SPEECH_KEY') ?? '';
_serviceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? ''; _serviceRegion = AppConfig.cred('stt_azure', 'region', 'AZURE_SPEECH_REGION') ?? '';
_azureTranslationKey = AppConfig.env('AZURE_TRANSLATION_KEY') ?? ''; _azureTranslationKey = AppConfig.cred('mt_azure', 'subscription_key', 'AZURE_TRANSLATION_KEY') ?? '';
_azureTranslationRegion = AppConfig.env('AZURE_TRANSLATION_REGION') ?? ''; _azureTranslationRegion = AppConfig.cred('mt_azure', 'region', 'AZURE_TRANSLATION_REGION') ?? '';
_xunfeiAppId = AppConfig.env('XUNFEI_ASR_APP_ID') ?? ''; _xunfeiAppId = AppConfig.cred('stt_iflytek', 'app_id', 'XUNFEI_ASR_APP_ID') ?? '';
_xunfeiAccessKeyId = AppConfig.env('XUNFEI_ASR_ACCESS_KEY_ID') ?? ''; _xunfeiAccessKeyId = AppConfig.cred('stt_iflytek', 'access_key_id', 'XUNFEI_ASR_ACCESS_KEY_ID') ?? '';
_xunfeiAccessKeySecret = _xunfeiAccessKeySecret =
AppConfig.env('XUNFEI_ASR_ACCESS_KEY_SECRET') ?? ''; AppConfig.cred('stt_iflytek', 'access_key_secret', 'XUNFEI_ASR_ACCESS_KEY_SECRET') ?? '';
_iflytekHost = AppConfig.env('IFLYTEK_ASR_HOST') ?? ''; _iflytekHost = AppConfig.cred('stt_iflytek', 'host', 'IFLYTEK_ASR_HOST') ?? '';
_doubaoAppKey = AppConfig.env('VOLC_OPENSPEECH_APP_ID') ?? ''; _doubaoAppKey = AppConfig.cred('ast_bytedance', 'app_id', 'VOLC_OPENSPEECH_APP_ID') ?? '';
_doubaoAccessKey = AppConfig.env('VOLC_OPENSPEECH_ACCESS_TOKEN') ?? ''; _doubaoAccessKey = AppConfig.cred('ast_bytedance', 'access_token', 'VOLC_OPENSPEECH_ACCESS_TOKEN') ?? '';
_doubaoResourceId = _doubaoResourceId =
AppConfig.env('VOLC_OPENSPEECH_TRANSLATION_BIGMODEL') ?? ''; AppConfig.cred('ast_bytedance', 'translation_service_type', 'VOLC_OPENSPEECH_TRANSLATION_BIGMODEL') ?? '';
// 千问端到端:只有 API Key 必须由 .env 提供; // 千问端到端:只有 API Key 必须由 .env 提供;
// 模型名与 WebSocket 地址给默认值,少配两项也能跑起来。 // 模型名与 WebSocket 地址给默认值,少配两项也能跑起来。
// (native 侧是拿到什么就用什么,空字符串会直接导致连不上, // (native 侧是拿到什么就用什么,空字符串会直接导致连不上,
// 所以兜底必须在这里做,不能指望 Config 的默认参数。) // 所以兜底必须在这里做,不能指望 Config 的默认参数。)
_alibabaAppKey = AppConfig.env('ALIBABA_OPENSPEECH_APP_KEY') ?? ''; _alibabaAppKey = AppConfig.cred('ast_alibaba', 'app_key', 'ALIBABA_OPENSPEECH_APP_KEY') ?? '';
final aliModel = AppConfig.env('ALIBABA_OPENSPEECH_APP_ID') ?? ''; final aliModel = AppConfig.cred('ast_alibaba', 'app_id', 'ALIBABA_OPENSPEECH_APP_ID') ?? '';
_alibabaAppId = aliModel.trim().isEmpty _alibabaAppId = aliModel.trim().isEmpty
? kAlibabaModelLegacy ? kAlibabaModelLegacy
: aliModel; : aliModel;
final aliUrl = AppConfig.env('ALIBABA_OPENSPEECH_APP_URL') ?? ''; final aliUrl = AppConfig.cred('ast_alibaba', 'ws_url', 'ALIBABA_OPENSPEECH_APP_URL') ?? '';
_alibabaAppURL = aliUrl.trim().isEmpty _alibabaAppURL = aliUrl.trim().isEmpty
? 'wss://dashscope.aliyuncs.com/api-ws/v1/realtime' ? 'wss://dashscope.aliyuncs.com/api-ws/v1/realtime'
: aliUrl; : aliUrl;

4
apps/client/lib/data/services/speech_impl/azure_tts_service.dart

@ -57,8 +57,8 @@ class AzureTtsService extends GetxService implements TtsService {
/// 从环境变量加载配置 /// 从环境变量加载配置
void _loadConfig() { void _loadConfig() {
// final _env = _storage.read("ENV") as Map<String, String>; // final _env = _storage.read("ENV") as Map<String, String>;
_subscriptionKey = AppConfig.env('AZURE_SPEECH_KEY') ?? ''; _subscriptionKey = AppConfig.cred('stt_azure', 'subscription_key', 'AZURE_SPEECH_KEY') ?? '';
_serviceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? ''; _serviceRegion = AppConfig.cred('stt_azure', 'region', 'AZURE_SPEECH_REGION') ?? '';
} }
/// 设置事件通道 /// 设置事件通道

3
apps/client/lib/modules/goods/views/widgets/goods_footer_section.dart

@ -10,7 +10,8 @@ class GoodsFooterSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final subColor = isDarkMode ? Colors.white60 : Colors.grey[600]; final subColor = isDarkMode ? Colors.white60 : Colors.grey[600];
final qq = AppConfig.env('iapCustomerServiceQQ') ?? ''; // 后台「第三方服务 → 应用参数」服务的 iap_customer_service_qq 字段(老键名 iapCustomerServiceQQ 作兜底)。
final qq = AppConfig.cred('app_params', 'iap_customer_service_qq', 'iapCustomerServiceQQ') ?? '';
return Padding( return Padding(
padding: EdgeInsets.symmetric(vertical: 12.h), padding: EdgeInsets.symmetric(vertical: 12.h),
child: Column( child: Column(

3
apps/client/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart

@ -68,7 +68,8 @@ class MobileElfController extends GetxController {
// 生命周期 // 生命周期
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
String get _agentId => AppConfig.env('MOBILE_ELF_AGENT_ID') ?? 'mobile_elf'; String get _agentId =>
AppConfig.cred('llm_mobile_elf', 'agent_id', 'MOBILE_ELF_AGENT_ID') ?? 'mobile_elf';
@override @override
void onInit() { void onInit() {

4
apps/client/lib/modules/voice_replication/views/recorder_bottom_sheet.dart

@ -53,8 +53,8 @@ class _RecorderBottomSheetState extends State<RecorderBottomSheet> {
} }
void _getDefaultData() async { void _getDefaultData() async {
_resourceKey = AppConfig.env('AZURE_SPEECH_KEY') ?? ''; _resourceKey = AppConfig.cred('stt_azure', 'subscription_key', 'AZURE_SPEECH_KEY') ?? '';
_resourceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? 'eastasia'; _resourceRegion = AppConfig.cred('stt_azure', 'region', 'AZURE_SPEECH_REGION') ?? 'eastasia';
_jessicaConsentId = const Uuid().v4(); _jessicaConsentId = const Uuid().v4();
Map? userInfo = _storage.read('user_info'); Map? userInfo = _storage.read('user_info');
if (userInfo != null) { if (userInfo != null) {

32
apps/client/pubspec.lock

@ -95,6 +95,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.7.0" version: "2.7.0"
asn1lib:
dependency: transitive
description:
name: asn1lib
sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024"
url: "https://pub.dev"
source: hosted
version: "1.6.5"
async: async:
dependency: transitive dependency: transitive
description: description:
@ -444,6 +452,14 @@ packages:
relative: true relative: true
source: path source: path
version: "1.0.0" version: "1.0.0"
encrypt:
dependency: "direct main"
description:
name: encrypt
sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2"
url: "https://pub.dev"
source: hosted
version: "5.0.3"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -1135,6 +1151,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation: json_annotation:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1509,6 +1533,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
url: "https://pub.dev"
source: hosted
version: "3.9.1"
pool: pool:
dependency: transitive dependency: transitive
description: description:

1
apps/client/pubspec.yaml

@ -156,6 +156,7 @@ dependencies:
path: local_plugins/qq_music path: local_plugins/qq_music
floating_ui_plugin: floating_ui_plugin:
path: local_plugins/floating_ui_plugin path: local_plugins/floating_ui_plugin
encrypt: ^5.0.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

35
apps/client/test/api_crypto_test.dart

@ -0,0 +1,35 @@
import 'dart:convert';
import 'package:eaimar/data/services/network/api_crypto.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_test/flutter_test.dart';
/// 网关加密协议的兼容性金测试。
/// 密文由 openssl 按服务端口径生成(lego/utils/crypto/aes/cbc.go:AES-CBC,IV 固定 16 个 '0',
/// PKCS5/7 填充,base64),不是用同一套 Dart 代码加密再解密——那只能证明自洽,证明不了与 Go 端一致。
void main() {
const key = '0123456789abcdef';
const cipher = '+DbMGxdFZ8VkBMivLb5FM7VgvUF+/ISnA8RRO6Nj3kyGbD3hV9fzAvC+DsTUoLiFVnHETIUGxCROudDtF4VyRJlAQQEMxpCf9Zx+19LMlFSPtWizfg+7SaJAJLZeY/lmZbCchmmMUkmFLmlyQMpaXw==';
const plain = '{"code":0,"msg":"ok","data":{"env":{"A":"1"},"thirdsvcs":[{"id":"stt_azure","fields":{"region":"eastasia"}}]}}';
test('能解开 Go 端口径生成的密文', () {
dotenv.testLoad(fileInput: 'GATEWAY_ENCRYPT_KEY=$key');
expect(ApiCrypto.enabled, isTrue);
final out = ApiCrypto.decrypt(cipher);
expect(out, plain);
final j = jsonDecode(out) as Map<String, dynamic>;
expect((j['data'] as Map)['thirdsvcs'], isA<List>());
});
test('密钥长度不是 16/24/32 → enabled 为 false,走明文 v1', () {
dotenv.testLoad(fileInput: 'GATEWAY_ENCRYPT_KEY=short');
expect(ApiCrypto.enabled, isFalse);
dotenv.testLoad(fileInput: 'GATEWAY_ENCRYPT_KEY=');
expect(ApiCrypto.enabled, isFalse);
});
test('密钥不一致 → 解密抛错(去 PKCS7 填充失败),不会静默给出乱码', () {
dotenv.testLoad(fileInput: 'GATEWAY_ENCRYPT_KEY=fedcba9876543210');
expect(() => ApiCrypto.decrypt(cipher), throwsA(anything));
});
}

44
apps/client/test/appconfig_cred_test.dart

@ -0,0 +1,44 @@
import 'package:eaimar/data/models/appconfig.dart';
import 'package:eaimar/data/models/appconfig_model.dart';
import 'package:flutter_test/flutter_test.dart';
/// AppConfig.cred 的取值口径:thirdsvcs 的 (服务 id, 字段) 优先,退回 env[老键名],两边都没有 → null。
/// 各业务服务 2026-09-14 起都改走它;口径错了表现为「某个能力鉴权失败」,不报配置错误。
void main() {
UserGetAppConfigResp build({Map<String, String> env = const {}, List<ThirdSvc> svcs = const []}) =>
UserGetAppConfigResp(env: env, agents: const [], mcps: const {}, products: const [], thirdsvcs: svcs);
test('thirdsvcs 有值时优先于 env', () {
AppConfig.initialize(build(
env: {'AZURE_SPEECH_KEY': 'from-env'},
svcs: [ThirdSvc(id: 'stt_azure', name: '', provider: 'azure', categories: '1', fields: {'subscription_key': 'from-svc'})],
));
expect(AppConfig.cred('stt_azure', 'subscription_key', 'AZURE_SPEECH_KEY'), 'from-svc');
expect(AppConfig.usingThirdSvcs, isTrue);
});
test('thirdsvcs 没有该服务/字段或为空串 → 退回 env', () {
AppConfig.initialize(build(
env: {'AZURE_SPEECH_KEY': 'from-env'},
svcs: [ThirdSvc(id: 'stt_azure', name: '', provider: 'azure', categories: '1', fields: {'subscription_key': ' '})],
));
expect(AppConfig.cred('stt_azure', 'subscription_key', 'AZURE_SPEECH_KEY'), 'from-env');
expect(AppConfig.cred('mt_azure', 'subscription_key', 'AZURE_SPEECH_KEY'), 'from-env');
});
test('两边都没有 → null(空串也算没有),调用方的 ?? 默认值才能生效', () {
AppConfig.initialize(build(env: {'AZURE_TRANSLATION_ENDPOINT': ''}));
expect(AppConfig.cred('mt_azure', 'endpoint', 'AZURE_TRANSLATION_ENDPOINT'), isNull);
expect(AppConfig.cred('mt_azure', 'endpoint', 'AZURE_TRANSLATION_ENDPOINT') ?? 'default', 'default');
expect(AppConfig.usingThirdSvcs, isFalse);
});
test('svcsByCategory 按逗号整项比对', () {
AppConfig.initialize(build(svcs: [
ThirdSvc(id: 'a', name: '', provider: '', categories: '1', fields: {}),
ThirdSvc(id: 'b', name: '', provider: '', categories: '10,11', fields: {}),
]));
expect(AppConfig.svcsByCategory(1).map((s) => s.id), ['a']);
expect(AppConfig.svcsByCategory(11).map((s) => s.id), ['b']);
});
}

44
apps/client/test/appconfig_model_test.dart

@ -70,4 +70,48 @@ void main() {
expect(a.isOnline, isFalse); expect(a.isOnline, isFalse);
expect(a.voice, ''); expect(a.voice, '');
}); });
thirdSvcTests();
}
void thirdSvcTests() {
group('thirdsvcs', () {
test('v3 形状能解析,字段是 map', () {
final resp = UserGetAppConfigResp.fromJson({
'env': <String, String>{},
'thirdsvcs': [
{
'id': 'stt_azure',
'name': '微软 Azure 语音',
'provider': 'azure',
'categories': '1',
'fields': {'subscription_key': 'k', 'region': 'southeastasia'},
},
// 空 map 字面量在 Dart 里会推断成 Map<dynamic,dynamic>,生成代码按 Map<String,dynamic> 强转会炸;
// 真实响应经 jsonDecode 出来就是 Map<String,dynamic>,这里显式标类型只是让测试字面量与之一致。
{'id': 'mcp_x', 'name': 'x', 'provider': '', 'categories': '10,11', 'fields': <String, String>{}},
],
});
expect(resp.thirdsvcs.length, 2);
expect(resp.thirdsvcs.first.fields['region'], 'southeastasia');
});
test('走明文 v1 时没有 thirdsvcs 键 → 空列表,不抛', () {
final resp = UserGetAppConfigResp.fromJson({'env': {'A': '1'}});
expect(resp.thirdsvcs, isEmpty);
expect(resp.env['A'], '1');
});
test('hasCategory 按逗号拆分整项比对,1 不会命中 10/11', () {
final s = ThirdSvc(id: 'x', name: '', provider: '', categories: '10,11', fields: {});
expect(s.hasCategory(1), isFalse);
expect(s.hasCategory(10), isTrue);
expect(s.hasCategory(11), isTrue);
});
test('条目缺字段时用默认值', () {
final resp = UserGetAppConfigResp.fromJson({'thirdsvcs': [{'id': 'only_id'}]});
expect(resp.thirdsvcs.single.id, 'only_id');
expect(resp.thirdsvcs.single.fields, isEmpty);
});
});
} }

28
apps/services/comm/appscope.go

@ -65,3 +65,31 @@ func AppRegion() string {
return RegionCodeOverseas return RegionCodeOverseas
} }
} }
// AppNamesHas 判断「绑定应用名 CSV」是否覆盖 app(DBProduct.Appnames 这种一对多绑定列专用)。
//
// 语义:
// - csv 为空 = **未绑定任何应用** → 返回 true,不限应用。存量产品大多没填这一列,
// 按「未绑定即不下发」处理会让一整批设备的绑定/OTA 当场失效,失败方向太重。
// - csv 非空 → 按逗号切开逐项**精确等值**比较(两侧 TrimSpace)。
//
// ⚠️ 绝不能退化成 SQL 的 `appnames LIKE '%app%'`:应用名之间存在真实的前缀包含关系
// (线上就有 `EAIMAR` 与 `EAIMAR-TEST`),子串匹配会让正式应用命中测试应用的产品。
// 所以这里只做内存里的精确比对,不进 SQL。
func AppNamesHas(csv, app string) bool {
csv = strings.TrimSpace(csv)
if csv == "" {
return true
}
app = strings.TrimSpace(app)
if app == "" {
// 未声明部署身份:与 AppName() 的约定一致,按「不限作用域」处理,别把配置读空。
return true
}
for _, p := range strings.Split(csv, ",") {
if strings.TrimSpace(p) == app {
return true
}
}
return false
}

31
apps/services/comm/appscope_test.go

@ -0,0 +1,31 @@
package comm
import "testing"
// AppNamesHas 是产品下发的作用域闸门(DBProduct.Appnames)。两条底线:
// - 前缀包含关系不能算命中——线上真实存在 EAIMAR 与 EAIMAR-TEST 两个应用名,
// 任何形式的子串匹配都会让正式应用拿到测试应用的产品(再由客户端挑成绑定 pid);
// - 空 CSV = 未绑定 = 不限应用,存量产品大多没填这一列,判严会让整批设备绑定/OTA 失效。
func TestAppNamesHas(t *testing.T) {
cases := []struct {
name string
csv string
app string
want bool
}{
{"未绑定任何应用即不限应用", "", "EAIMAR", true},
{"未声明部署身份时不收窄", "EAIMAR", "", true},
{"单个精确命中", "EAIMAR", "EAIMAR", true},
{"多个里命中一个", "deepGlass,EAIMAR", "EAIMAR", true},
{"带空格照样命中", " deepGlass , EAIMAR ", "EAIMAR", true},
{"不同应用不命中", "deepGlass", "EAIMAR", false},
{"前缀不算命中(正式不拿测试的产品)", "EAIMAR-TEST", "EAIMAR", false},
{"反向前缀也不算命中", "EAIMAR", "EAIMAR-TEST", false},
{"大小写敏感", "eaimar", "EAIMAR", false},
}
for _, c := range cases {
if got := AppNamesHas(c.csv, c.app); got != c.want {
t.Errorf("%s: AppNamesHas(%q, %q) = %v, want %v", c.name, c.csv, c.app, got, c.want)
}
}
}

2
apps/services/comm/const.go

@ -82,7 +82,6 @@ const (
TableDeviceMac = "device_mac" //设备MAC表(全局一张,主键 code=MAC;productid 只是归属列,不再是查询键) TableDeviceMac = "device_mac" //设备MAC表(全局一张,主键 code=MAC;productid 只是归属列,不再是查询键)
TableGoods = "goods" //支付商品表 TableGoods = "goods" //支付商品表
TablePayOrder = "payorder" //支付订单表 TablePayOrder = "payorder" //支付订单表
TableWakeupVoice = "wakeupvoice" //唤醒语音表
TableUserUseLog = "useruselog" //用户日志 TableUserUseLog = "useruselog" //用户日志
TableUserStatistics = "userstatistics" //用户统计表 TableUserStatistics = "userstatistics" //用户统计表
@ -109,6 +108,7 @@ const (
TableThirdSvcConfig = "third_svc_config" //第三方服务配置表(JSONB 字段定义 + 默认值,移植自 deep_server) TableThirdSvcConfig = "third_svc_config" //第三方服务配置表(JSONB 字段定义 + 默认值,移植自 deep_server)
TableThirdSvcRegionOverride = "third_svc_region_override" //第三方服务区域字段覆盖表(每服务×区域一行,JSONB:值覆盖/区域专属字段/停用字段) TableThirdSvcRegionOverride = "third_svc_region_override" //第三方服务区域字段覆盖表(每服务×区域一行,JSONB:值覆盖/区域专属字段/停用字段)
TableThirdSvcTemplate = "third_svc_template" //第三方服务商字段模板表(数据驱动,启动 seed 内置常见服务商) TableThirdSvcTemplate = "third_svc_template" //第三方服务商字段模板表(数据驱动,启动 seed 内置常见服务商)
TableSvcCategory = "svc_category" //第三方服务类别表(后台可增删改;内置 1~11 的 id 不可改不可删,运行时语义只挂在 comm.SvcCat* 那几个上)
TableCallTranslatePair = "call_translate_pair" //【已废弃】旧通话翻译语言对表(区域×单语言对×直接拼服务),由 call_translate_rule 取代,代码不再读写 TableCallTranslatePair = "call_translate_pair" //【已废弃】旧通话翻译语言对表(区域×单语言对×直接拼服务),由 call_translate_rule 取代,代码不再读写
TableCallTranslateRule = "call_translate_rule" //通话翻译规则表(区域×多源/多目标语言×优先级 → agent) TableCallTranslateRule = "call_translate_rule" //通话翻译规则表(区域×多源/多目标语言×优先级 → agent)
TableChannelAppCfg = "channel_app" //渠道分发配置表(console 主库,按 app_name+channel;下载地址/上架状态/支付渠道/跳过绑定) TableChannelAppCfg = "channel_app" //渠道分发配置表(console 主库,按 app_name+channel;下载地址/上架状态/支付渠道/跳过绑定)

3
apps/services/comm/svcpool.go

@ -215,6 +215,9 @@ var serverOnlySvcCats = map[int32]bool{
SvcCatIdVerify: true, SvcCatIdVerify: true,
} }
// IsServerOnlySvcCat 单个类别 id 是否服务端专用(后台类别列表用它标只读属性 server_only)。
func IsServerOnlySvcCat(cat int32) bool { return serverOnlySvcCats[cat] }
// IsServerOnlySvc 判断某服务(按其逗号分隔的 categories)是否服务端专用、不得下发客户端。 // IsServerOnlySvc 判断某服务(按其逗号分隔的 categories)是否服务端专用、不得下发客户端。
// 只要类别里含任一服务端专用类别就返回 true——宁可少发也不能把主账号凭据发出去。 // 只要类别里含任一服务端专用类别就返回 true——宁可少发也不能把主账号凭据发出去。
func IsServerOnlySvc(categories string) bool { func IsServerOnlySvc(categories string) bool {

27
apps/services/modules/api/api_addconfig.go

@ -1,27 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 添加配置
func (this *apiComp) AddConfig(session comm.IUserSession, req *pb.ApiAddConfigReq) (resp *pb.ApiAddConfigResp, errdata *pb.ErrorData) {
var (
err error
)
if err = this.module.model.addconfig(req.Config); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("登录失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
if err = this.service.RpcBroadcast(session, comm.Service_Home, string(comm.Rpc_ModifyAppConifg), &pb.Rpc_EmptyReq{}, nil); err != nil {
this.module.Error("广播失败了!", log.Field{Key: "err", Value: err.Error()})
}
resp = &pb.ApiAddConfigResp{}
return
}

21
apps/services/modules/api/api_addwakeupvoice.go

@ -1,21 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 新增唤醒语音
func (this *apiComp) AddWakeupVoice(session comm.IUserSession, req *pb.ApiAddWakeupVoiceReq) (resp *pb.ApiAddWakeupVoiceResp, errdata *pb.ErrorData) {
var (
err error
)
if err = this.module.model.addwakeupvoice(req.Voice); err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
this.module.Error("AddWakeupVoice 失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiAddWakeupVoiceResp{}
return
}

27
apps/services/modules/api/api_delconfig.go

@ -1,27 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 登录后台
func (this *apiComp) DelConfig(session comm.IUserSession, req *pb.ApiDelConfigReq) (resp *pb.ApiDelConfigResp, errdata *pb.ErrorData) {
var (
err error
)
if err = this.module.model.delconfig(req.Id); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("登录失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
if err = this.service.RpcBroadcast(session, comm.Service_Home, string(comm.Rpc_ModifyAppConifg), &pb.Rpc_EmptyReq{}, nil); err != nil {
this.module.Error("广播失败了!", log.Field{Key: "err", Value: err.Error()})
}
resp = &pb.ApiDelConfigResp{}
return
}

21
apps/services/modules/api/api_delwakeupvoice.go

@ -1,21 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 删除唤醒语音
func (this *apiComp) DelWakeupVoice(session comm.IUserSession, req *pb.ApiDelWakeupVoiceReq) (resp *pb.ApiDelWakeupVoiceResp, errdata *pb.ErrorData) {
var (
err error
)
if err = this.module.model.delwakeupvoice(req.Id); err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
this.module.Error("DelWakeupVoice 失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiDelWakeupVoiceResp{}
return
}

28
apps/services/modules/api/api_getconfig.go

@ -1,28 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 获取配置
func (this *apiComp) GetConfig(session comm.IUserSession, req *pb.ApiGetConfigReq) (resp *pb.ApiGetConfigResp, errdata *pb.ErrorData) {
var (
config []*pb.DBAppConfigItem
err error
)
if config, err = this.module.model.config(); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("登录失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiGetConfigResp{
Config: config,
}
return
}

28
apps/services/modules/api/api_getwakeupvoice.go

@ -1,28 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 获取唤醒语音列表
func (this *apiComp) GetWakeupVoice(session comm.IUserSession, req *pb.ApiGetWakeupVoiceReq) (resp *pb.ApiGetWakeupVoiceResp, errdata *pb.ErrorData) {
var (
model *pb.DBWakeupVoice
err error
)
if model, err = this.module.model.wakeupvoice(req.Id); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("GetWakeupVoice 失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiGetWakeupVoiceResp{
Voice: model,
}
return
}

28
apps/services/modules/api/api_getwakeupvoices.go

@ -1,28 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 获取唤醒语音列表
func (this *apiComp) GetWakeupVoices(session comm.IUserSession, req *pb.ApiGetWakeupVoicesReq) (resp *pb.ApiGetWakeupVoicesResp, errdata *pb.ErrorData) {
var (
models []*pb.DBWakeupVoice
err error
)
if models, err = this.module.model.wakeupvoices(); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("GetWakeupVoices 失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiGetWakeupVoicesResp{
Voices: models,
}
return
}

27
apps/services/modules/api/api_updateconfig.go

@ -1,27 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 更新配置
func (this *apiComp) UpdateConfig(session comm.IUserSession, req *pb.ApiUpdateConfigReq) (resp *pb.ApiUpdateConfigResp, errdata *pb.ErrorData) {
var (
err error
)
if err = this.module.model.updateconfig(req.Config); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("UpdateAgent Fail!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
if err = this.service.RpcBroadcast(session, comm.Service_Home, string(comm.Rpc_ModifyAppConifg), &pb.Rpc_EmptyReq{}, nil); err != nil {
this.module.Error("广播失败了!", log.Field{Key: "err", Value: err.Error()})
}
resp = &pb.ApiUpdateConfigResp{}
return
}

21
apps/services/modules/api/api_updatewakeupvoice.go

@ -1,21 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 更新唤醒语音
func (this *apiComp) UpdateWakeupVoice(session comm.IUserSession, req *pb.ApiUpdateWakeupVoiceReq) (resp *pb.ApiUpdateWakeupVoiceResp, errdata *pb.ErrorData) {
var (
err error
)
if err = this.module.model.updatewakeupvoice(req.Voice); err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
this.module.Error("UpdateWakeupVoice 失败!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiUpdateWakeupVoiceResp{}
return
}

3
apps/services/modules/api/core.go

@ -55,9 +55,6 @@ var auditableAPIMap = map[string]bool{
"api_updatetemplates": true, "api_updatetemplates": true,
"api_deltemplate": true, "api_deltemplate": true,
// 唤醒词 // 唤醒词
"api_addwakeupvoice": true,
"api_updatewakeupvoice": true,
"api_delwakeupvoice": true,
// 用户管理 // 用户管理
"api_updateuser": true, "api_updateuser": true,
// 邮件 // 邮件

11
apps/services/modules/api/interceptor_permission.go

@ -95,12 +95,6 @@ var apiPageMap = map[string]string{
"api_getuseruselogspaged": scopeAny, "api_getuseruselogspaged": scopeAny,
"api_getadminresourcelogs": scopeAny, // 超管查全部,其他账号只能查自己;handler 内做隔离 "api_getadminresourcelogs": scopeAny, // 超管查全部,其他账号只能查自己;handler 内做隔离
// === config 服务配置 ===
"api_getconfig": "config",
"api_addconfig": "config",
"api_updateconfig": "config",
"api_delconfig": "config",
// === mcp MCP配置 === // === mcp MCP配置 ===
"api_getmcpserver": "mcp", "api_getmcpserver": "mcp",
"api_getmcpservers": "mcp", "api_getmcpservers": "mcp",
@ -130,11 +124,6 @@ var apiPageMap = map[string]string{
"api_delchannelapp": "channelapp", "api_delchannelapp": "channelapp",
// === wakeup 唤醒词管理 === // === wakeup 唤醒词管理 ===
"api_getwakeupvoice": "wakeup",
"api_getwakeupvoices": "wakeup",
"api_addwakeupvoice": "wakeup",
"api_updatewakeupvoice": "wakeup",
"api_delwakeupvoice": "wakeup",
// === sysconfig 系统配置 === // === sysconfig 系统配置 ===

155
apps/services/modules/api/model.go

@ -59,9 +59,6 @@ func (this *modelComp) Init(service core.IService, module core.IModule, comp cor
if err = postgres.CreateTable(comm.TableEchomeetTemplate, &pb.DBEchoMeetTemplate{}); err != nil { if err = postgres.CreateTable(comm.TableEchomeetTemplate, &pb.DBEchoMeetTemplate{}); err != nil {
this.module.Errorln(err) this.module.Errorln(err)
} }
if err = mysql.CreateTable(comm.TableWakeupVoice, &pb.DBWakeupVoice{}); err != nil {
this.module.Errorln(err)
}
if err = postgres.CreateTable(comm.TableSolutionProvider, &pb.DBSolutionProvider{}); err != nil { if err = postgres.CreateTable(comm.TableSolutionProvider, &pb.DBSolutionProvider{}); err != nil {
this.module.Errorln(err) this.module.Errorln(err)
@ -125,23 +122,30 @@ func (this *modelComp) Init(service core.IService, module core.IModule, comp cor
this.module.Errorln(err) this.module.Errorln(err)
return err return err
} }
if err = postgres.CreateTable(comm.TableWakeupVoice, &pb.DBWakeupVoice{}); err != nil {
this.module.Errorln(err)
return err
} else {
//设置wakeupvoice表的主键从0xD001开始(仅空表生效,跨 MySQL/Postgres 通用)
postgres.AutoIncrementStart(comm.TableWakeupVoice, "id", 0xD001)
}
if err = mysql.CreateTable(comm.TableProductStat, &pb.DBProductStat{}); err != nil { if err = mysql.CreateTable(comm.TableProductStat, &pb.DBProductStat{}); err != nil {
this.module.Errorln(err) this.module.Errorln(err)
} }
model := &pb.DBAdminUser{ // 默认超管账号:**只在不存在时**播种。
Account: this.module.options.AdninAccount, // 原先是每次启动无条件 Insert 一次,且返回值都没接——账号已存在时 MySQL 抛
// `Duplicate entry 'admin' for key 'adnim.PRIMARY'`,GORM 把它打成一条红色 ERROR,
// 于是每次重启日志里都有一条看着像启动失败、实则只是重复播种的报错。
// 不能改用 Save/Upsert:那会把运营改过的密码在每次重启时刷回配置文件里的初始值。
if account := this.module.options.AdninAccount; account != "" {
exist := &pb.DBAdminUser{}
err := mysql.FindOne(comm.TableAdmin, exist, "account=?", account)
switch {
case err == mysql.ErrNoDocuments:
if err = mysql.Insert(comm.TableAdmin, &pb.DBAdminUser{
Account: account,
Password: this.module.options.AdninPassword, Password: this.module.options.AdninPassword,
Identity: pb.Identity_Admin, Identity: pb.Identity_Admin,
}); err != nil {
this.module.Errorln(err)
}
case err != nil:
this.module.Errorln(err)
}
} }
// 执行分页查询
mysql.Insert(comm.TableAdmin, model)
return return
} }
func (this *modelComp) Start() (err error) { func (this *modelComp) Start() (err error) {
@ -268,30 +272,6 @@ func (this *modelComp) updateuser(user *pb.DBUser) (err error) {
return return
} }
// 配置项
func (this *modelComp) config() (config []*pb.DBAppConfigItem, err error) {
config = make([]*pb.DBAppConfigItem, 0)
err = mysql.Find(comm.TableAppConfig, &config, "")
return
}
// 添加配置
func (this *modelComp) addconfig(config ...*pb.DBAppConfigItem) (err error) {
err = mysql.Insert(comm.TableAppConfig, config)
return
}
// 更新配置
func (this *modelComp) updateconfig(config ...*pb.DBAppConfigItem) (err error) {
err = mysql.Save(comm.TableAppConfig, config)
return
}
func (this *modelComp) delconfig(id uint64) (err error) {
err = mysql.Delete(comm.TableAppConfig, "id=?", id)
return
}
// 智能体 // 智能体
func (this *modelComp) agents() (agents []*pb.DBAgent, err error) { func (this *modelComp) agents() (agents []*pb.DBAgent, err error) {
agents = make([]*pb.DBAgent, 0) agents = make([]*pb.DBAgent, 0)
@ -322,11 +302,17 @@ func (this *modelComp) delagent(id string) (err error) {
} }
// Mcp 服务:从 svc_config(MCP 服务类型) + 区域覆盖解析该区域可用的 MCP 服务器。 // Mcp 服务:从 svc_config(MCP 服务类型) + 区域覆盖解析该区域可用的 MCP 服务器。
// MCP 已并入第三方服务(comm.SvcCatMCP),服务定义全局(app_name=”),url/tools 按区域 fork。 // MCP 已并入第三方服务(comm.SvcCatMCP),url/tools 按区域 fork。
//
// ⚠️ 作用域是**本部署的应用**(comm.AppName()),不是全局层。2026-09-14 服务配置按应用隔离后,
// console 启动的 migrateGlobalScopeToApps 会把全局层(app_name='')整个分给各应用、跑完即空,
// 所以写死 "" 的查询恒返回 0 行——表现为 MCP 列表永远是空的、且不报任何错。写入更糟:
// 建在全局层的服务本应用读不到(user/model_config.go 的 getmcpservers 按 comm.AppName() 查),
// 等 console 下次重启还会被当成"全局默认"分发给**所有**应用。
func (this *modelComp) mcpservers(region pb.Region) (servers []*pb.DBMcpServer, err error) { func (this *modelComp) mcpservers(region pb.Region) (servers []*pb.DBMcpServer, err error) {
servers = make([]*pb.DBMcpServer, 0) servers = make([]*pb.DBMcpServer, 0)
svcs := make([]*comm.ThirdSvcConfig, 0) svcs := make([]*comm.ThirdSvcConfig, 0)
if err = postgres.Find(comm.TableSvcConfig, &svcs, "app_name=? AND enable=?", "", true); err != nil { if err = postgres.Find(comm.TableSvcConfig, &svcs, "app_name=? AND enable=?", comm.AppName(), true); err != nil {
return return
} }
for _, svc := range svcs { for _, svc := range svcs {
@ -344,7 +330,7 @@ func (this *modelComp) mcpservers(region pb.Region) (servers []*pb.DBMcpServer,
// mcpRegionOverride 读取某 MCP 服务在指定区域的覆盖(不存在返回 nil)。 // mcpRegionOverride 读取某 MCP 服务在指定区域的覆盖(不存在返回 nil)。
func (this *modelComp) mcpRegionOverride(svcId string, region int32) *comm.SvcRegionOverride { func (this *modelComp) mcpRegionOverride(svcId string, region int32) *comm.SvcRegionOverride {
ovr := &comm.SvcRegionOverride{} ovr := &comm.SvcRegionOverride{}
if e := postgres.FindOne(comm.TableSvcRegionOverride, ovr, "app_name=? AND svc_id=? AND region=?", "", svcId, region); e != nil { if e := postgres.FindOne(comm.TableSvcRegionOverride, ovr, "app_name=? AND svc_id=? AND region=?", comm.AppName(), svcId, region); e != nil {
return nil return nil
} }
return ovr return ovr
@ -353,7 +339,7 @@ func (this *modelComp) mcpRegionOverride(svcId string, region int32) *comm.SvcRe
// Mcp 服务:按 id 取单个(无区域信息,优先基础配置,回退首个有 url 的区域覆盖)。 // Mcp 服务:按 id 取单个(无区域信息,优先基础配置,回退首个有 url 的区域覆盖)。
func (this *modelComp) mcpserver(id string) (server *pb.DBMcpServer, err error) { func (this *modelComp) mcpserver(id string) (server *pb.DBMcpServer, err error) {
svc := &comm.ThirdSvcConfig{} svc := &comm.ThirdSvcConfig{}
if err = postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", "", id); err != nil { if err = postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", comm.AppName(), id); err != nil {
return return
} }
if !comm.CategoriesHasMCP(svc.Categories) { if !comm.CategoriesHasMCP(svc.Categories) {
@ -365,7 +351,7 @@ func (this *modelComp) mcpserver(id string) (server *pb.DBMcpServer, err error)
return return
} }
ovrs := make([]*comm.SvcRegionOverride, 0) ovrs := make([]*comm.SvcRegionOverride, 0)
if e := postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=? AND svc_id=?", "", id); e == nil { if e := postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=? AND svc_id=?", comm.AppName(), id); e == nil {
for _, ovr := range ovrs { for _, ovr := range ovrs {
if s := comm.ResolveMcpServer(svc, ovr, ovr.Region); s != nil { if s := comm.ResolveMcpServer(svc, ovr, ovr.Region); s != nil {
server = s server = s
@ -382,10 +368,11 @@ func (this *modelComp) mcpserver(id string) (server *pb.DBMcpServer, err error)
func (this *modelComp) upsertMcpSvc(s *pb.DBMcpServer) (err error) { func (this *modelComp) upsertMcpSvc(s *pb.DBMcpServer) (err error) {
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
svc := &comm.ThirdSvcConfig{} svc := &comm.ThirdSvcConfig{}
miss := postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", "", s.Id) != nil app := comm.AppName()
miss := postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", app, s.Id) != nil
if miss { if miss {
svc = &comm.ThirdSvcConfig{ svc = &comm.ThirdSvcConfig{
AppName: "", Id: s.Id, Name: s.Name, Provider: "custom", AppName: app, Id: s.Id, Name: s.Name, Provider: "custom",
Categories: strconv.Itoa(int(comm.SvcCatMCP)), Description: s.Description, Enable: s.Enable, Categories: strconv.Itoa(int(comm.SvcCatMCP)), Description: s.Description, Enable: s.Enable,
Fields: comm.McpBaseFields(), Createtime: now, Updatetime: now, Fields: comm.McpBaseFields(), Createtime: now, Updatetime: now,
} }
@ -402,9 +389,9 @@ func (this *modelComp) upsertMcpSvc(s *pb.DBMcpServer) (err error) {
} }
} }
ovr := &comm.SvcRegionOverride{} ovr := &comm.SvcRegionOverride{}
newOvr := postgres.FindOne(comm.TableSvcRegionOverride, ovr, "app_name=? AND svc_id=? AND region=?", "", s.Id, int32(s.Region)) != nil newOvr := postgres.FindOne(comm.TableSvcRegionOverride, ovr, "app_name=? AND svc_id=? AND region=?", app, s.Id, int32(s.Region)) != nil
if newOvr { if newOvr {
ovr = &comm.SvcRegionOverride{AppName: "", SvcId: s.Id, Region: int32(s.Region)} ovr = &comm.SvcRegionOverride{AppName: app, SvcId: s.Id, Region: int32(s.Region)}
} }
ovr.Overrides = map[string]string{"url": s.Url, "type": strconv.Itoa(int(s.Stype)), "tools": s.Tools} ovr.Overrides = map[string]string{"url": s.Url, "type": strconv.Itoa(int(s.Stype)), "tools": s.Tools}
ovr.Updatetime = now ovr.Updatetime = now
@ -431,10 +418,11 @@ func (this *modelComp) delmcpservers(ids []string) (err error) {
if len(ids) == 0 { if len(ids) == 0 {
return return
} }
if err = postgres.Delete(comm.TableSvcConfig, "app_name=? AND id IN ?", "", ids); err != nil { app := comm.AppName()
if err = postgres.Delete(comm.TableSvcConfig, "app_name=? AND id IN ?", app, ids); err != nil {
return return
} }
return postgres.Delete(comm.TableSvcRegionOverride, "app_name=? AND svc_id IN ?", "", ids) return postgres.Delete(comm.TableSvcRegionOverride, "app_name=? AND svc_id IN ?", app, ids)
} }
// 渠道商(全局实体,id=8位短码)。与 console 侧同表同库,两端实现须保持一致。 // 渠道商(全局实体,id=8位短码)。与 console 侧同表同库,两端实现须保持一致。
@ -732,11 +720,19 @@ func (this *modelComp) decrProductStatActivated(productid uint32) (err error) {
} }
// 会议记录模板管理--------------------------------------------------------------------- // 会议记录模板管理---------------------------------------------------------------------
//
// ⚠️ 公共模板 2026-09-14 起**按应用隔离**(作用域键 app_name,console 的「会议模板」页按应用管,
// 客户端侧 echomeet/model.go 也一律带 comm.AppName() 查)。这里的每个查询都必须带上作用域:
// - 不带 → 列表把所有应用的模板混在一起(两个应用各 287 条就会列出 574 条),按 id 改/删还能动到别人的;
// - 新增不写 app_name → 落在空作用域,客户端按 app_name 查**永远读不到**,且不报任何错。
// pb.DBEchoMeetTemplate 没有 app_name 字段(列由 console 用 ALTER 补),所以只能在 WHERE 里用它、
// 插入后再补一刀 UPDATE——与 console 的 addMeetTemplate 同一套做法。
// 列表查询:排除 outline / template 大文本字段,避免传输过大 // 列表查询:排除 outline / template 大文本字段,避免传输过大
func (this *modelComp) gettemplates() (models []*pb.DBEchoMeetTemplate, err error) { func (this *modelComp) gettemplates() (models []*pb.DBEchoMeetTemplate, err error) {
models = make([]*pb.DBEchoMeetTemplate, 0) models = make([]*pb.DBEchoMeetTemplate, 0)
err = postgres.Table(comm.TableEchomeetTemplate). err = postgres.Table(comm.TableEchomeetTemplate).
Select("id, tid, source, title, description, language, ttype, tags, icon, sort"). Select("id, tid, source, title, description, language, ttype, tags, icon, sort").
Where("app_name = ?", comm.AppName()).
Order("sort DESC"). Order("sort DESC").
Find(&models).Error Find(&models).Error
return return
@ -745,21 +741,48 @@ func (this *modelComp) gettemplates() (models []*pb.DBEchoMeetTemplate, err erro
// 详情查询:返回完整字段(含 outline / template) // 详情查询:返回完整字段(含 outline / template)
func (this *modelComp) gettemplate(id uint64) (model *pb.DBEchoMeetTemplate, err error) { func (this *modelComp) gettemplate(id uint64) (model *pb.DBEchoMeetTemplate, err error) {
model = &pb.DBEchoMeetTemplate{} model = &pb.DBEchoMeetTemplate{}
err = postgres.FindOne(comm.TableEchomeetTemplate, &model, "id=?", id) err = postgres.FindOne(comm.TableEchomeetTemplate, &model, "id=? AND app_name=?", id, comm.AppName())
return return
} }
func (this *modelComp) deltemplates(ids []uint64) (err error) { func (this *modelComp) deltemplates(ids []uint64) (err error) {
err = postgres.Delete(comm.TableEchomeetTemplate, "id IN ?", ids) err = postgres.Delete(comm.TableEchomeetTemplate, "id IN ? AND app_name=?", ids, comm.AppName())
return return
} }
func (this *modelComp) addtemplates(templates []*pb.DBEchoMeetTemplate) (err error) { func (this *modelComp) addtemplates(templates []*pb.DBEchoMeetTemplate) (err error) {
err = postgres.Insert(comm.TableEchomeetTemplate, templates) if len(templates) == 0 {
return
}
if err = postgres.Insert(comm.TableEchomeetTemplate, templates); err != nil {
return return
}
ids := make([]uint64, 0, len(templates))
for _, t := range templates {
ids = append(ids, t.Id)
}
// Insert 后主键已回填;补写作用域列(pb struct 里没有它,Insert 带不上)。
return postgres.Table(comm.TableEchomeetTemplate).
Where("id IN ?", ids).Update("app_name", comm.AppName()).Error
} }
func (this *modelComp) updatetemplates(templates []*pb.DBEchoMeetTemplate) (err error) { func (this *modelComp) updatetemplates(templates []*pb.DBEchoMeetTemplate) (err error) {
if len(templates) == 0 {
return
}
// 先确认这批 id 全在本应用名下:Save 按主键整行覆盖,漏了这一步就能拿别的应用的 id 改它的模板。
ids := make([]uint64, 0, len(templates))
for _, t := range templates {
ids = append(ids, t.Id)
}
var n int64
if err = postgres.Table(comm.TableEchomeetTemplate).
Where("id IN ? AND app_name=?", ids, comm.AppName()).Count(&n).Error; err != nil {
return
}
if int(n) != len(ids) {
return fmt.Errorf("模板不存在或不属于本应用(%s):请求 %d 条,命中 %d 条", comm.AppName(), len(ids), n)
}
err = postgres.Save(comm.TableEchomeetTemplate, templates) err = postgres.Save(comm.TableEchomeetTemplate, templates)
return return
} }
@ -797,38 +820,10 @@ func (this *modelComp) delgoods(id string) (err error) {
return return
} }
// 唤醒词管理---------------------------------------------------------------------
// 查询唤醒语音列表
func (this *modelComp) wakeupvoices() (models []*pb.DBWakeupVoice, err error) {
models = make([]*pb.DBWakeupVoice, 0)
err = postgres.Find(comm.TableWakeupVoice, &models, "")
return
}
// 查询唤醒语音详情
func (this *modelComp) wakeupvoice(id uint32) (model *pb.DBWakeupVoice, err error) {
model = &pb.DBWakeupVoice{}
err = postgres.FindOne(comm.TableWakeupVoice, &model, "id=?", id)
return
}
// 添加唤醒语音
func (this *modelComp) addwakeupvoice(voice *pb.DBWakeupVoice) (err error) {
err = postgres.Insert(comm.TableWakeupVoice, voice)
return
}
// 更新唤醒语音
func (this *modelComp) updatewakeupvoice(voice *pb.DBWakeupVoice) (err error) {
err = postgres.Save(comm.TableWakeupVoice, voice)
return
}
// 删除唤醒语音
func (this *modelComp) delwakeupvoice(id uint32) (err error) {
err = postgres.Delete(comm.TableWakeupVoice, "id=?", id)
return
}
func (this *modelComp) getPayOrders(where string, page, size int32, args ...interface{}) (models []*pb.DBPayOrder, total int64, err error) { func (this *modelComp) getPayOrders(where string, page, size int32, args ...interface{}) (models []*pb.DBPayOrder, total int64, err error) {
models = make([]*pb.DBPayOrder, 0) models = make([]*pb.DBPayOrder, 0)

6
apps/services/modules/api/model_test.go

@ -419,12 +419,6 @@ func Test_DB(t *testing.T) {
if err = sys.CreateTable(comm.TableGoods, &pb.DBGoods{}); err != nil { if err = sys.CreateTable(comm.TableGoods, &pb.DBGoods{}); err != nil {
fmt.Printf("创建表失败: %v", err) fmt.Printf("创建表失败: %v", err)
} }
if err = sys.CreateTable(comm.TableWakeupVoice, &pb.DBWakeupVoice{}); err != nil {
fmt.Printf("创建表失败: %v", err)
} else {
//设置product表的主键从1000开始
sys.Exec(fmt.Sprintf("ALTER TABLE %s AUTO_INCREMENT = %d", comm.TableWakeupVoice, 0xD001))
}
return return
} }
} }

155
apps/services/modules/console/api_appconfig.go

@ -1,155 +0,0 @@
package console
import (
"strings"
"yunyan/comm"
"yunyan/pb"
"github.com/gin-gonic/gin"
)
// ============================ 应用环境配置 (appcfg) ============================
//
// 「应用环境配置」页(admin appconfig.vue)的后端:读写**选中部署应用业务库**里的 config 表
// (comm.TableAppConfig / pb.DBAppConfigItem)——即应用自有的 key/value 配置(音乐、OSS 等)。
//
// 结构与「全局环境配置」(global_config, 存 console 公共库、按区域分组)一致,但去掉区域维度:
// 应用 config 不分区域,且落在各应用自己的业务库里,故按部署 app_id 从 registry 动态取连接
// (与 modulecfg / analyze / 重置设备码同机制),不经 console 公共库。
//
// 无加密字段(与 global_config 一致,明文 key/value)。
// appCfgList 列出某部署业务库 config 表的全部配置项(按 group 分组由前端做)。
func (this *serverComp) appCfgList(c *gin.Context) {
var req struct {
AppId uint32 `json:"app_id"`
}
_ = c.ShouldBindJSON(&req)
if req.AppId == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 必填")
return
}
db, err := this.module.registry.getServiceDB(req.AppId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error())
return
}
if err := db.CreateTable(comm.TableAppConfig, &pb.DBAppConfigItem{}); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
items := make([]*pb.DBAppConfigItem, 0)
if err := db.Find(comm.TableAppConfig, &items, ""); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, gin.H{"items": items})
}
// appCfgAdd 在某部署业务库新增一条 config(id 由库自增回填)。
func (this *serverComp) appCfgAdd(c *gin.Context) {
var req struct {
AppId uint32 `json:"app_id"`
Group string `json:"group"`
Key string `json:"key"`
Value string `json:"value"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if req.AppId == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 必填")
return
}
if strings.TrimSpace(req.Key) == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "键(key) 必填")
return
}
db, err := this.module.registry.getServiceDB(req.AppId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error())
return
}
if err := db.CreateTable(comm.TableAppConfig, &pb.DBAppConfigItem{}); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
m := &pb.DBAppConfigItem{
Group: strings.TrimSpace(req.Group),
Key: strings.TrimSpace(req.Key),
Value: req.Value,
Description: strings.TrimSpace(req.Description),
}
if err := db.Insert(comm.TableAppConfig, m); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, m)
}
// appCfgUpdate 更新某部署业务库的一条 config(按 id)。
func (this *serverComp) appCfgUpdate(c *gin.Context) {
var req struct {
AppId uint32 `json:"app_id"`
Id uint64 `json:"id"`
Group string `json:"group"`
Key string `json:"key"`
Value string `json:"value"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if req.AppId == 0 || req.Id == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 与 id 必填")
return
}
db, err := this.module.registry.getServiceDB(req.AppId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error())
return
}
m := &pb.DBAppConfigItem{
Id: req.Id,
Group: strings.TrimSpace(req.Group),
Key: strings.TrimSpace(req.Key),
Value: req.Value,
Description: strings.TrimSpace(req.Description),
}
if err := db.Save(comm.TableAppConfig, m); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, m)
}
// appCfgDel 按 id 批量删除某部署业务库的 config。
func (this *serverComp) appCfgDel(c *gin.Context) {
var req struct {
AppId uint32 `json:"app_id"`
Ids []uint64 `json:"ids"`
}
_ = c.ShouldBindJSON(&req)
if req.AppId == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 必填")
return
}
if len(req.Ids) == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "ids 必填")
return
}
db, err := this.module.registry.getServiceDB(req.AppId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error())
return
}
if err := db.Delete(comm.TableAppConfig, "id IN ?", req.Ids); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, gin.H{"deleted": len(req.Ids)})
}

81
apps/services/modules/console/api_config.go

@ -13,6 +13,7 @@ import (
natssys "yunyan/sys/nats" natssys "yunyan/sys/nats"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
) )
// broadcastConfigChanged 向各业务服务广播一条「配置已变更」事件,通知其重载对应缓存。 // broadcastConfigChanged 向各业务服务广播一条「配置已变更」事件,通知其重载对应缓存。
@ -59,6 +60,16 @@ func ensureConfigTables() error {
if err := pg.CreateTable(comm.TableEchomeetTemplate, &pb.DBEchoMeetTemplate{}); err != nil { if err := pg.CreateTable(comm.TableEchomeetTemplate, &pb.DBEchoMeetTemplate{}); err != nil {
return err return err
} }
// 2026-09-14 会议模板按应用隔离:pb 生成的 struct 加不了字段(改 proto 要重生成 22 个文件),
// 用 ALTER 补一列,读写都靠 WHERE/UPDATE 显式带上它。gorm 扫进 pb struct 时会忽略多出来的列。
if err := pg.Exec("ALTER TABLE " + comm.TableEchomeetTemplate + " ADD COLUMN IF NOT EXISTS app_name varchar(64) NOT NULL DEFAULT ''").Error; err != nil {
return err
}
_ = pg.Exec("CREATE INDEX IF NOT EXISTS idx_echomeet_template_app ON " + comm.TableEchomeetTemplate + " (app_name, source, language)").Error
// 第三方服务类别(后台可增删改),内置 11 个由 seedSvcCategories 补种。
if err := pg.CreateTable(comm.TableSvcCategory, &SvcCategory{}); err != nil {
return err
}
// 服务商字段模板(全局 schema 预设,不按应用分离)——保持原表。 // 服务商字段模板(全局 schema 预设,不按应用分离)——保持原表。
if err := pg.CreateTable(comm.TableThirdSvcTemplate, &ThirdSvcTemplate{}); err != nil { if err := pg.CreateTable(comm.TableThirdSvcTemplate, &ThirdSvcTemplate{}); err != nil {
return err return err
@ -311,10 +322,17 @@ func migrateLegacyConfigToScoped() {
// getMeetTemplates 列出公共会议模板(不含 outline/template 大字段,减小传输)。 // getMeetTemplates 列出公共会议模板(不含 outline/template 大字段,减小传输)。
func (this *serverComp) getMeetTemplates(c *gin.Context) { func (this *serverComp) getMeetTemplates(c *gin.Context) {
var req struct {
AppName string `json:"app_name"`
}
_ = c.ShouldBindJSON(&req)
if !this.meetTplScopeOK(c, req.AppName) {
return
}
models := make([]*pb.DBEchoMeetTemplate, 0) models := make([]*pb.DBEchoMeetTemplate, 0)
err := postgres.Table(comm.TableEchomeetTemplate). err := postgres.Table(comm.TableEchomeetTemplate).
Select("id, tid, source, title, description, language, ttype, tags, icon"). Select("id, tid, source, title, description, language, ttype, tags, icon").
Where("source = ?", "public"). Where("source = ? AND app_name = ?", "public", req.AppName).
Order("id DESC"). Order("id DESC").
Find(&models).Error Find(&models).Error
if err != nil { if err != nil {
@ -345,7 +363,7 @@ func (this *serverComp) getMeetTemplate(c *gin.Context) {
// addMeetTemplate 新增公共模板(强制 source=public,id 自增回填)。 // addMeetTemplate 新增公共模板(强制 source=public,id 自增回填)。
func (this *serverComp) addMeetTemplate(c *gin.Context) { func (this *serverComp) addMeetTemplate(c *gin.Context) {
var m pb.DBEchoMeetTemplate var m pb.DBEchoMeetTemplate
if err := c.ShouldBindJSON(&m); err != nil { if err := c.ShouldBindBodyWith(&m, binding.JSON); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return return
} }
@ -353,12 +371,21 @@ func (this *serverComp) addMeetTemplate(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, "标题 必填") writeErr(c, pb.ErrorCode_ReqParameterError, "标题 必填")
return return
} }
appName := meetTplAppName(c)
if !this.meetTplScopeOK(c, appName) {
return
}
m.Id = 0 m.Id = 0
m.Source = "public" m.Source = "public"
if err := postgres.Insert(comm.TableEchomeetTemplate, &m); err != nil { if err := postgres.Insert(comm.TableEchomeetTemplate, &m); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
} }
// pb struct 没有 app_name 字段,插完再补一刀。
if err := postgres.Table(comm.TableEchomeetTemplate).Where("id=?", m.Id).Update("app_name", appName).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
broadcastConfigChanged(comm.ConfigKindTemplate, "add", 0) broadcastConfigChanged(comm.ConfigKindTemplate, "add", 0)
writeOK(c, &m) writeOK(c, &m)
} }
@ -366,7 +393,7 @@ func (this *serverComp) addMeetTemplate(c *gin.Context) {
// updateMeetTemplate 更新公共模板(按 id,强制 source=public)。 // updateMeetTemplate 更新公共模板(按 id,强制 source=public)。
func (this *serverComp) updateMeetTemplate(c *gin.Context) { func (this *serverComp) updateMeetTemplate(c *gin.Context) {
var m pb.DBEchoMeetTemplate var m pb.DBEchoMeetTemplate
if err := c.ShouldBindJSON(&m); err != nil { if err := c.ShouldBindBodyWith(&m, binding.JSON); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return return
} }
@ -374,6 +401,15 @@ func (this *serverComp) updateMeetTemplate(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
return return
} }
appName := meetTplAppName(c)
if !this.meetTplScopeOK(c, appName) {
return
}
// 只能改自己应用的:按 (id, app_name) 命中才算存在,否则拿别的应用的 id 也能改。
if n := meetTplCount("id=? AND app_name=?", m.Id, appName); n == 0 {
writeErr(c, pb.ErrorCode_DBError, "模板不存在或不属于该应用")
return
}
m.Source = "public" m.Source = "public"
if err := postgres.Save(comm.TableEchomeetTemplate, &m); err != nil { if err := postgres.Save(comm.TableEchomeetTemplate, &m); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
@ -393,7 +429,11 @@ func (this *serverComp) delMeetTemplate(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, "ids 必填") writeErr(c, pb.ErrorCode_ReqParameterError, "ids 必填")
return return
} }
if err := postgres.Delete(comm.TableEchomeetTemplate, "id IN ?", req.Ids); err != nil { appName := meetTplAppName(c)
if !this.meetTplScopeOK(c, appName) {
return
}
if err := postgres.Delete(comm.TableEchomeetTemplate, "id IN ? AND app_name=?", req.Ids, appName); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
} }
@ -471,8 +511,12 @@ func (this *serverComp) syncMeetTemplate(c *gin.Context) {
} }
vals[col] = meetTplColValue(src, col) vals[col] = meetTplColValue(src, col)
} }
appName := meetTplAppName(c)
if !this.meetTplScopeOK(c, appName) {
return
}
db := postgres.Table(comm.TableEchomeetTemplate). db := postgres.Table(comm.TableEchomeetTemplate).
Where("source = ? AND tid = ? AND id <> ?", "public", src.Tid, src.Id) Where("source = ? AND app_name = ? AND tid = ? AND id <> ?", "public", appName, src.Tid, src.Id)
if len(req.Languages) > 0 { if len(req.Languages) > 0 {
db = db.Where("language IN ?", req.Languages) db = db.Where("language IN ?", req.Languages)
} }
@ -484,3 +528,30 @@ func (this *serverComp) syncMeetTemplate(c *gin.Context) {
broadcastConfigChanged(comm.ConfigKindTemplate, "update", 0) broadcastConfigChanged(comm.ConfigKindTemplate, "update", 0)
writeOK(c, gin.H{"updated": res.RowsAffected}) writeOK(c, gin.H{"updated": res.RowsAffected})
} }
// ── 会议模板的应用作用域(2026-09-14 起按应用隔离,与第三方服务同一套规矩)──
// meetTplAppName 从请求体里取 app_name。pb.DBEchoMeetTemplate 没有这个字段,ShouldBindJSON
// 绑不进去,这里把 body 再解一次只取这一个键(gin 的 ShouldBindBodyWith 会缓存 body,可重复读)。
func meetTplAppName(c *gin.Context) string {
var req struct {
AppName string `json:"app_name"`
}
_ = c.ShouldBindBodyWith(&req, binding.JSON)
return strings.TrimSpace(req.AppName)
}
// meetTplScopeOK app_name 必填 + 账号权限。
func (this *serverComp) meetTplScopeOK(c *gin.Context, appName string) bool {
if appName == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "app_name 必填:会议模板按应用隔离,请先选择应用")
return false
}
return this.requireAppNameScope(c, appName)
}
func meetTplCount(query string, args ...interface{}) int64 {
var n int64
_ = postgres.Table(comm.TableEchomeetTemplate).Where(query, args...).Count(&n).Error
return n
}

8
apps/services/modules/console/api_device.go

@ -464,6 +464,14 @@ func (this *serverComp) previewAuthCodes(c *gin.Context, sys *appConn) {
if !ok { if !ok {
return return
} }
// 产品必选:设备的应用归属完全靠 device_mac.productid → product.appnames 这条链推出来
// (表上没有应用列),productid=0 的设备等于「不属于任何应用」,谁都绑不了也查不到。
// 不显式拦的话这里会落到下面的「产品不存在: record not found」,提示指向数据缺失、
// 实际是表单没选产品。库侧另有 CHECK 约束兜底,见 console registry.go。
if req.Productid == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "请先选择产品:设备必须挂在产品下,否则无法归属到应用")
return
}
product, err := dvProduct(sys, req.Productid) product, err := dvProduct(sys, req.Productid)
if err != nil { if err != nil {
writeErr(c, pb.ErrorCode_DBError, "产品不存在: "+err.Error()) writeErr(c, pb.ErrorCode_DBError, "产品不存在: "+err.Error())

171
apps/services/modules/console/api_svccategory.go

@ -0,0 +1,171 @@
package console
import (
"strconv"
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/lego/sys/postgres"
"yunyan/pb"
"github.com/gin-gonic/gin"
)
// ============================ 第三方服务类别 CRUD ============================
//
// 后台「服务与环境配置 → 第三方服务 → 类别管理」的后端。规则见 model_svccategory.go 头部注释:
// 内置类别只能改展示属性,自定义类别可增删改,删除前查引用。
// seedSvcCategories 幂等 seed 内置类别:不存在则插入;已存在只补 builtin/voice 两个语义标记,
// 展示属性(label/short/color/sort/enabled)一律保留运营改过的值。启动时跑一次。
func seedSvcCategories() {
inserted := 0
for _, c := range builtinSvcCategories() {
exist := &SvcCategory{}
if err := postgres.FindOne(comm.TableSvcCategory, exist, "id=?", c.Id); err == nil {
if exist.Builtin != true || exist.Voice != c.Voice {
exist.Builtin = true
exist.Voice = c.Voice
_ = postgres.Save(comm.TableSvcCategory, exist)
}
continue
}
if err := postgres.Insert(comm.TableSvcCategory, &c); err != nil {
log.Warnf("console: seed 服务类别 %d 失败: %v", c.Id, err)
continue
}
inserted++
}
if inserted > 0 {
log.Infof("console: 内置服务类别 seed 完成 inserted=%d", inserted)
}
}
// getSvcCategories 全部类别(含停用的,前端自己按 enabled 决定在哪些地方展示),按 sort,id 升序。
func (this *serverComp) getSvcCategories(c *gin.Context) {
items := make([]*SvcCategory, 0, 16)
if err := postgres.Table(comm.TableSvcCategory).Order("sort ASC, id ASC").Find(&items).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
for _, it := range items {
fillSvcCategoryRuntime(it)
}
writeOK(c, gin.H{"items": items})
}
// saveSvcCategory 新增或更新一个类别。
// - id=0 → 新增自定义类别,id 由服务端从 100 起分配;
// - id>0 → 更新:内置类别只接受展示属性,builtin/id 不可改;voice 内置的也不让改
// (音色预填是按类别语义定的,改了新建 TTS 服务时 languages 就不预填了)。
func (this *serverComp) saveSvcCategory(c *gin.Context) {
var m SvcCategory
if err := c.ShouldBindJSON(&m); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
m.Label = strings.TrimSpace(m.Label)
m.Short = strings.TrimSpace(m.Short)
if m.Label == "" || m.Short == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "名称与角标 必填")
return
}
if len([]rune(m.Short)) > 8 {
writeErr(c, pb.ErrorCode_ReqParameterError, "角标最多 8 个字符(卡片上放不下)")
return
}
if m.Color == "" {
m.Color = "#475569"
}
if m.Bg == "" {
m.Bg = "#f1f5f9"
}
if m.Id == 0 {
// 新增:分配 id。max(id, 99)+1 保证自定义从 100 起,与内置永不相撞。
var maxId int32
_ = postgres.Table(comm.TableSvcCategory).Select("COALESCE(MAX(id), 0)").Scan(&maxId).Error
if maxId < svcCategoryCustomIdStart-1 {
maxId = svcCategoryCustomIdStart - 1
}
m.Id = maxId + 1
m.Builtin = false
if err := postgres.Insert(comm.TableSvcCategory, &m); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
fillSvcCategoryRuntime(&m)
writeOK(c, &m)
return
}
old := &SvcCategory{}
if err := postgres.FindOne(comm.TableSvcCategory, old, "id=?", m.Id); err != nil {
writeErr(c, pb.ErrorCode_DBError, "类别不存在: "+strconv.Itoa(int(m.Id)))
return
}
// 语义标记只认库里的:内置的 builtin/voice 不可改,自定义的 voice 可改、builtin 恒 false。
m.Builtin = old.Builtin
if old.Builtin {
m.Voice = old.Voice
}
if err := postgres.Save(comm.TableSvcCategory, &m); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
fillSvcCategoryRuntime(&m)
writeOK(c, &m)
}
// delSvcCategory 删除自定义类别。内置类别拒绝;被服务或模板引用的也拒绝,并把引用列出来。
func (this *serverComp) delSvcCategory(c *gin.Context) {
var req struct {
Id int32 `json:"id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Id == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
return
}
old := &SvcCategory{}
if err := postgres.FindOne(comm.TableSvcCategory, old, "id=?", req.Id); err != nil {
writeErr(c, pb.ErrorCode_DBError, "类别不存在")
return
}
if old.Builtin {
writeErr(c, pb.ErrorCode_ReqParameterError,
"内置类别不能删除:运行时与内置模板按这个 id 引用(如 MT=3 用于 user_translate、实名=11 决定不下发)。不想用可以停用。")
return
}
// 引用检查:服务实例的 categories 是逗号分隔多值,逐条拆开比对,别用 LIKE('1' 会命中 '10'/'11')。
svcs := make([]*ThirdSvcConfig, 0)
_ = postgres.Find(comm.TableSvcConfig, &svcs, "")
refs := make([]string, 0, 4)
for _, s := range svcs {
if categoriesHas(s.Categories, req.Id) {
scope := s.AppName
if scope == "" {
scope = "<全局>"
}
refs = append(refs, "服务 "+s.Id+"("+scope+")")
}
}
tpls := make([]*ThirdSvcTemplate, 0)
_ = postgres.Find(comm.TableThirdSvcTemplate, &tpls, "category=?", req.Id)
for _, t := range tpls {
refs = append(refs, "模板 "+t.Id)
}
if len(refs) > 0 {
if len(refs) > 6 {
refs = append(refs[:6], "…等 "+strconv.Itoa(len(refs))+" 处")
}
writeErr(c, pb.ErrorCode_ReqParameterError, "该类别仍被引用,先把它们改到别的类别:"+strings.Join(refs, "、"))
return
}
if err := postgres.Delete(comm.TableSvcCategory, "id=?", req.Id); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, gin.H{"deleted": 1, "at": time.Now().Unix()})
}

113
apps/services/modules/console/api_svcconfig.go

@ -89,6 +89,9 @@ func (this *serverComp) getSvcConfigs(c *gin.Context) {
AppName string `json:"app_name"` AppName string `json:"app_name"`
} }
_ = c.ShouldBindJSON(&req) _ = c.ShouldBindJSON(&req)
if !this.svcScopeOK(c, req.AppName) {
return
}
items := make([]*ThirdSvcConfig, 0) items := make([]*ThirdSvcConfig, 0)
if err := postgres.Find(comm.TableSvcConfig, &items, "app_name=?", req.AppName); err != nil { if err := postgres.Find(comm.TableSvcConfig, &items, "app_name=?", req.AppName); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
@ -264,6 +267,9 @@ func (this *serverComp) addSvcConfig(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, "服务ID 和 名称 必填") writeErr(c, pb.ErrorCode_ReqParameterError, "服务ID 和 名称 必填")
return return
} }
if !this.svcScopeOK(c, m.AppName) {
return
}
// 作用域内(app_name,id)唯一检查 // 作用域内(app_name,id)唯一检查
exist := &ThirdSvcConfig{} exist := &ThirdSvcConfig{}
if err := postgres.FindOne(comm.TableSvcConfig, exist, "app_name=? AND id=?", m.AppName, m.Id); err == nil { if err := postgres.FindOne(comm.TableSvcConfig, exist, "app_name=? AND id=?", m.AppName, m.Id); err == nil {
@ -307,6 +313,9 @@ func (this *serverComp) updateSvcConfig(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, "服务ID 必填") writeErr(c, pb.ErrorCode_ReqParameterError, "服务ID 必填")
return return
} }
if !this.svcScopeOK(c, m.AppName) {
return
}
// 读旧记录(同作用域),取代理主键 + 已存储的加密字段密文 // 读旧记录(同作用域),取代理主键 + 已存储的加密字段密文
old := &ThirdSvcConfig{} old := &ThirdSvcConfig{}
if err := postgres.FindOne(comm.TableSvcConfig, old, "app_name=? AND id=?", m.AppName, m.Id); err != nil { if err := postgres.FindOne(comm.TableSvcConfig, old, "app_name=? AND id=?", m.AppName, m.Id); err != nil {
@ -351,6 +360,9 @@ func (this *serverComp) delSvcConfig(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
return return
} }
if !this.svcScopeOK(c, req.AppName) {
return
}
if err := postgres.Delete(comm.TableSvcConfig, "app_name=? AND id=?", req.AppName, req.Id); err != nil { if err := postgres.Delete(comm.TableSvcConfig, "app_name=? AND id=?", req.AppName, req.Id); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
@ -396,6 +408,9 @@ func (this *serverComp) getSvcRegionOverride(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, "svc_id 和 region 必填") writeErr(c, pb.ErrorCode_ReqParameterError, "svc_id 和 region 必填")
return return
} }
if !this.svcScopeOK(c, req.AppName) {
return
}
cfg := &ThirdSvcConfig{} cfg := &ThirdSvcConfig{}
if err := postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", req.AppName, req.SvcId); err != nil { if err := postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", req.AppName, req.SvcId); err != nil {
writeErr(c, pb.ErrorCode_DBError, "服务不存在: "+req.SvcId) writeErr(c, pb.ErrorCode_DBError, "服务不存在: "+req.SvcId)
@ -456,6 +471,9 @@ func (this *serverComp) saveSvcRegionOverride(c *gin.Context) {
if req.Overrides == nil { if req.Overrides == nil {
req.Overrides = make(map[string]string) req.Overrides = make(map[string]string)
} }
if !this.svcScopeOK(c, req.AppName) {
return
}
// 读服务字段定义(同作用域),确定哪些字段需要加密、哪些是合法的默认字段 key // 读服务字段定义(同作用域),确定哪些字段需要加密、哪些是合法的默认字段 key
cfg := &ThirdSvcConfig{} cfg := &ThirdSvcConfig{}
if err := postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", req.AppName, req.SvcId); err != nil { if err := postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", req.AppName, req.SvcId); err != nil {
@ -560,6 +578,9 @@ func (this *serverComp) syncSvcToRegions(c *gin.Context) {
writeOK(c, gin.H{"synced": 0}) writeOK(c, gin.H{"synced": 0})
return return
} }
if !this.svcScopeOK(c, req.AppName) {
return
}
// 确定同步源:默认字段值覆盖 + 区域专属字段 + 停用字段(同作用域) // 确定同步源:默认字段值覆盖 + 区域专属字段 + 停用字段(同作用域)
var srcOverrides map[string]string var srcOverrides map[string]string
var srcExtra []SvcField var srcExtra []SvcField
@ -608,3 +629,95 @@ func (this *serverComp) syncSvcToRegions(c *gin.Context) {
} }
writeOK(c, gin.H{"synced": synced}) writeOK(c, gin.H{"synced": synced})
} }
// svcScopeOK 第三方服务的作用域校验:app_name 必填 + 账号权限。
//
// 2026-09-14 起没有「全局默认」层了(见 migrate_scope.go),每个应用一套配置互不干涉。
// 空 app_name 一律拒绝,否则前端某处漏传就会在一个谁也看不见的作用域里建出服务。
// 权限沿用 requireAppNameScope:有应用绑定的账号只能碰自己名下的应用。
func (this *serverComp) svcScopeOK(c *gin.Context, appName string) bool {
if strings.TrimSpace(appName) == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "app_name 必填:第三方服务按应用隔离,请先选择应用")
return false
}
return this.requireAppNameScope(c, appName)
}
// copySvcConfig 把一个应用的服务(含区域分叉)复制到另一个应用。
//
// 各应用互不干涉之后,「两个应用用同一套凭据」这种最常见的情况得靠它,不然运营要逐个服务
// 抄一遍密文——而密文在后台是打码的,根本抄不出来。复制走库里的密文原样搬,不经过前端。
//
// ids 为空 = 复制源应用的全部服务;overwrite=false 时目标已有同 id 的跳过。
func (this *serverComp) copySvcConfig(c *gin.Context) {
var req struct {
SrcApp string `json:"src_app"`
DstApp string `json:"dst_app"`
Ids []string `json:"ids"`
Overwrite bool `json:"overwrite"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
req.SrcApp, req.DstApp = strings.TrimSpace(req.SrcApp), strings.TrimSpace(req.DstApp)
if req.SrcApp == "" || req.DstApp == "" || req.SrcApp == req.DstApp {
writeErr(c, pb.ErrorCode_ReqParameterError, "src_app 与 dst_app 必填且不能相同")
return
}
// 目标应用要有写权限;源应用只读,能看到列表即可(读权限在 getSvcConfigs 那边已按账号过滤)。
if !this.svcScopeOK(c, req.DstApp) {
return
}
srcs := make([]*ThirdSvcConfig, 0)
q, args := "app_name=?", []interface{}{req.SrcApp}
if len(req.Ids) > 0 {
q += " AND id IN ?"
args = append(args, req.Ids)
}
if err := postgres.Find(comm.TableSvcConfig, &srcs, q, args...); err != nil && err != postgres.ErrNoDocuments {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
copied, skipped := 0, 0
for _, src := range srcs {
exist := &ThirdSvcConfig{}
has := postgres.FindOne(comm.TableSvcConfig, exist, "app_name=? AND id=?", req.DstApp, src.Id) == nil
if has && !req.Overwrite {
skipped++
continue
}
cp := *src
cp.AppName = req.DstApp
cp.Updatetime = time.Now().UnixMilli()
if has {
cp.RowId = exist.RowId
cp.Createtime = exist.Createtime
if err := postgres.Save(comm.TableSvcConfig, &cp); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
// 覆盖模式下目标的旧分叉整体换成源的,免得两边的区域配置拼在一起说不清
_ = postgres.Delete(comm.TableSvcRegionOverride, "app_name=? AND svc_id=?", req.DstApp, src.Id)
} else {
cp.RowId = 0
cp.Createtime = cp.Updatetime
if err := postgres.Insert(comm.TableSvcConfig, &cp); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
}
ovrs := make([]*SvcRegionOverride, 0)
_ = postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=? AND svc_id=?", req.SrcApp, src.Id)
for _, o := range ovrs {
oc := *o
oc.Id = 0
oc.AppName = req.DstApp
oc.Updatetime = time.Now().Unix()
_ = postgres.Insert(comm.TableSvcRegionOverride, &oc)
}
copied++
}
broadcastConfigChanged(comm.ConfigKindThirdSvc, "copy", 0)
writeOK(c, gin.H{"copied": copied, "skipped": skipped})
}

53
apps/services/modules/console/api_svctemplate.go

@ -136,6 +136,7 @@ const (
svcCatLLMVision int32 = 9 // 多媒体大模型(能识别图像,用于带图的会议总结) svcCatLLMVision int32 = 9 // 多媒体大模型(能识别图像,用于带图的会议总结)
svcCatMCP int32 = 10 // MCP 服务(模型上下文协议;url/type/tools 三字段,按区域 fork,全应用共享) svcCatMCP int32 = 10 // MCP 服务(模型上下文协议;url/type/tools 三字段,按区域 fork,全应用共享)
svcCatIdVerify int32 = 11 // 身份证实名核验(服务端专用:凭据是云账号主 AK/SK,comm.IsServerOnlySvc 保证永不下发客户端) svcCatIdVerify int32 = 11 // 身份证实名核验(服务端专用:凭据是云账号主 AK/SK,comm.IsServerOnlySvc 保证永不下发客户端)
svcCatAppParams int32 = 12 // 应用参数(非凭据的应用级业务参数,如内购客服 QQ;2026-09-14 从业务库 config 表搬来,见 migrate_appparams.go)
) )
// sf 构造一个模板字段(sort 由调用顺序在 tpl 内自动填)。 // sf 构造一个模板字段(sort 由调用顺序在 tpl 内自动填)。
@ -148,6 +149,12 @@ func sfd(key, desc string, encrypted bool, defValue string) SvcField {
return SvcField{Key: key, Description: desc, Encrypted: encrypted, DefValue: defValue} return SvcField{Key: key, Description: desc, Encrypted: encrypted, DefValue: defValue}
} }
// sfe 构造一个带「下发键名」的模板字段:值会以 envKey 为名出现在 user_getappconfig 的 env 里。
// ⚠️ envKey 是已发布客户端读的键名(搜客户端 AppConfig.env / AppConfig.cred 的第三个参数),改一个字母老包就读不到。
func sfe(key, desc string, encrypted bool, envKey, defValue string) SvcField {
return SvcField{Key: key, Description: desc, Encrypted: encrypted, DefValue: defValue, EnvKey: envKey}
}
// tpl 构造一个内置模板(Builtin=true,字段 sort 按传入顺序自动编号)。 // tpl 构造一个内置模板(Builtin=true,字段 sort 按传入顺序自动编号)。
func tpl(id, provider, name string, category, order int32, fields ...SvcField) ThirdSvcTemplate { func tpl(id, provider, name string, category, order int32, fields ...SvcField) ThirdSvcTemplate {
for i := range fields { for i := range fields {
@ -233,10 +240,16 @@ func builtinSvcTemplates() []ThirdSvcTemplate {
// 阿里的端到端翻译走百炼 qwen3.5-livetranslate:凭据是 DashScope 的 API Key, // 阿里的端到端翻译走百炼 qwen3.5-livetranslate:凭据是 DashScope 的 API Key,
// **不是**云账号的 AccessKey ID/Secret。原模板照后者建,与线上实际用的那套对不上—— // **不是**云账号的 AccessKey ID/Secret。原模板照后者建,与线上实际用的那套对不上——
// 客户端读的是 ALIBABA_OPENSPEECH_APP_KEY(即这里的 app_key),AK/SK 一个都用不到。 // 客户端读的是 ALIBABA_OPENSPEECH_APP_KEY(即这里的 app_key),AK/SK 一个都用不到。
// 图片翻译(客户端 alibaba_image_translation_service)用的也是这把百炼 API Key,只是走 qwen-vl 视觉模型,
// 所以它的模型名/接口地址挂在这里而不是 llmv_qwen——那条是服务端会议总结用的 qwen-plus,
// base_url 是不带路径的根地址,两边对不上。老下发键名 ALIBABA_VL_MODEL / ALIBABA_VL_ENDPOINT 原先
// 哪张表都没有,客户端一直用代码里的默认值;现在后台能配了,留空仍走客户端默认。
tpl("ast_alibaba", "alibaba", "阿里云 AST", svcCatAST, 2, tpl("ast_alibaba", "alibaba", "阿里云 AST", svcCatAST, 2,
sf("app_key", "百炼 API Key", true), sf("app_key", "百炼 API Key", true),
sf("app_id", "旧版模型名/AppId(仅旧档使用)", false), sf("app_id", "旧版模型名/AppId(仅旧档使用)", false),
sf("ws_url", "WebSocket 地址(国内站/国际站不同)", false)), sf("ws_url", "WebSocket 地址(国内站/国际站不同)", false),
sfe("vl_model", "图片翻译视觉模型(留空=qwen-vl-plus)", false, "ALIBABA_VL_MODEL", ""),
sfe("vl_endpoint", "图片翻译接口地址(留空=DashScope 兼容模式 chat/completions)", false, "ALIBABA_VL_ENDPOINT", "")),
// LLM 文本大模型 // LLM 文本大模型
tpl("llm_openai", "openai", "OpenAI", svcCatLLM, 0, tpl("llm_openai", "openai", "OpenAI", svcCatLLM, 0,
@ -285,6 +298,24 @@ func builtinSvcTemplates() []ThirdSvcTemplate {
tpl("llmv_doubao", "doubao", "豆包视觉(火山引擎)", svcCatLLMVision, 3, tpl("llmv_doubao", "doubao", "豆包视觉(火山引擎)", svcCatLLMVision, 3,
sf("api_key", "API Key", true), sf("endpoint_id", "推理接入点 ID", false), sf("base_url", "接口地址", false)), sf("api_key", "API Key", true), sf("endpoint_id", "推理接入点 ID", false), sf("base_url", "接口地址", false)),
// 中国移动 灵犀(客户端「移动精灵」模块,原生 agent_service 那条链路)。2026-09-14 从业务库 config 表
// 的 AGENT_TYPE / MOBILE_ELF_* / YIDONG_PID 搬来(migrate_appparams.go),字段值仍按老键名下发。
// 归到「文本大模型」只是分组:它不出音频给我们(音频在原生 SDK 内部闭环),放 STS 会被自动
// 加上 languages/default_lang 两个对它没意义的字段、还被巡检当成「没配语言」。
// agent_type 是 AI 球的跳转开关:值为 yidong 时进移动精灵,其它一律进 EMAI;
// 停用本服务 = 不下发这些键 = 客户端按默认进 EMAI。
tpl("llm_mobile_elf", "chinamobile", "中国移动 灵犀(移动精灵)", svcCatLLM, 9,
sfe("agent_type", "AI 球跳转:yidong=移动精灵,其它=EMAI 助手", false, "AGENT_TYPE", ""),
sfe("agent_id", "灵犀 AgentId", false, "MOBILE_ELF_AGENT_ID", ""),
sfe("product_id", "灵犀产品 ID", false, "MOBILE_ELF_PRODUCT_ID", ""),
sfe("product_key", "灵犀产品密钥", true, "MOBILE_ELF_PRODUCT_KEY", ""),
sfe("pid", "移动产品 PID(设备检测匹配用)", false, "YIDONG_PID", "")),
// 应用参数:非凭据的应用级业务参数。后台「应用参数」标签页 2026-09-14 下线后,这类东西的唯一入口。
// 每个字段带下发键名,客户端零改动。要加新参数:这里加一个 sfe,patchSvcConfigFields 会把它补到各应用的实例上。
tpl("app_params", "app", "应用参数", svcCatAppParams, 0,
sfe("iap_customer_service_qq", "内购页页脚显示的客服 QQ(留空不显示)", false, "iapCustomerServiceQQ", "")),
// STS 端到端语音对话 // STS 端到端语音对话
tpl("sts_volcengine", "volcengine", "火山引擎 STS", svcCatSTS, 0, tpl("sts_volcengine", "volcengine", "火山引擎 STS", svcCatSTS, 0,
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true), sf("cluster", "集群标识", false)), sf("appid", "应用 AppID", false), sf("token", "访问 Token", true), sf("cluster", "集群标识", false)),
@ -294,6 +325,26 @@ func builtinSvcTemplates() []ThirdSvcTemplate {
sf("subscription_key", "订阅密钥", true), sf("region", "区域,如 eastus", false)), sf("subscription_key", "订阅密钥", true), sf("region", "区域,如 eastus", false)),
tpl("sts_google", "google", "Google STS", svcCatSTS, 3, tpl("sts_google", "google", "Google STS", svcCatSTS, 3,
sf("api_key", "API Key", true)), sf("api_key", "API Key", true)),
// 科大讯飞 AIUI(aiui.xfyun.cn):唤醒→识别→语义理解→内容服务→合成 一条链路的人机交互平台,
// 归 STS(端到端语音对话)而不是 STT——它吐的是「听懂之后的答复」,不是转写文本。
//
// 鉴权是**自成一套**的,别照别家的 key/secret 套:请求头 X-Appid / X-CurTime / X-Param(业务参数的 base64)
// / X-CheckSum = MD5(api_key + X-CurTime + X-Param)。所以只有 app_id 与 api_key 两个是必填凭据,
// 其余都是 X-Param 里的业务参数。
//
// ⚠️ auth_id 是**每个用户/设备一个**的标识(32 位小写字母+数字),AIUI 拿它存个性化数据与多轮上下文。
// 这里留空是推荐做法(由端侧按设备 id 生成);填死等于让全部用户共用一份上下文,A 说的话会进 B 的对话历史。
// 留这个字段只是给「先用固定 id 联调」这种场景一个入口。
//
// 音频格式(sample_rate/aue)刻意不做成字段:端侧音频出口固定 PCM16/16k/mono,配成别的只会两边对不上。
// 发音人在 AIUI 控制台的场景里配,不从这边下发。
tpl("sts_iflytek_aiui", "iflytek", "科大讯飞 AIUI", svcCatSTS, 4,
sf("app_id", "AIUI 应用 ID(请求头 X-Appid)", false),
sf("api_key", "AIUI API Key(算 X-CheckSum 用)", true),
sfd("ws_url", "接口地址", false, "wss://wsapi.xfyun.cn/v1/aiui"),
sfd("scene", "场景,对应 AIUI 控制台里建的那个场景", false, "main"),
sfd("interact_mode", "交互模式:oneshot=一次问答(按键说话适用),continuous=持续交互", false, "oneshot"),
sf("auth_id", "用户标识(32位小写字母+数字)。留空=端侧按设备生成,推荐留空", false)),
// 存储 对象存储 // 存储 对象存储
// domain 可选:填了之后「上传完成后存进库的那个访问地址」走该域名(OSS 绑定的自定义域名 / CDN), // domain 可选:填了之后「上传完成后存进库的那个访问地址」走该域名(OSS 绑定的自定义域名 / CDN),

44
apps/services/modules/console/api_svctemplate_test.go

@ -152,10 +152,16 @@ func TestCategoriesHas(t *testing.T) {
func TestLLMTemplatesCarryEndpoint(t *testing.T) { func TestLLMTemplatesCarryEndpoint(t *testing.T) {
// 地址字段的几种写法:base_url(OpenAI 兼容) / endpoint(Google 自定义端点) // 地址字段的几种写法:base_url(OpenAI 兼容) / endpoint(Google 自定义端点)
addrKeys := map[string]bool{"base_url": true, "endpoint": true} addrKeys := map[string]bool{"base_url": true, "endpoint": true}
// 不经 HTTP 的例外:中国移动灵犀走客户端原生 SDK(agent_service 插件),地址在 SDK 内部,
// 这条模板只承载 AgentId/产品密钥/AI 球跳转开关。
noHTTP := map[string]bool{"llm_mobile_elf": true}
for _, tpl := range builtinSvcTemplates() { for _, tpl := range builtinSvcTemplates() {
if tpl.Category != svcCatLLM && tpl.Category != svcCatLLMVision { if tpl.Category != svcCatLLM && tpl.Category != svcCatLLMVision {
continue continue
} }
if noHTTP[tpl.Id] {
continue
}
ok := false ok := false
for _, f := range tpl.Fields { for _, f := range tpl.Fields {
if addrKeys[f.Key] { if addrKeys[f.Key] {
@ -196,6 +202,44 @@ func TestQwenTemplateBaseURL(t *testing.T) {
} }
} }
// 科大讯飞 AIUI 的鉴权自成一套:X-CheckSum = MD5(api_key + X-CurTime + X-Param)。
// 两处一旦被「顺手统一」就会静默出问题:api_key 若被改成不加密,密钥会明文躺在库里并随
// thirdsvcs 原样下发;接口地址若被改成别家那种 https 形态,AIUI 的流式交互握不上手。
func TestIflytekAIUITemplate(t *testing.T) {
var tpl *ThirdSvcTemplate
for i, tp := range builtinSvcTemplates() {
if tp.Id == "sts_iflytek_aiui" {
tpl = &builtinSvcTemplates()[i]
break
}
}
if tpl == nil {
t.Fatal("内置模板里没有 sts_iflytek_aiui")
}
if tpl.Category != svcCatSTS {
t.Errorf("AIUI 应归 STS(端到端语音对话)=%d,实际 %d——它吐的是答复不是转写,归 STT 会被会议/翻译那些按类别选路的地方选错",
svcCatSTS, tpl.Category)
}
fields := map[string]SvcField{}
for _, f := range tpl.Fields {
fields[f.Key] = f
}
for _, k := range []string{"app_id", "api_key", "ws_url", "scene"} {
if _, ok := fields[k]; !ok {
t.Errorf("AIUI 模板缺字段 %s", k)
}
}
if !fields["api_key"].Encrypted {
t.Error("AIUI 的 api_key 必须加密存储:它是算 X-CheckSum 的密钥,明文等于谁拿到配置谁就能冒充本应用调用")
}
if fields["app_id"].Encrypted {
t.Error("AIUI 的 app_id 是请求头 X-Appid,不是密钥,不该加密——加密了端侧拿到的是密文")
}
if got := fields["ws_url"].DefValue; got != "wss://wsapi.xfyun.cn/v1/aiui" {
t.Errorf("AIUI 默认接口地址应为 wss://wsapi.xfyun.cn/v1/aiui,实际 %q", got)
}
}
// 废弃字段在**实例**上只删空的、留有值的。 // 废弃字段在**实例**上只删空的、留有值的。
// 背景:ast_alibaba 模板原先照云账号 AK/SK 建,而这条链路(百炼 qwen3.5-livetranslate) // 背景:ast_alibaba 模板原先照云账号 AK/SK 建,而这条链路(百炼 qwen3.5-livetranslate)
// 用的是 app_key。那两个空的加密字段会让服务被判「缺凭据」且保存被拒——运营改不动。 // 用的是 app_key。那两个空的加密字段会让服务被判「缺凭据」且保存被拒——运营改不动。

301
apps/services/modules/console/migrate_appparams.go

@ -0,0 +1,301 @@
package console
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/lego/sys/mysql"
"yunyan/lego/sys/postgres"
"yunyan/pb"
)
// ============================ 应用参数归一:业务库 config 表 → svc_config ============================
//
// 2026-09-14 后台「服务与环境配置」的「应用参数」标签页下线。它是业务库 config 表的裸 key/value 编辑器,
// 而 user_getappconfig 原先把这张表**整张**塞进 env 下发——COS 密钥、灵犀产品密钥、连「会员与算力」页
// 写进去的 COMPUTE_RATE_* 都会明文发到每个客户端手里。逐项核过两台机的 22 行之后的处置:
//
// - 客户端还在读的键搬进 svc_config,字段带 env_key、仍按老键名下发(客户端零改动即可):
// AGENT_TYPE / MOBILE_ELF_AGENT_ID / MOBILE_ELF_PRODUCT_ID / MOBILE_ELF_PRODUCT_KEY / YIDONG_PID
// → 服务 llm_mobile_elf(中国移动 灵犀 / 移动精灵)
// iapCustomerServiceQQ → 服务 app_params(应用参数)
// - 谁都不读的死键直接删(appCfgDeadKeys:音乐 / Spotify / 公码 / 导航 / 腾讯 COS / MeetServers);
// COS 那 5 个是腾讯云直传时代的密钥,上传 2026-09-04 起走 OSS 预签名,且已确认线上没有老包在读。
// - 服务端自用的(算力系数 / 运营参数 / 迁移标记)留在表里,由「会员与算力」页维护,**不再下发**。
// - 其余不认识的键原样保留并告警:它们已经没有任何后台入口、也不下发了,
// 要么加进 appParamsPlan 落点表,要么手工清。
//
// 服务实例照内置模板建(builtinSvcTemplates 里的 llm_mobile_elf / app_params),值从 config 表取。
// 幂等、每次启动跑:服务已存在只补空字段;config 表里的行只在**落库成功之后**才删。
//
// 必须排在 migrateGlobalScopeToApps 之后:直接写 app_name=<应用名> 的行。作用域键同那里一样是
// app_registry.app_name(不是部署名 name)。同一应用有多个部署(多套业务库)时按 id 升序,
// 第一个部署的值为准,后面的只清表。
// appParamFieldSpec config 表里的一个键在服务上的落点。ConfigKey 同时也是字段的下发键名(env_key)。
type appParamFieldSpec struct {
ConfigKey string
FieldKey string
}
// appParamSvcSpec 一个落点服务。模板 id 指向 builtinSvcTemplates,名称/类别/字段定义都从模板取。
type appParamSvcSpec struct {
TplId string
CreateIfEmpty bool // config 表里一个值都没有时是否也建:app_params 要建出来让运营有地方填;移动精灵没值就不建
Fields []appParamFieldSpec
}
var appParamsPlan = []appParamSvcSpec{
{
TplId: "llm_mobile_elf",
Fields: []appParamFieldSpec{
{ConfigKey: "AGENT_TYPE", FieldKey: "agent_type"},
{ConfigKey: "MOBILE_ELF_AGENT_ID", FieldKey: "agent_id"},
{ConfigKey: "MOBILE_ELF_PRODUCT_ID", FieldKey: "product_id"},
{ConfigKey: "MOBILE_ELF_PRODUCT_KEY", FieldKey: "product_key"},
{ConfigKey: "YIDONG_PID", FieldKey: "pid"},
},
},
{
TplId: "app_params", CreateIfEmpty: true,
Fields: []appParamFieldSpec{
{ConfigKey: "iapCustomerServiceQQ", FieldKey: "iap_customer_service_qq"},
},
},
}
// appCfgDeadKeys 确认服务端、客户端(含原生插件)、后台都没有读取方的键 → 原因。
// 判定时间 2026-09-14,方法是对 apps/services、apps/client/lib、local_plugins、apps/admin 全文 grep。
var appCfgDeadKeys = map[string]string{
"APP_MUSICSERVICE_TYPE": "音乐模块已删",
"APP_ANDROID_MUSICSERVICE_TYPE": "音乐模块已删",
"APP_IOS_MUSICSERVICE_TYPE": "音乐模块已删",
"SPOTIFY_CLIENT_ID": "音乐模块已删",
"SPOTIFY_REDIRECT_URL": "音乐模块已删",
"APP_AUTHCODE_OPEN": "厂家公码已下线",
"APP_AUTHCODE_JUMP": "厂家公码已下线",
"APP_NAVIGATION_MODE": "老 agent 模块已删,无人读取",
"COS_APP_ID": "上传已改 OSS 预签名,且无老包",
"COS_BUCKET_NAME": "上传已改 OSS 预签名,且无老包",
"COS_REGION": "上传已改 OSS 预签名,且无老包",
"COS_SECRET_ID": "上传已改 OSS 预签名,且无老包",
"COS_SECRET_KEY": "上传已改 OSS 预签名,且无老包",
"MeetServers": "会议选型走 echomeet_orch,无人读取",
}
// appCfgServerKeys 服务端自用、要留在 config 表里的键(由「会员与算力」页维护;迁移标记由 home 写)。
func appCfgServerKeys() map[string]bool {
return map[string]bool{
comm.ConfigKeyComputeRateTranslate: true,
comm.ConfigKeyComputeRateMeeting: true,
comm.ConfigKeyComputeRateAIChat: true,
comm.ConfigKeyComputeGate: true,
comm.ConfigKeyNewUserGiftVipDays: true,
comm.ConfigKeyNewUserGiftCompute: true,
comm.ConfigKeyVipWarnDays: true,
comm.ConfigKeyComputeWarn: true,
"COMPUTE_LEGACY_MIGRATED": true, // modules/user/model_user.go legacyComputeMigratedKey
}
}
func migrateAppParamsToSvc(encKey string, apps []*AppRegistry) {
sort.Slice(apps, func(i, j int) bool { return apps[i].Id < apps[j].Id })
seenScope := map[string]bool{}
for _, app := range apps {
if !app.Enabled || app.ServiceDsn == "" {
continue
}
scope := strings.TrimSpace(app.AppName)
if scope == "" {
log.Warnf("console.appparams: 部署 %s 未归属应用(app_name 为空),它的 config 表不处理", app.Name)
continue
}
db, err := openDriver(app.ServiceDsn)
if err != nil {
log.Warnf("console.appparams: 部署 %s 业务库连接失败,跳过: %v", app.Name, err)
continue
}
items := make([]*pb.DBAppConfigItem, 0)
if err := db.Find(comm.TableAppConfig, &items, ""); err != nil {
log.Warnf("console.appparams: 部署 %s 读 config 表失败,跳过: %v", app.Name, err)
continue
}
if len(items) == 0 {
continue
}
vals := map[string]string{}
for _, it := range items {
k, v := normalizeAppCfgRow(it)
if k == "" {
continue
}
if old, ok := vals[k]; !ok || (strings.TrimSpace(old) == "" && strings.TrimSpace(v) != "") {
vals[k] = v
}
}
migrated := map[string]bool{}
for _, spec := range appParamsPlan {
// 同一应用的第二个部署:服务行已由第一个部署建好,这里只把键标成已迁走去清表,不再覆盖值。
keys, err := upsertAppParamSvc(scope, spec, vals, encKey, seenScope[scope])
if err != nil {
log.Warnf("console.appparams: 应用 %s 服务 %s 迁移失败: %v", scope, spec.TplId, err)
continue
}
for k := range keys {
migrated[k] = true
}
}
seenScope[scope] = true
cleanAppParamRows(app.Name, db, items, migrated)
}
}
// upsertAppParamSvc 建或补一个落点服务,返回这次确认已落到 svc_config 里的 config 键。
// - 服务不存在:按模板建;config 里一个值都没有且 !CreateIfEmpty 则不建(返回空)。
// - 服务已存在:只给空着的字段填值、补缺失的字段与 env_key;运营填过的值不覆盖。
// - 只要字段在服务上存在(不管值是取自 config 还是运营填的),对应 config 键就算迁走——
// svc_config 从此是唯一出处,config 表那行留着只会误导。
//
// ⚠️ 返回的键只在落库成功之后才有效,调用方拿它去删 config 表——提前返回会让两张表都没值。
func upsertAppParamSvc(scope string, spec appParamSvcSpec, vals map[string]string, encKey string, scopeSeen bool) (map[string]bool, error) {
tpl := findBuiltinTemplate(spec.TplId)
if tpl == nil {
return nil, fmt.Errorf("内置模板 %s 不存在", spec.TplId)
}
hasValue := false
for _, f := range spec.Fields {
if strings.TrimSpace(vals[f.ConfigKey]) != "" {
hasValue = true
break
}
}
now := time.Now().Unix()
svc := &comm.ThirdSvcConfig{}
exists := true
if err := postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", scope, tpl.Id); err != nil {
if err != postgres.ErrNoDocuments {
return nil, err
}
exists = false
if !hasValue && !spec.CreateIfEmpty {
return nil, nil
}
fields := make([]comm.SvcField, len(tpl.Fields))
copy(fields, tpl.Fields)
svc = &comm.ThirdSvcConfig{
AppName: scope, Id: tpl.Id, Name: tpl.Name, Provider: tpl.Provider,
Categories: strconv.Itoa(int(tpl.Category)), Description: tpl.Description,
Enable: true, Fields: fields, Createtime: now,
}
}
idx := map[string]int{}
for i, f := range svc.Fields {
idx[f.Key] = i
}
done := map[string]bool{}
changed := !exists
// 同一应用的后续部署只清表不写值——除非服务是这次才建的(首个部署没值没建)。
takeValues := !scopeSeen || !exists
for _, fs := range spec.Fields {
i, ok := idx[fs.FieldKey]
if !ok {
// 模板里没有的字段(模板被运营改过):按明文补一个,别让值丢了。
svc.Fields = append(svc.Fields, comm.SvcField{Key: fs.FieldKey, Description: fs.ConfigKey, Sort: int32(len(svc.Fields))})
i = len(svc.Fields) - 1
idx[fs.FieldKey] = i
changed = true
}
f := &svc.Fields[i]
if f.EnvKey == "" {
f.EnvKey = fs.ConfigKey
changed = true
}
if v := vals[fs.ConfigKey]; takeValues && strings.TrimSpace(v) != "" && strings.TrimSpace(f.DefValue) == "" {
stored, err := storeVal(v, f.Encrypted, encKey)
if err != nil {
return nil, fmt.Errorf("字段 %s 加密失败: %w", fs.FieldKey, err)
}
f.DefValue = stored
changed = true
}
done[fs.ConfigKey] = true
}
// 服务停用 = 这些键一个都不下发。既然 config 表里有值在用,就得让它继续生效(同 migrate_envtosvc 对 stt_azure 的处理)。
if exists && !svc.Enable && hasValue && takeValues {
svc.Enable = true
changed = true
log.Infof("console.appparams: 应用 %s 的服务 %s 原为停用,因承接了 config 表在用的键已启用", scope, tpl.Id)
}
if changed {
svc.Updatetime = now
var err error
if exists {
err = postgres.Save(comm.TableSvcConfig, svc)
} else {
err = postgres.Insert(comm.TableSvcConfig, svc)
}
if err != nil {
return nil, err
}
log.Infof("console.appparams: 应用 %s 服务 %s %s(承接 config 键 %d 个)", scope, tpl.Id, map[bool]string{true: "已补字段", false: "已建"}[exists], len(done))
}
return done, nil
}
// findBuiltinTemplate 按 id 取内置模板定义(代码里的,不查库——模板表里的可能被运营改过)。
func findBuiltinTemplate(id string) *ThirdSvcTemplate {
for _, t := range builtinSvcTemplates() {
if t.Id == id {
tt := t
return &tt
}
}
return nil
}
// cleanAppParamRows 删已迁走的与确认死掉的行;服务端自用的留下;不认识的留下并告警。
func cleanAppParamRows(deployName string, db mysql.ISys, items []*pb.DBAppConfigItem, migrated map[string]bool) {
serverKeys := appCfgServerKeys()
dropIds := make([]uint64, 0)
dropKeys := make([]string, 0)
unknown := make([]string, 0)
for _, it := range items {
k, _ := normalizeAppCfgRow(it)
if k == "" {
continue
}
reason := ""
switch {
case migrated[k]:
reason = "已迁入 svc_config"
case appCfgDeadKeys[k] != "":
reason = appCfgDeadKeys[k]
case serverKeys[k]:
continue
default:
unknown = append(unknown, k)
continue
}
dropIds = append(dropIds, it.Id)
dropKeys = append(dropKeys, k+"("+reason+")")
}
if len(dropIds) > 0 {
if err := db.Delete(comm.TableAppConfig, "id IN ?", dropIds); err != nil {
log.Warnf("console.appparams: 部署 %s 清理 config 表失败: %v", deployName, err)
} else {
log.Infof("console.appparams: 部署 %s 的 config 表清掉 %d 行:%s", deployName, len(dropKeys), strings.Join(dropKeys, ", "))
}
}
if len(unknown) > 0 {
log.Warnf("console.appparams: 部署 %s 的 config 表还有 %d 个后台已无入口、也不再下发的键:%s——"+
"要么加进 migrate_appparams.go 的落点表,要么手工删", deployName, len(unknown), strings.Join(unknown, ", "))
}
}

9
apps/services/modules/console/migrate_envtosvc.go

@ -206,6 +206,15 @@ func normalizeAppCfgRow(it *pb.DBAppConfigItem) (key, value string) {
// 任何一步失败都只记日志不中断启动:迁移是幂等的,下次启动会重来; // 任何一步失败都只记日志不中断启动:迁移是幂等的,下次启动会重来;
// 而 console 起不来的后果(整个后台不可用)比晚一轮迁移严重得多。 // 而 console 起不来的后果(整个后台不可用)比晚一轮迁移严重得多。
func migrateEnvToSvcConfig(encKey string, apps []*AppRegistry) { func migrateEnvToSvcConfig(encKey string, apps []*AppRegistry) {
// ⓪ 作用域已按应用分配(migrate_scope.go 跑过)之后本迁移不再进场。
// 它的落点是全局层(app_name='');分配之后全局层为空,若照常跑会从 global_config 把服务
// **重新建回全局层**,接着 migrate_scope 再把它们分给首个应用时与已有行撞 (app_name,id) 唯一键。
// 新应用要这套凭据走后台「从其它应用复制」,不再从 global_config 长出来。
var scoped int64
_ = postgres.Table(comm.TableSvcConfig).Where("app_name <> ''").Count(&scoped).Error
if scoped > 0 {
return
}
// ① 把 global_config 读成 key → region → value。 // ① 把 global_config 读成 key → region → value。
type gcRow struct { type gcRow struct {
Region int32 `gorm:"column:region"` Region int32 `gorm:"column:region"`

263
apps/services/modules/console/migrate_scope.go

@ -0,0 +1,263 @@
package console
import (
"fmt"
"sort"
"strings"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/lego/sys/postgres"
)
// ============================ 作用域归一:全局层 → 每个应用一套 ============================
//
// 2026-09-14 之前第三方服务配置是「全局默认(app_name='') + 应用覆盖」:应用没配的服务回退到全局行。
// 两台机上的全部服务/区域分叉/会议编排/会议模板都在全局层,没有一个应用真正有自己的一套。
// 按要求改成**每个应用一套、互不干涉**:应用 A 删掉某个服务,不会悄悄回退到别人那份。
//
// 迁移把全局层的行分给每个已启用的应用:
// - **第一个应用(app_registry 里 id 最小的)直接把 app_name 从 '' 改成它**,行本身不动——
// 行 id/row_id 全部保留,svc_health、echomeet_record 等按 id 引用的数据不受影响;
// - 其余应用各拷一份(新 id),已存在的 (app, key) 不覆盖。
// 跑完之后全局层为空,运行时那些 `OR app_name=''` 的回退分支再也匹配不到任何行。
//
// 幂等:全局层空了就整体跳过。每次启动跑。
//
// ⚠️ 作用域键是 app_registry.**app_name**(= 应用中心的应用名,如 EAIMAR / deepGlass),不是
// app_registry.name(部署名,如 deepglass)。运行时用 ANALYZE_APP_NAME 找自己的行,
// 实测它等于 app_name;用错列会让 deepGlass 一个服务都读不到。
//
// 涉及的表(都以 app_name 为作用域键):
// svc_config / svc_region_override / echomeet_orch / echomeet_orch_setting / echomeet_template(source=public)
func migrateGlobalScopeToApps(apps []*AppRegistry) {
targets := scopeTargetApps(apps)
if len(targets) == 0 {
log.Infof("console.scope: 没有已启用的应用,全局层原样保留")
return
}
first, rest := targets[0], targets[1:]
n1, err := migrateScopeSvcConfig(first, rest)
if err != nil {
log.Warnf("console.scope: svc_config 归一失败: %v", err)
}
n2, err := migrateScopeRegionOverride(first, rest)
if err != nil {
log.Warnf("console.scope: svc_region_override 归一失败: %v", err)
}
n3, err := migrateScopeEchoOrch(first, rest)
if err != nil {
log.Warnf("console.scope: echomeet_orch 归一失败: %v", err)
}
n4, err := migrateScopeEchoOrchSetting(first, rest)
if err != nil {
log.Warnf("console.scope: echomeet_orch_setting 归一失败: %v", err)
}
n5, err := migrateScopeMeetTemplate(first, rest)
if err != nil {
log.Warnf("console.scope: echomeet_template 归一失败: %v", err)
}
if n1+n2+n3+n4+n5 > 0 {
log.Infof("console.scope: 全局层已分给各应用(首个应用 %s 原行改名,其余 %v 各拷一份):"+
"svc_config %d / 区域分叉 %d / 会议编排 %d / 编排开关 %d / 会议模板 %d",
first, rest, n1, n2, n3, n4, n5)
}
}
// scopeTargetApps 已启用应用的 app_name 去重,按 app_registry.id 升序(决定谁拿原行)。
func scopeTargetApps(apps []*AppRegistry) []string {
sort.Slice(apps, func(i, j int) bool { return apps[i].Id < apps[j].Id })
seen := map[string]bool{}
out := make([]string, 0, len(apps))
for _, a := range apps {
n := strings.TrimSpace(a.AppName)
if !a.Enabled || n == "" || seen[n] {
continue
}
seen[n] = true
out = append(out, n)
}
return out
}
// migrateScopeSvcConfig 服务实例。返回处理的全局行数。
func migrateScopeSvcConfig(first string, rest []string) (int, error) {
rows := make([]*ThirdSvcConfig, 0)
if err := postgres.Find(comm.TableSvcConfig, &rows, "app_name=?", ""); err != nil && err != postgres.ErrNoDocuments {
return 0, err
}
if len(rows) == 0 {
return 0, nil
}
for _, r := range rows {
for _, app := range rest {
exist := &ThirdSvcConfig{}
if err := postgres.FindOne(comm.TableSvcConfig, exist, "app_name=? AND id=?", app, r.Id); err == nil {
continue
}
cp := *r
cp.RowId = 0
cp.AppName = app
if err := postgres.Insert(comm.TableSvcConfig, &cp); err != nil {
return 0, fmt.Errorf("拷贝 %s → %s: %w", r.Id, app, err)
}
}
}
// 拷完再改名(逐行):首个应用已有同 id 的(上次跑到一半留下的)就删掉全局行,否则改名。
for _, r := range rows {
exist := &ThirdSvcConfig{}
if postgres.FindOne(comm.TableSvcConfig, exist, "app_name=? AND id=?", first, r.Id) == nil {
_ = postgres.Delete(comm.TableSvcConfig, "row_id=?", r.RowId)
continue
}
if err := postgres.Table(comm.TableSvcConfig).Where("row_id=?", r.RowId).Update("app_name", first).Error; err != nil {
return 0, err
}
}
return len(rows), nil
}
func migrateScopeRegionOverride(first string, rest []string) (int, error) {
rows := make([]*SvcRegionOverride, 0)
if err := postgres.Find(comm.TableSvcRegionOverride, &rows, "app_name=?", ""); err != nil && err != postgres.ErrNoDocuments {
return 0, err
}
if len(rows) == 0 {
return 0, nil
}
for _, r := range rows {
for _, app := range rest {
exist := &SvcRegionOverride{}
if err := postgres.FindOne(comm.TableSvcRegionOverride, exist,
"app_name=? AND svc_id=? AND region=?", app, r.SvcId, r.Region); err == nil {
continue
}
cp := *r
cp.Id = 0
cp.AppName = app
if err := postgres.Insert(comm.TableSvcRegionOverride, &cp); err != nil {
return 0, fmt.Errorf("拷贝 %s@%d → %s: %w", r.SvcId, r.Region, app, err)
}
}
}
for _, r := range rows {
exist := &SvcRegionOverride{}
if postgres.FindOne(comm.TableSvcRegionOverride, exist, "app_name=? AND svc_id=? AND region=?", first, r.SvcId, r.Region) == nil {
_ = postgres.Delete(comm.TableSvcRegionOverride, "id=?", r.Id)
continue
}
if err := postgres.Table(comm.TableSvcRegionOverride).Where("id=?", r.Id).Update("app_name", first).Error; err != nil {
return 0, err
}
}
return len(rows), nil
}
func migrateScopeEchoOrch(first string, rest []string) (int, error) {
rows := make([]*EchoOrchestration, 0)
if err := postgres.Find(comm.TableEchomeetOrch, &rows, "app_name=?", ""); err != nil && err != postgres.ErrNoDocuments {
return 0, err
}
if len(rows) == 0 {
return 0, nil
}
for _, r := range rows {
for _, app := range rest {
exist := &EchoOrchestration{}
if err := postgres.FindOne(comm.TableEchomeetOrch, exist,
"app_name=? AND region=? AND kind=? AND svc_id=?", app, r.Region, r.Kind, r.SvcId); err == nil {
continue
}
cp := *r
cp.RowId = 0
cp.AppName = app
if err := postgres.Insert(comm.TableEchomeetOrch, &cp); err != nil {
return 0, err
}
}
}
for _, r := range rows {
exist := &EchoOrchestration{}
if postgres.FindOne(comm.TableEchomeetOrch, exist, "app_name=? AND region=? AND kind=? AND svc_id=?", first, r.Region, r.Kind, r.SvcId) == nil {
_ = postgres.Delete(comm.TableEchomeetOrch, "row_id=?", r.RowId)
continue
}
if err := postgres.Table(comm.TableEchomeetOrch).Where("row_id=?", r.RowId).Update("app_name", first).Error; err != nil {
return 0, err
}
}
return len(rows), nil
}
func migrateScopeEchoOrchSetting(first string, rest []string) (int, error) {
rows := make([]*EchoOrchSetting, 0)
if err := postgres.Find(comm.TableEchomeetOrchSetting, &rows, "app_name=?", ""); err != nil && err != postgres.ErrNoDocuments {
return 0, err
}
if len(rows) == 0 {
return 0, nil
}
for _, r := range rows {
for _, app := range rest {
exist := &EchoOrchSetting{}
if err := postgres.FindOne(comm.TableEchomeetOrchSetting, exist,
"app_name=? AND region=?", app, r.Region); err == nil {
continue
}
cp := *r
cp.RowId = 0
cp.AppName = app
if err := postgres.Insert(comm.TableEchomeetOrchSetting, &cp); err != nil {
return 0, err
}
}
}
for _, r := range rows {
exist := &EchoOrchSetting{}
if postgres.FindOne(comm.TableEchomeetOrchSetting, exist, "app_name=? AND region=?", first, r.Region) == nil {
_ = postgres.Delete(comm.TableEchomeetOrchSetting, "row_id=?", r.RowId)
continue
}
if err := postgres.Table(comm.TableEchomeetOrchSetting).Where("row_id=?", r.RowId).Update("app_name", first).Error; err != nil {
return 0, err
}
}
return len(rows), nil
}
// migrateScopeMeetTemplate 公共会议模板。表结构是 pb 生成的 struct(没有 AppName 字段),
// app_name 列由 ensureConfigTables 用 ALTER 补上,这里走裸 SQL 按列名拷贝。
// 首个应用同样原行改名——客户端记录里存的模板 id 得保住。
func migrateScopeMeetTemplate(first string, rest []string) (int, error) {
pg := postgres.GetSys()
var n int64
if err := pg.Raw("SELECT COUNT(*) FROM " + comm.TableEchomeetTemplate +
" WHERE source='public' AND COALESCE(app_name,'')=''").Scan(&n).Error; err != nil {
return 0, err
}
if n == 0 {
return 0, nil
}
cols := "tid, source, title, description, language, ttype, tags, icon, outline, template, sort"
for _, app := range rest {
// 已经有这个应用的同 tid+language 就跳过(NOT EXISTS),其余整批拷。
if err := pg.Exec("INSERT INTO "+comm.TableEchomeetTemplate+" ("+cols+", app_name) "+
"SELECT "+cols+", ? FROM "+comm.TableEchomeetTemplate+" g "+
"WHERE g.source='public' AND COALESCE(g.app_name,'')='' AND NOT EXISTS ("+
"SELECT 1 FROM "+comm.TableEchomeetTemplate+" x WHERE x.source='public' AND x.app_name=? AND x.tid=g.tid AND x.language=g.language)",
app, app).Error; err != nil {
return 0, err
}
}
// 首个应用:已有同 tid+language 的删全局行,其余改名。
if err := pg.Exec("DELETE FROM "+comm.TableEchomeetTemplate+" g WHERE g.source='public' AND COALESCE(g.app_name,'')='' AND EXISTS ("+
"SELECT 1 FROM "+comm.TableEchomeetTemplate+" x WHERE x.source='public' AND x.app_name=? AND x.tid=g.tid AND x.language=g.language)", first).Error; err != nil {
return 0, err
}
if err := pg.Exec("UPDATE "+comm.TableEchomeetTemplate+" SET app_name=? WHERE source='public' AND COALESCE(app_name,'')=''", first).Error; err != nil {
return 0, err
}
return int(n), nil
}

23
apps/services/modules/console/migrate_scope_test.go

@ -0,0 +1,23 @@
package console
import "testing"
// 作用域归一里「谁拿原行、谁拿拷贝」由这个函数决定;用错列(name 而不是 app_name)会让
// 运行时按 ANALYZE_APP_NAME 一行都找不到,且不报错。
func TestScopeTargetApps(t *testing.T) {
apps := []*AppRegistry{
{Id: 17, Name: "deepglass", AppName: "deepGlass", Enabled: true},
{Id: 15, Name: "EAIMAR-TEST", AppName: "EAIMAR-TEST", Enabled: true},
{Id: 20, Name: "EAIMAR-HW", AppName: "EAIMAR-TEST", Enabled: true}, // 同一应用的第二个部署
{Id: 3, Name: "old", AppName: "Old", Enabled: false}, // 停用的不参与
{Id: 30, Name: "blank", AppName: " ", Enabled: true}, // 没填应用名的跳过
}
got := scopeTargetApps(apps)
if len(got) != 2 || got[0] != "EAIMAR-TEST" || got[1] != "deepGlass" {
t.Fatalf("应按 app_registry.id 升序取去重后的 app_name,实际 %v", got)
}
// 首个(拿原行的)必须是 id 最小的已启用应用,而不是部署名字典序最小的
if got[0] == "deepGlass" {
t.Fatal("首个应用应由 id 决定,不是名字")
}
}

10
apps/services/modules/console/model_registry.go

@ -98,7 +98,17 @@ func (this *modelComp) Init(service core.IService, module core.IModule, comp cor
this.module.Errorln(e) this.module.Errorln(e)
} else { } else {
migrateEnvToSvcConfig(this.module.options.EncryptKey, apps) migrateEnvToSvcConfig(this.module.options.EncryptKey, apps)
// 作用域归一:全局层分给各应用(2026-09-14,每个应用一套互不干涉)。
// 必须排在 migrateEnvToSvcConfig 之后:那一步把凭据写进的是全局层,这一步再把全局层分下去。
migrateGlobalScopeToApps(apps)
// 应用参数归一:业务库 config 表里客户端还在读的键 → svc_config(按应用),死键删除。
// 必须排在 migrateGlobalScopeToApps 之后:直接写 app_name=<应用> 的行,不再经过全局层。
migrateAppParamsToSvc(this.module.options.EncryptKey, apps)
// 只读巡检:作用域键(部署 .env / 注册表两列 / 库里实际存的)对不上时点名告警,见 scope_check.go。
checkScopeConsistency(apps)
} }
// 内置服务类别补种(幂等,只补语义标记不动展示属性)。
seedSvcCategories()
return return
} }

74
apps/services/modules/console/model_svccategory.go

@ -0,0 +1,74 @@
package console
import (
"yunyan/comm"
)
// ============================ 第三方服务类别 (svc_category) ============================
//
// 2026-09-14 之前类别是三处写死的常量:admin 前端的 SCAT 数组(标签/颜色)、console 的 svcCat*
// (模板与会议编排的类别映射)、comm 的 SvcCat*(真正带运行时语义的四个)。运营想加一个新类别
// 或改个名字都得改代码发版。现在类别落库、后台可增删改,但要分清两种东西:
//
// - **展示属性**(label/short/color/bg/sort/enabled):任何类别都可改,纯后台分组用。
// - **运行时语义**:只挂在 comm.SvcCat*(MT=3 用于 user_translate、AST=4 VIP 过期停发、
// MCP=10 特殊字段与工具发现、IdVerify=11 服务端专用永不下发)以及模板/会议编排引用的
// 内置 id(1/2/5/8/9)上。**这些 id 不能改也不能删**,改了运行时找不到;后台把它们标成 builtin。
// 自定义类别(id ≥ 100)只有分组作用,不会因为叫「实名」就获得「不下发」的保护——
// server_only 是从 comm 里读出来的只读属性,不落库、后台改不了,这是刻意的:
// 它守的是主账号密钥不出网关,不该由一个勾选框决定。
//
// 删除自定义类别前会查 svc_config.categories 与 third_svc_template.category 的引用,
// 有引用一律拒绝——否则那些服务在后台会掉进「未分类」,看着像丢了。
// SvcCategory 第三方服务类别(表 TableSvcCategory)。
type SvcCategory struct {
Id int32 `gorm:"column:id;primaryKey" json:"id"` // 内置 1~12;自定义从 100 起由服务端分配
Label string `gorm:"column:label;size:64" json:"label"` // 全称,如「STT 语音识别」
Short string `gorm:"column:short;size:16" json:"short"` // 卡片角标,如「STT」
Color string `gorm:"column:color;size:16" json:"color"` // 角标前景色
Bg string `gorm:"column:bg;size:16" json:"bg"` // 角标背景色
Sort int32 `gorm:"column:sort" json:"sort"` // 展示排序,越小越靠前
Builtin bool `gorm:"column:builtin" json:"builtin"` // seed 内置:id 不可改、不可删(运行时/模板按 id 引用)
Voice bool `gorm:"column:voice" json:"voice"` // 音频输出类:新建服务时按音色表预填 languages(原前端 VOICE_CATS)
Enabled bool `gorm:"column:enabled" json:"enabled"` // 停用后不在新增向导里出现,已归入的服务照常展示
// ServerOnly 只读:来自 comm.IsServerOnlySvc,读列表时现算,不落库、后台改不了。
ServerOnly bool `gorm:"-" json:"server_only"`
}
func (SvcCategory) TableName() string { return comm.TableSvcCategory }
// svcCategoryCustomIdStart 自定义类别 id 起点。1~99 留给内置(现在用到 12)。
const svcCategoryCustomIdStart int32 = 100
// builtinSvcCategories 与 admin 原 SCAT 数组逐项对应(顺序即默认 sort),末尾是后加的「应用参数」。
// ⚠️ 这里的 id 与 comm.SvcCat* / console svcCat* 是同一套数字,改任何一处都要三处同改。
func builtinSvcCategories() []SvcCategory {
list := []SvcCategory{
{Id: svcCatSTT, Label: "STT 语音识别", Short: "STT", Color: "#0369a1", Bg: "#dbeafe"},
{Id: svcCatASRFile, Label: "录音文件识别", Short: "录音识别", Color: "#0e7490", Bg: "#cffafe"},
{Id: svcCatTTS, Label: "TTS 语音合成", Short: "TTS", Color: "#7c3aed", Bg: "#ede9fe", Voice: true},
{Id: svcCatMT, Label: "MT 机器翻译", Short: "MT", Color: "#059669", Bg: "#dcfce7"},
{Id: svcCatAST, Label: "AST 端到端翻译", Short: "AST", Color: "#c2410c", Bg: "#fff7ed", Voice: true},
{Id: svcCatLLM, Label: "LLM 文本大模型", Short: "文本LLM", Color: "#b45309", Bg: "#fef9c3"},
{Id: svcCatLLMVision, Label: "多媒体大模型", Short: "多媒体LLM", Color: "#9333ea", Bg: "#f3e8ff"},
{Id: svcCatSTS, Label: "STS 端到端对话", Short: "STS", Color: "#be185d", Bg: "#fce7f3", Voice: true},
{Id: svcCatStorage, Label: "存储 OSS/COS", Short: "存储", Color: "#475569", Bg: "#f1f5f9"},
{Id: svcCatMCP, Label: "MCP 服务", Short: "MCP", Color: "#0d9488", Bg: "#ccfbf1"},
{Id: svcCatIdVerify, Label: "身份证校验", Short: "实名", Color: "#9f1239", Bg: "#ffe4e6"},
// 应用参数(2026-09-14):非凭据的应用级业务参数,原「应用参数」标签页(业务库 config 表)下线后的唯一入口。
{Id: svcCatAppParams, Label: "应用参数", Short: "参数", Color: "#334155", Bg: "#e2e8f0"},
}
for i := range list {
list[i].Builtin = true
list[i].Enabled = true
list[i].Sort = int32(i)
}
return list
}
// fillSvcCategoryRuntime 补上只读的运行时属性。
func fillSvcCategoryRuntime(c *SvcCategory) {
c.ServerOnly = comm.IsServerOnlySvcCat(c.Id)
}

23
apps/services/modules/console/registry.go

@ -206,6 +206,13 @@ func ensureDeviceTables() error {
if res := adb.Exec("DROP TABLE IF EXISTS " + comm.TableBrand); res.Error != nil { if res := adb.Exec("DROP TABLE IF EXISTS " + comm.TableBrand); res.Error != nil {
return res.Error return res.Error
} }
// 唤醒语音(wakeupvoice)2026-09-14 下线:这张表没有任何应用维度(两个 app 看到同一份),
// 后台从来没有管理它的页面,客户端 api.dart 里那个 getwakeupvoices 也零调用方——
// 纯粹是一条没人用、又会跨应用串数据的链路。服务端接口、model、建表全部已删,这里收尾删表。
// ⚠️ 不可逆。业务库(mysql)里同名的那张由各应用自己清理,不在这里动。
if res := adb.Exec("DROP TABLE IF EXISTS wakeupvoice"); res.Error != nil {
return res.Error
}
if err := adb.CreateTable(comm.TableChannel, &pb.DBChannel{}); err != nil { if err := adb.CreateTable(comm.TableChannel, &pb.DBChannel{}); err != nil {
return err return err
} }
@ -319,6 +326,22 @@ func ensureDeviceTables() error {
return res.Error return res.Error
} }
} }
// 设备必须挂在产品下:应用归属完全靠 device_mac.productid → product.appnames 推出来
// (这张表是全平台一张、没有应用列),productid=0 的行等于「不属于任何应用」——
// 绑定查不到、解绑也解不掉,只会变成查不清来路的脏数据。后台生成/导入入口已显式拦截,
// 这里再加一道库级 CHECK 兜底,堵住直接写库和将来新增的写入路径。
//
// ⚠️ best-effort:表里若已有 productid=0 的存量行,ADD CONSTRAINT 会失败——那要在后台
// 把这些设备补上产品(或删掉)后才能生效,但不能因此阻断 console 启动,所以只告警。
if res := adb.Exec(`DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'device_mac_productid_positive') THEN
ALTER TABLE ` + comm.TableDeviceMac + ` ADD CONSTRAINT device_mac_productid_positive CHECK (productid > 0);
END IF;
END $$;`); res.Error != nil {
log.Warnf("console: device_mac 的 productid>0 约束未能建立(多半是表里还有 productid=0 的存量行,"+
"补上产品或清掉后重启即可生效): %v", res.Error)
}
var products []*pb.DBProduct var products []*pb.DBProduct
if err := adb.Find(comm.TableProduct, &products, ""); err != nil && err != postgres.ErrNoDocuments { if err := adb.Find(comm.TableProduct, &products, ""); err != nil && err != postgres.ErrNoDocuments {
return err return err

97
apps/services/modules/console/scope_check.go

@ -0,0 +1,97 @@
package console
import (
"sort"
"strings"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/lego/sys/postgres"
)
// ============================ 作用域键一致性巡检(只读,只打日志) ============================
//
// 按应用隔离的配置(svc_config / 会议模板 …)用 app_name 当作用域键,而这个键有三个来源,
// 三者必须是同一个字符串,错一个就会「后台改半天不生效」或「后台整页空白」,且**不报任何错**:
//
// ① 业务部署 .env 的 ANALYZE_APP_NAME —— 运行时 comm.AppName(),决定客户端读哪一套;
// ② 应用注册表 app_registry.app_name —— 迁移/复制/后台下拉用它;
// ③ 应用注册表 app_registry.name —— 统计落库认它(console.stat 的 getAppByName 查的是 name)。
//
// 线上真实踩过:测试机 app_registry 是 (name=EAIMAR-TEST, app_name=EAIMAR),而部署的
// ANALYZE_APP_NAME=EAIMAR-TEST。于是配置全落在 'EAIMAR-TEST' 作用域(客户端读得到),
// 后台下拉给出的却是 'EAIMAR'(app_name||name)→ 选它看到的是空页面,在那里新建的服务
// 客户端一个都读不到。两边都不报错,只有对着库查才看得出来。
//
// 巡检不改数据(作用域键是运维决定的,自动改名可能把线上配置搬到没人读的地方),
// 只把对不上的地方点名打出来。
func checkScopeConsistency(apps []*AppRegistry) {
known := map[string]bool{} // 注册表认得的作用域键(app_name 与 name 都算)
declared := map[string]bool{} // 后台下拉会给出的键(前端取 app_name||name,与它一致)
for _, a := range apps {
if !a.Enabled {
continue
}
n, an := strings.TrimSpace(a.Name), strings.TrimSpace(a.AppName)
if n != "" {
known[n] = true
}
if an != "" {
known[an] = true
}
if an != "" && n != "" && an != n {
log.Warnf("console.scope: 应用注册表 id=%d 的 name(%q) 与 app_name(%q) 不同值——"+
"配置作用域、后台下拉、统计落库三者会各认一个,务必改成同值(见 comm/appscope.go)", a.Id, n, an)
}
if d := an; d != "" {
declared[d] = true
} else if n != "" {
declared[n] = true
}
}
if len(known) == 0 {
return
}
for _, table := range []string{comm.TableSvcConfig, comm.TableEchomeetTemplate} {
var scopes []string
if err := postgres.Table(table).Group("app_name").Pluck("app_name", &scopes).Error; err != nil {
log.Warnf("console.scope: 巡检 %s 的作用域键失败(跳过): %v", table, err)
continue
}
orphan := make([]string, 0)
for _, s := range scopes {
s = strings.TrimSpace(s)
if s == "" || known[s] {
continue
}
orphan = append(orphan, s)
}
if len(orphan) > 0 {
sort.Strings(orphan)
log.Warnf("console.scope: %s 里有配置挂在注册表不认识的作用域 %v ——"+
"若某个业务部署的 ANALYZE_APP_NAME 正是它,那套配置正在生效却在后台**看不到也改不了**;"+
"对齐办法是把 app_registry 的 name/app_name 都改成这个值(不要改业务部署的 .env,"+
"那会让客户端立刻读到另一套配置)", table, orphan)
}
// 反向:后台下拉会给出、但库里一行配置都没有的作用域——选中它就是空白页。
empty := make([]string, 0)
for d := range declared {
found := false
for _, s := range scopes {
if strings.TrimSpace(s) == d {
found = true
break
}
}
if !found {
empty = append(empty, d)
}
}
if len(empty) > 0 && len(orphan) > 0 {
sort.Strings(empty)
log.Warnf("console.scope: %s 里作用域 %v 一行都没有,而后台下拉正会给出它们——"+
"配合上面那条孤儿作用域看,多半是同一个应用的两个名字", table, empty)
}
}
}

21
apps/services/modules/console/server.go

@ -119,11 +119,8 @@ func (this *serverComp) routes(eng *gin.Engine) {
apps.POST("/modulecfg/save", this.moduleCfgSave) apps.POST("/modulecfg/save", this.moduleCfgSave)
apps.POST("/modulecfg/del", this.moduleCfgDel) apps.POST("/modulecfg/del", this.moduleCfgDel)
apps.POST("/modulecfg/reset", this.moduleCfgReset) // 还原为业务服务配置文件(yaml)的初始值 apps.POST("/modulecfg/reset", this.moduleCfgReset) // 还原为业务服务配置文件(yaml)的初始值
// 应用环境配置(应用自有 key/value 配置,结构同全局环境配置但无区域):读写应用业务库(config)。 // 业务库 config 表的裸编辑接口(appcfg/*) 2026-09-14 已删:该表只剩服务端自用的算力/运营参数
apps.POST("/appcfg/list", this.appCfgList) // (走 compute/* 接口),客户端要读的参数都进了 svc_config(见 migrate_appparams.go)。
apps.POST("/appcfg/add", this.appCfgAdd)
apps.POST("/appcfg/update", this.appCfgUpdate)
apps.POST("/appcfg/del", this.appCfgDel)
// 应用商品配置(内购商品,按 app_name 隔离、存 console 公共库;与部署环境无关) // 应用商品配置(内购商品,按 app_name 隔离、存 console 公共库;与部署环境无关)
apps.POST("/appgoods/list", this.appGoodsList) apps.POST("/appgoods/list", this.appGoodsList)
@ -370,6 +367,20 @@ func (this *serverComp) handleWeb(c *gin.Context) {
this.getSvcAlerts(c) this.getSvcAlerts(c)
return return
// 第三方服务商模板(svctemplate):数据驱动的字段 schema 预设,新增服务时选模板自动带出字段。 // 第三方服务商模板(svctemplate):数据驱动的字段 schema 预设,新增服务时选模板自动带出字段。
// 第三方服务类别(svc_category):后台可增删改;内置 11 个 id 不可改不可删,见 model_svccategory.go。
case "api_getsvccategories":
this.getSvcCategories(c)
return
case "api_savesvccategory":
this.saveSvcCategory(c)
return
case "api_delsvccategory":
this.delSvcCategory(c)
return
// 跨应用复制服务(含区域分叉):各应用互不干涉之后,「两个应用用同一套凭据」靠它,密文原样搬不经前端。
case "api_copysvcconfig":
this.copySvcConfig(c)
return
case "api_getsvctemplates": case "api_getsvctemplates":
this.getSvcTemplates(c) this.getSvcTemplates(c)
return return

15
apps/services/modules/echomeet/model.go

@ -98,13 +98,17 @@ func (this *modelComp) updateuser(user *pb.DBUser) (err error) {
} }
// d读取模版 // d读取模版
// 2026-09-14 起公共模板按应用隔离(后台「会议模板」页按应用管),作用域键 = 本部署的 comm.AppName()。
// 表里的 app_name 列由 console 用 ALTER 补上,pb struct 没这个字段——扫描时 gorm 忽略多出来的列,
// 只在 WHERE 里用它就行。
func (this *modelComp) getcommtemplates(language string) (models []*pb.DBEchoMeetTemplate, err error) { func (this *modelComp) getcommtemplates(language string) (models []*pb.DBEchoMeetTemplate, err error) {
models = make([]*pb.DBEchoMeetTemplate, 0) models = make([]*pb.DBEchoMeetTemplate, 0)
app := comm.AppName()
if language == "" { if language == "" {
err = postgres.Find(comm.TableEchomeetTemplate, &models, "source = ?", "public") err = postgres.Find(comm.TableEchomeetTemplate, &models, "source = ? AND app_name = ?", "public", app)
return return
} }
if err = postgres.Find(comm.TableEchomeetTemplate, &models, "source = ? AND language = ?", "public", language); err != nil { if err = postgres.Find(comm.TableEchomeetTemplate, &models, "source = ? AND app_name = ? AND language = ?", "public", app, language); err != nil {
return return
} }
if len(models) > 0 { if len(models) > 0 {
@ -114,7 +118,7 @@ func (this *modelComp) getcommtemplates(language string) (models []*pb.DBEchoMee
// 按 tid 去重保留 id 最小一条,避免同一模板出现多条。 // 按 tid 去重保留 id 最小一条,避免同一模板出现多条。
if base := baseLang(language); base != "" && base != language { if base := baseLang(language); base != "" && base != language {
var variants []*pb.DBEchoMeetTemplate var variants []*pb.DBEchoMeetTemplate
if err = postgres.Find(comm.TableEchomeetTemplate, &variants, "source = ? AND (language = ? OR language LIKE ?)", "public", base, base+"-%"); err != nil { if err = postgres.Find(comm.TableEchomeetTemplate, &variants, "source = ? AND app_name = ? AND (language = ? OR language LIKE ?)", "public", app, base, base+"-%"); err != nil {
return return
} }
models = pickBaseLangVariants(variants, base) models = pickBaseLangVariants(variants, base)
@ -186,7 +190,8 @@ func (this *modelComp) resolvetemplate(tid string, templateid uint64, language s
// 避免区域 locale 没有专属模板时解析失败;FindOne→gorm First 默认按主键 id 升序,取最小一条保证结果稳定。 // 避免区域 locale 没有专属模板时解析失败;FindOne→gorm First 默认按主键 id 升序,取最小一条保证结果稳定。
func (this *modelComp) gettemplatefortid(tid string, language string) (template *pb.DBEchoMeetTemplate, err error) { func (this *modelComp) gettemplatefortid(tid string, language string) (template *pb.DBEchoMeetTemplate, err error) {
template = &pb.DBEchoMeetTemplate{} template = &pb.DBEchoMeetTemplate{}
if err = postgres.FindOne(comm.TableEchomeetTemplate, template, "tid=? AND language=?", tid, language); err == nil { app := comm.AppName()
if err = postgres.FindOne(comm.TableEchomeetTemplate, template, "app_name=? AND tid=? AND language=?", app, tid, language); err == nil {
return return
} }
if err = mysql.FindOne(comm.TableEchomeetTemplate, template, "tid=? AND language=?", tid, language); err == nil { if err = mysql.FindOne(comm.TableEchomeetTemplate, template, "tid=? AND language=?", tid, language); err == nil {
@ -194,7 +199,7 @@ func (this *modelComp) gettemplatefortid(tid string, language string) (template
} }
// 回退:同基础语言(裸 base 或其任一区域变体,如 es / es-ES 都能兜底 es-MX) // 回退:同基础语言(裸 base 或其任一区域变体,如 es / es-ES 都能兜底 es-MX)
if base := baseLang(language); base != "" && base != language { if base := baseLang(language); base != "" && base != language {
if err = postgres.FindOne(comm.TableEchomeetTemplate, template, "tid=? AND (language=? OR language LIKE ?)", tid, base, base+"-%"); err == nil { if err = postgres.FindOne(comm.TableEchomeetTemplate, template, "app_name=? AND tid=? AND (language=? OR language LIKE ?)", app, tid, base, base+"-%"); err == nil {
return return
} }
err = mysql.FindOne(comm.TableEchomeetTemplate, template, "tid=? AND (language=? OR language LIKE ?)", tid, base, base+"-%") err = mysql.FindOne(comm.TableEchomeetTemplate, template, "tid=? AND (language=? OR language LIKE ?)", tid, base, base+"-%")

7
apps/services/modules/user/api_getappconfig.go

@ -22,7 +22,6 @@ func (this *apiComp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAppC
ipdata *ipinfo.IPData ipdata *ipinfo.IPData
region pb.Region region pb.Region
err error err error
config []*pb.DBAppConfigItem
agents []*pb.DBAgent agents []*pb.DBAgent
mcpcfgs []*mcpEntry mcpcfgs []*mcpEntry
env map[string]string env map[string]string
@ -41,9 +40,9 @@ func (this *apiComp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAppC
log.Field{Key: "region", Value: region}, log.Field{Key: "region", Value: region},
log.Field{Key: "err", Value: err}, log.Field{Key: "err", Value: err},
) )
config, agents, mcpcfgs = this.module.configmodel.getdb() agents, mcpcfgs = this.module.configmodel.getdb()
// env = 应用自有配置打底 + 第三方服务配置(svc_config)按 env_key 覆盖,见 appEnvForUser。 // env 只来自第三方服务配置(svc_config)字段上的 env_key,见 appEnvForUser。
env = appEnvForUser(session.GetUserId(), config, region) env = appEnvForUser(session.GetUserId(), region)
mcps = make(map[string]*pb.McpServer) mcps = make(map[string]*pb.McpServer)
// MCP 服务:按客户端区域解析(url 在该区域为空则不下发)。 // MCP 服务:按客户端区域解析(url 在该区域为空则不下发)。

33
apps/services/modules/user/api_getwakeupvoices.go

@ -1,33 +0,0 @@
package user
import (
"yunyan/comm"
"yunyan/pb"
)
// @Summary 获取唤醒词列表
// @Description 获取唤醒词列表
// @Tags User
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param user body pb.UserGetWakeupVoicesReq true "用户反馈"
// @Success 200 {object} comm.HttpResult{data=pb.UserGetWakeupVoicesResp} "成功返回"
// @Router /api/home/user_getwakeupvoices [post]
func (this *apiComp) GetWakeupVoices(session comm.IUserSession, req *pb.UserGetWakeupVoicesReq) (resp *pb.UserGetWakeupVoicesResp, errdata *pb.ErrorData) {
var (
voices []*pb.DBWakeupVoice
err error
)
if voices, err = this.module.configmodel.getWakeupVoices(); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
return
}
resp = &pb.UserGetWakeupVoicesResp{
Voices: voices,
}
return
}

7
apps/services/modules/user/api_v2_getappconfig.go

@ -25,7 +25,6 @@ func (this *apiV2Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp
ipdata *ipinfo.IPData ipdata *ipinfo.IPData
region pb.Region region pb.Region
err error err error
config []*pb.DBAppConfigItem
agents []*pb.DBAgent agents []*pb.DBAgent
mcpcfgs []*mcpEntry mcpcfgs []*mcpEntry
env map[string]string env map[string]string
@ -44,9 +43,9 @@ func (this *apiV2Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp
log.Field{Key: "region", Value: region}, log.Field{Key: "region", Value: region},
log.Field{Key: "err", Value: err}, log.Field{Key: "err", Value: err},
) )
config, agents, mcpcfgs = this.module.configmodel.getdb() agents, mcpcfgs = this.module.configmodel.getdb()
// env = 应用自有配置打底 + 第三方服务配置(svc_config)按 env_key 覆盖,见 appEnvForUser。 // env 只来自第三方服务配置(svc_config)字段上的 env_key,见 appEnvForUser。
env = appEnvForUser(session.GetUserId(), config, region) env = appEnvForUser(session.GetUserId(), region)
mcps = make(map[string]*pb.McpServer) mcps = make(map[string]*pb.McpServer)
// MCP 服务:按客户端区域解析(url 在该区域为空则不下发)。 // MCP 服务:按客户端区域解析(url 在该区域为空则不下发)。

13
apps/services/modules/user/api_v3_getappconfig.go

@ -48,16 +48,9 @@ func (this *apiV3Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp
} }
thirdsvcs = filterSvcsByVip(session.GetUserId(), thirdsvcs) thirdsvcs = filterSvcsByVip(session.GetUserId(), thirdsvcs)
// 环境变量:应用自有配置(config 表) 打底,第三方服务配置的 env_key 覆盖(口径同 v1/v2)。 // 环境变量:只来自第三方服务配置字段上的 env_key(口径同 v1/v2 的 appEnvForUser;
// 注意 getdb 的第二个返回值是**旧 agent 表**,v3 已不下发它(见下方 agentItemsV3)。 // 这里不再调它是为了复用上面同一份 VIP 过滤结果)。业务库 config 表 2026-09-14 起不再下发。
config, _, _ := this.module.configmodel.getdb() env := svcEnvOverlay(thirdsvcs, envById)
env := make(map[string]string, len(config)+16)
for _, v := range config {
env[v.Key] = v.Value
}
for k, v := range svcEnvOverlay(thirdsvcs, envById) {
env[k] = v
}
// 智能体:与 user_getagents_v3 同一份数据与结构(含 type、服务编排、MCP、变量、已补全音色), // 智能体:与 user_getagents_v3 同一份数据与结构(含 type、服务编排、MCP、变量、已补全音色),
// 客户端只调 appconfig 也能直接把 agent 跑起来。 // 客户端只调 appconfig 也能直接把 agent 跑起来。

22
apps/services/modules/user/model_cache.go

@ -19,6 +19,7 @@ import (
"yunyan/lego/core/cbase" "yunyan/lego/core/cbase"
"yunyan/lego/sys/cron" "yunyan/lego/sys/cron"
"yunyan/lego/sys/log" "yunyan/lego/sys/log"
"yunyan/lego/sys/postgres"
"yunyan/pb" "yunyan/pb"
"yunyan/sys/cache" "yunyan/sys/cache"
"fmt" "fmt"
@ -75,15 +76,34 @@ func (this *modelCacheComp) refreshProduct() {
func (this *modelCacheComp) GetProduct(id int32) (model *pb.DBProduct, err error) { func (this *modelCacheComp) GetProduct(id int32) (model *pb.DBProduct, err error) {
// 缓存里可能是升级前写入的旧结构(没有兼容字段 factoryid),命中也补一次,见 compat.go。 // 缓存里可能是升级前写入的旧结构(没有兼容字段 factoryid),命中也补一次,见 compat.go。
if v, found, cerr := cache.GetOne[pb.DBProduct](context.Background(), comm.Cache_Product, fmt.Sprintf("%d", id)); cerr == nil && found { if v, found, cerr := cache.GetOne[pb.DBProduct](context.Background(), comm.Cache_Product, fmt.Sprintf("%d", id)); cerr == nil && found {
if !comm.AppNamesHas(v.Appnames, comm.AppName()) {
// 命中的是别的应用的产品(存量全量缓存里可能有)——按「没有」处理,与 model 层一致。
return nil, postgres.ErrNoDocuments
}
return compatProduct(v), nil return compatProduct(v), nil
} }
return this.module.model.getProduct(id) return this.module.model.getProduct(id)
} }
// GetProducts 读取全部设备信息:缓存优先,缓存为空或异常时回退查 DB // GetProducts 读取全部设备信息:缓存优先,缓存为空或异常时回退查 DB
//
// ⚠️ 出口再过一次应用归属:写缓存的 refreshProduct 走的是已过滤的 getProducts,但 TTL 24h 的
// **存量缓存**是按应用过滤之前写下的全量快照,不在这里挡一道就要等一天才干净。
func (this *modelCacheComp) GetProducts() (models []*pb.DBProduct, err error) { func (this *modelCacheComp) GetProducts() (models []*pb.DBProduct, err error) {
if list, cerr := cache.GetAll[pb.DBProduct](context.Background(), comm.Cache_Product); cerr == nil && len(list) > 0 { if list, cerr := cache.GetAll[pb.DBProduct](context.Background(), comm.Cache_Product); cerr == nil && len(list) > 0 {
return compatProducts(list), nil return compatProducts(scopeProducts(list)), nil
} }
return this.module.model.getProducts() return this.module.model.getProducts()
} }
// scopeProducts 只留绑定了本应用(或未绑定任何应用)的产品,口径同 model 层,见 comm.AppNamesHas。
func scopeProducts(list []*pb.DBProduct) []*pb.DBProduct {
app := comm.AppName()
out := make([]*pb.DBProduct, 0, len(list))
for _, m := range list {
if comm.AppNamesHas(m.Appnames, app) {
out = append(out, m)
}
}
return out
}

35
apps/services/modules/user/model_config.go

@ -28,7 +28,6 @@ type modelConfigComp struct {
cbase.ModuleCompBase cbase.ModuleCompBase
module *User module *User
lock sync.RWMutex lock sync.RWMutex
config []*pb.DBAppConfigItem
agents []*pb.DBAgent agents []*pb.DBAgent
mcps []*mcpEntry mcps []*mcpEntry
} }
@ -98,13 +97,9 @@ func (this *modelConfigComp) Rpc_ModifyAppConifg(ctx context.Context, args *pb.R
func (this *modelConfigComp) loaddb() (err error) { func (this *modelConfigComp) loaddb() (err error) {
var ( var (
config []*pb.DBAppConfigItem
agents []*pb.DBAgent agents []*pb.DBAgent
mcps []*mcpEntry mcps []*mcpEntry
) )
if config, err = this.getconfig(); err != nil {
return
}
if agents, err = this.getagents(); err != nil { if agents, err = this.getagents(); err != nil {
return return
} }
@ -112,34 +107,25 @@ func (this *modelConfigComp) loaddb() (err error) {
return return
} }
this.lock.Lock() this.lock.Lock()
this.config = config
this.agents = agents this.agents = agents
this.mcps = mcps this.mcps = mcps
this.lock.Unlock() this.lock.Unlock()
// 算力换算系数与运营参数也存 config 表,但走 comm 的独立缓存(业务侧各处直接调 comm.LoadXxx, // 业务库 config 表 2026-09-14 起不再整张缓存/下发(客户端要读的键都进了 svc_config)。
// 不经过这里的 this.config)。后台改完会广播到这里,顺手让那两份缓存失效, // 表里剩下的算力换算系数与运营参数走 comm 的独立缓存(业务侧各处直接调 comm.LoadXxx)。
// 否则要等它们自己的 30s TTL 过期才生效。 // 后台改完会广播到这里,顺手让那两份缓存失效,否则要等它们自己的 30s TTL 过期才生效。
comm.InvalidateComputeRates() comm.InvalidateComputeRates()
comm.InvalidateOpsParams() comm.InvalidateOpsParams()
return return
} }
func (this *modelConfigComp) getdb() (config []*pb.DBAppConfigItem, agents []*pb.DBAgent, mcps []*mcpEntry) { func (this *modelConfigComp) getdb() (agents []*pb.DBAgent, mcps []*mcpEntry) {
this.lock.RLock() this.lock.RLock()
config = this.config
agents = this.agents agents = this.agents
mcps = this.mcps mcps = this.mcps
this.lock.RUnlock() this.lock.RUnlock()
return return
} }
// App自有配置(音乐、OSS等)
func (this *modelConfigComp) getconfig() (config []*pb.DBAppConfigItem, err error) {
config = make([]*pb.DBAppConfigItem, 0)
err = mysql.Find(comm.TableAppConfig, &config, "")
return
}
// 智能体 // 智能体
func (this *modelConfigComp) getagents() (agents []*pb.DBAgent, err error) { func (this *modelConfigComp) getagents() (agents []*pb.DBAgent, err error) {
agents = make([]*pb.DBAgent, 0) agents = make([]*pb.DBAgent, 0)
@ -147,12 +133,14 @@ func (this *modelConfigComp) getagents() (agents []*pb.DBAgent, err error) {
return return
} }
// mcp:MCP 已并入第三方服务(svc_config 的 MCP 服务类型)。读全局(app_name=”)启用的 MCP 服务及其 // mcp:MCP 已并入第三方服务(svc_config 的 MCP 服务类型)。读**本应用**作用域下启用的 MCP 服务及其
// 全部区域覆盖,缓存为 mcpEntry;getappconfig 时按客户端区域用 comm.ResolveMcpServer 解析。 // 全部区域覆盖,缓存为 mcpEntry;getappconfig 时按客户端区域用 comm.ResolveMcpServer 解析。
// ⚠️ 2026-09-14 作用域归一后全局层(app_name='')为空,这里原先查的是全局层——会一条都读不到、且不报错。
func (this *modelConfigComp) getmcpservers() (entries []*mcpEntry, err error) { func (this *modelConfigComp) getmcpservers() (entries []*mcpEntry, err error) {
entries = make([]*mcpEntry, 0) entries = make([]*mcpEntry, 0)
svcs := make([]*comm.ThirdSvcConfig, 0) svcs := make([]*comm.ThirdSvcConfig, 0)
if err = postgres.Find(comm.TableSvcConfig, &svcs, "app_name=? AND enable=?", "", true); err != nil { app := comm.AppName()
if err = postgres.Find(comm.TableSvcConfig, &svcs, "app_name=? AND enable=?", app, true); err != nil {
return return
} }
for _, svc := range svcs { for _, svc := range svcs {
@ -160,7 +148,7 @@ func (this *modelConfigComp) getmcpservers() (entries []*mcpEntry, err error) {
continue continue
} }
ovrs := make([]*comm.SvcRegionOverride, 0) ovrs := make([]*comm.SvcRegionOverride, 0)
_ = postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=? AND svc_id=?", "", svc.Id) _ = postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=? AND svc_id=?", app, svc.Id)
m := make(map[int32]*comm.SvcRegionOverride, len(ovrs)) m := make(map[int32]*comm.SvcRegionOverride, len(ovrs))
for _, o := range ovrs { for _, o := range ovrs {
m[o.Region] = o m[o.Region] = o
@ -170,8 +158,3 @@ func (this *modelConfigComp) getmcpservers() (entries []*mcpEntry, err error) {
return return
} }
func (this *modelConfigComp) getWakeupVoices() (voices []*pb.DBWakeupVoice, err error) {
voices = make([]*pb.DBWakeupVoice, 0)
err = postgres.Find(comm.TableWakeupVoice, &voices, "")
return
}

77
apps/services/modules/user/model_user.go

@ -250,10 +250,36 @@ func (this *modelUserComp) delDevice(id uint64) (err error) {
// 这两个函数是绑定/解绑路径上的"读后写同一行"校验读,用 FindOnePrimary 强制走主库, // 这两个函数是绑定/解绑路径上的"读后写同一行"校验读,用 FindOnePrimary 强制走主库,
// 避免读到只读副本复制延迟内的旧状态造成重复绑定竞态。 // 避免读到只读副本复制延迟内的旧状态造成重复绑定竞态。
// getFactoryDevice 按授权码(= 主键 code)取设备行。 // deviceInThisApp 这台设备是否属于本应用。
//
// device_mac 是**全平台一张表**(2026-09-04 从 license_<pid> 分表合过来),MAC 全局唯一、
// 表上没有应用列。应用归属沿这条链走:device_mac.productid → product.appnames(CSV)→ 本应用。
// 不校验的话,A 应用的设备能绑到 B 应用的账号上,绑定礼/设备 VIP/算力也跟着串过去。
//
// ⚠️ productid=0 一律**拒绝**,不是放行:设备必须挂在产品下(后台生成/导入都已在入口校验,
// device_mac 上还有 CHECK 约束兜底),0 意味着这条数据本身是坏的——既推不出应用归属,
// 也说不清是谁的设备。两台机实测都没有这种行,真出现了应该在后台修数据而不是放它过去。
func (this *modelUserComp) deviceInThisApp(model *pb.DBAuthCode) bool {
if model == nil {
return false
}
if model.Productid == 0 {
return false
}
// getProduct 自带 appnames 过滤:产品不存在、或不属于本应用,都返回 ErrNoDocuments。
_, err := this.getProduct(int32(model.Productid))
return err == nil
}
// getFactoryDevice 按授权码(= 主键 code)取设备行。不属于本应用的设备等同于「查不到」。
func (this *modelUserComp) getFactoryDevice(code string) (model *pb.DBAuthCode, err error) { func (this *modelUserComp) getFactoryDevice(code string) (model *pb.DBAuthCode, err error) {
model = &pb.DBAuthCode{} model = &pb.DBAuthCode{}
err = postgres.FindOnePrimary(comm.TableDeviceMac, model, "code=?", code) if err = postgres.FindOnePrimary(comm.TableDeviceMac, model, "code=?", code); err != nil {
return
}
if !this.deviceInThisApp(model) {
return &pb.DBAuthCode{}, postgres.ErrNoDocuments
}
return return
} }
@ -267,9 +293,14 @@ func (this *modelUserComp) getFactoryDevice(code string) (model *pb.DBAuthCode,
// //
// 所以这里多加一次兜底:精确匹配没中,就把入参规范化后再查一次。 // 所以这里多加一次兜底:精确匹配没中,就把入参规范化后再查一次。
// 顺序不能反——先精确保证存量非规范数据(如果有)仍然查得到,规范化只用于补救。 // 顺序不能反——先精确保证存量非规范数据(如果有)仍然查得到,规范化只用于补救。
// ⚠️ 命中后还要过一道应用归属(deviceInThisApp):这张表是全平台共用的,
// 别的应用的设备也在里面,不拦就等于「谁的 MAC 都能绑到本应用的账号上」。
func (this *modelUserComp) getFactoryDeviceformac(devicemac string) (model *pb.DBAuthCode, err error) { func (this *modelUserComp) getFactoryDeviceformac(devicemac string) (model *pb.DBAuthCode, err error) {
model = &pb.DBAuthCode{} model = &pb.DBAuthCode{}
if err = postgres.FindOnePrimary(comm.TableDeviceMac, model, "devicemac=?", devicemac); err == nil { if err = postgres.FindOnePrimary(comm.TableDeviceMac, model, "devicemac=?", devicemac); err == nil {
if !this.deviceInThisApp(model) {
return &pb.DBAuthCode{}, postgres.ErrNoDocuments
}
return model, nil return model, nil
} }
norm, ok := devcode.NormalizeMac(devicemac) norm, ok := devcode.NormalizeMac(devicemac)
@ -277,7 +308,12 @@ func (this *modelUserComp) getFactoryDeviceformac(devicemac string) (model *pb.D
return model, err // 规范化不了,或本来就是规范形态:保留第一次的错误 return model, err // 规范化不了,或本来就是规范形态:保留第一次的错误
} }
model = &pb.DBAuthCode{} model = &pb.DBAuthCode{}
err = postgres.FindOnePrimary(comm.TableDeviceMac, model, "devicemac=?", norm) if err = postgres.FindOnePrimary(comm.TableDeviceMac, model, "devicemac=?", norm); err != nil {
return
}
if !this.deviceInThisApp(model) {
return &pb.DBAuthCode{}, postgres.ErrNoDocuments
}
return return
} }
@ -311,23 +347,50 @@ func (this *modelUserComp) getCode(addr string) (code string, err error) {
} }
// 以下三个产品读取口的结果都会下发到客户端,出库即过 compatProduct 回填老客户端的 factoryid 字段(见 compat.go)。 // 以下三个产品读取口的结果都会下发到客户端,出库即过 compatProduct 回填老客户端的 factoryid 字段(见 compat.go)。
//
// ⚠️ product 是**公共库里的全平台表**(没有 app_name 列,多个应用共用一张),按应用的归属记在
// DBProduct.Appnames(CSV,console 产品管理里勾选)。这里必须按 comm.AppName() 过滤——
// 不过滤的话 EAIMAR 的客户端会拿到 deepGlass 的产品:
// - 设备绑定的 pid 是客户端从这张表里挑的,多出别家的产品会挑错 → 服务端按 (pid, MAC) 查不到 → AuthorizeNoCanUse;
// - OTA 页按「设备名与产品名互相包含」匹配产品,多一个同名/近名产品就会匹配到别家的固件。
// 过滤只能在内存里做,不能下推成 SQL LIKE,理由见 comm.AppNamesHas。
func (this *modelUserComp) getProduct(id int32) (model *pb.DBProduct, err error) { func (this *modelUserComp) getProduct(id int32) (model *pb.DBProduct, err error) {
model = &pb.DBProduct{} model = &pb.DBProduct{}
err = postgres.FindOne(comm.TableProduct, model, "id=?", id) if err = postgres.FindOne(comm.TableProduct, model, "id=?", id); err != nil {
return
}
if !comm.AppNamesHas(model.Appnames, comm.AppName()) {
// 存在但不属于本应用:等同于「没有这个产品」,别把别家的产品当自己的返回。
return nil, postgres.ErrNoDocuments
}
compatProduct(model) compatProduct(model)
return return
} }
func (this *modelUserComp) getProducts() (models []*pb.DBProduct, err error) { func (this *modelUserComp) getProducts() (models []*pb.DBProduct, err error) {
models = make([]*pb.DBProduct, 0) all := make([]*pb.DBProduct, 0)
err = postgres.Find(comm.TableProduct, &models, "") if err = postgres.Find(comm.TableProduct, &all, ""); err != nil {
return
}
app := comm.AppName()
models = make([]*pb.DBProduct, 0, len(all))
for _, m := range all {
if comm.AppNamesHas(m.Appnames, app) {
models = append(models, m)
}
}
compatProducts(models) compatProducts(models)
return return
} }
func (this *modelUserComp) getProductForName(devicename string) (model *pb.DBProduct, err error) { func (this *modelUserComp) getProductForName(devicename string) (model *pb.DBProduct, err error) {
model = &pb.DBProduct{} model = &pb.DBProduct{}
err = postgres.FindOne(comm.TableProduct, model, "devicename=?", devicename) if err = postgres.FindOne(comm.TableProduct, model, "devicename=?", devicename); err != nil {
return
}
if !comm.AppNamesHas(model.Appnames, comm.AppName()) {
return nil, postgres.ErrNoDocuments
}
compatProduct(model) compatProduct(model)
return return
} }

26
apps/services/modules/user/svcresolve.go

@ -128,28 +128,26 @@ func svcEnvOverlay(items []*pb.ThirdSvcItem, envById map[string]map[string]strin
return env return env
} }
// appEnvForUser 合成下发给客户端的环境变量表 env: // appEnvForUser 合成下发给客户端的环境变量表 env:**只来自 svc_config 字段上的 env_key**。
// //
// 应用自有配置(业务库 config 表) 打底 ← 第三方服务配置(svc_config) 的 env_key 覆盖 // 演变:2026-09-12 之前是「global_config 按区域覆盖业务库 config 表」,凭据同时存在两张扁平表里、
// // 且全局表悄悄赢,后台改了半天实际生效的一直是另一张表。9-12 凭据归一到 svc_config 后,
// 这取代了 2026-09-12 之前的「global_config 按区域覆盖 app_config」。旧法的问题是凭据同时存在 // env 变成「config 表打底 ← svc_config 的 env_key 覆盖」。9-14 起 config 表**不再参与**:
// 两张扁平表里、且**全局表悄悄赢**:同一个 AZURE_SPEECH_REGION / OPENAI_API_KEY / XUNFEI 密钥 // 那张表整张下发等于把 COS 密钥、灵犀产品密钥、连「会员与算力」页写进去的 COMPUTE_RATE_* 都明文发到
// 在两边值不一样,后台在「应用环境配置」里改了半天,实际生效的一直是另一张表,且不报任何错。 // 客户端;客户端真正读的几个键(AGENT_TYPE / MOBILE_ELF_* / iapCustomerServiceQQ)已由 console 启动迁移
// 现在凭据只有 svc_config 一个出处,区域差异由它自己的 svc_region_override 表达。 // 搬进 svc_config(migrate_appparams.go),以同名 env_key 继续下发。config 表现在只剩服务端自用的
// 算力/运营参数,由 comm.LoadComputeRates 等自己读。
// //
// env 是给**存量客户端**的兼容层:新客户端直接读 thirdsvcs(结构化、带类别), // env 是给**存量客户端**的兼容层:新客户端直接读 thirdsvcs(结构化、带类别),
// 老客户端继续读 env[KEY],两者同源,不会再出现两份值打架。 // 老客户端继续读 env[KEY],两者同源,不会再出现两份值打架。
// //
// 解析失败只记日志、退回「只有应用自有配置」:env 少几个键表现为对应能力鉴权失败, // 解析失败只记日志、退回空表:env 少几个键表现为对应能力鉴权失败,
// 而整个 user_getappconfig 报错会让客户端连产品表/智能体都拿不到,直接白屏。 // 而整个 user_getappconfig 报错会让客户端连产品表/智能体都拿不到,直接白屏。
func appEnvForUser(uid string, config []*pb.DBAppConfigItem, region pb.Region) map[string]string { func appEnvForUser(uid string, region pb.Region) map[string]string {
env := make(map[string]string, len(config)+16) env := make(map[string]string, 32)
for _, v := range config {
env[v.Key] = v.Value
}
items, envById, err := resolveThirdSvcsWithEnv(comm.AppName(), region, nil) items, envById, err := resolveThirdSvcsWithEnv(comm.AppName(), region, nil)
if err != nil { if err != nil {
log.Warnf("下发 env: 第三方服务解析失败,本次只下发应用自有配置: %v", err) log.Warnf("下发 env: 第三方服务解析失败,本次 env 为空: %v", err)
return env return env
} }
// 与 thirdsvcs 用同一份 VIP 过滤结果——否则 VIP 过期的用户拿不到 AST 条目, // 与 thirdsvcs 用同一份 VIP 过滤结果——否则 VIP 过期的用户拿不到 AST 条目,

10
apps/services/services/home/main.go

@ -17,7 +17,6 @@ import (
ali_auth "yunyan/sys/aliyun/auth" ali_auth "yunyan/sys/aliyun/auth"
apple_auth "yunyan/sys/auth/apple" apple_auth "yunyan/sys/auth/apple"
facebook_auth "yunyan/sys/auth/facebook" facebook_auth "yunyan/sys/auth/facebook"
firebase_auth "yunyan/sys/auth/firebase"
google_auth "yunyan/sys/auth/google" google_auth "yunyan/sys/auth/google"
wechat_auth "yunyan/sys/auth/wechat" wechat_auth "yunyan/sys/auth/wechat"
"yunyan/sys/doubao" "yunyan/sys/doubao"
@ -191,11 +190,10 @@ func (this *Service) InitSys() {
} else { } else {
log.Infof("init sys.facebook_auth success!") log.Infof("init sys.facebook_auth success!")
} }
if err := firebase_auth.OnInit(this.GetSettings().Sys["firebase_auth"]); err != nil { // sys/auth/firebase 已于 2026-09-14 删除:它是 google_auth 的重复实现,Auth() 一处调用方都没有
log.Warnf("init sys.firebase_auth err: %s (Firebase 登录未配置,该登录方式不可用)", err.Error()) // (登录类型只有 pb.SginTyoe_ 的 WXChat/Google/FaceBook/Apple/Mail/Phone/Tourists,没有 Firebase;
} else { // 客户端拿的 Firebase ID Token 走 stype=Google,由 google_auth 校验)。它唯一的作用是在缺那个
log.Infof("init sys.firebase_auth success!") // 服务账号 json 时于启动日志留一条 ERROR + 一条 WARN,看着像启动出了问题。
}
if err := wechat_auth.OnInit(this.GetSettings().Sys["wechat_auth"]); err != nil { if err := wechat_auth.OnInit(this.GetSettings().Sys["wechat_auth"]); err != nil {
log.Warnf("init sys.wechat_auth err: %s (微信登录未配置,该登录方式不可用)", err.Error()) log.Warnf("init sys.wechat_auth err: %s (微信登录未配置,该登录方式不可用)", err.Error())
} else { } else {

4
apps/services/sys/auth/apple/options.go

@ -27,7 +27,7 @@ func newOptions(config map[string]interface{}, opts ...Option) Options {
o(&options) o(&options)
} }
if options.Log == nil { if options.Log == nil {
options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.tavily", 3)) options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.apple_auth", 3))
} }
return options return options
} }
@ -38,7 +38,7 @@ func newOptionsByOption(opts ...Option) Options {
o(&options) o(&options)
} }
if options.Log == nil { if options.Log == nil {
options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.tavily", 3)) options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.apple_auth", 3))
} }
return options return options
} }

4
apps/services/sys/auth/facebook/options.go

@ -32,7 +32,7 @@ func newOptions(config map[string]interface{}, opts ...Option) Options {
o(&options) o(&options)
} }
if options.Log == nil { if options.Log == nil {
options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.tavily", 3)) options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.facebook_auth", 3))
} }
return options return options
} }
@ -43,7 +43,7 @@ func newOptionsByOption(opts ...Option) Options {
o(&options) o(&options)
} }
if options.Log == nil { if options.Log == nil {
options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.tavily", 3)) options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.facebook_auth", 3))
} }
return options return options
} }

70
apps/services/sys/auth/firebase/auth.go

@ -1,70 +0,0 @@
package firebase_auth
import (
"context"
"fmt"
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/auth"
"github.com/coze-dev/coze-go"
"google.golang.org/api/option"
)
// newSys 创建 Firebase 认证系统实例并初始化 Auth 客户端。
// 参数:
// - options: 运行所需配置(如服务账号密钥路径、日志)
//
// 返回值:
// - sys: 创建成功的实例
// - err: 创建或初始化失败时返回错误
func newSys(options Options) (sys *Google, err error) {
sys = &Google{
options: options,
}
// 指定服务账号密钥路径
opt := option.WithCredentialsFile(options.ApiKeyFile)
// 初始化 Firebase App
app, err := firebase.NewApp(context.Background(), nil, opt)
if err != nil {
options.Log.Errorln(err)
return
}
// 获取 Auth 客户端
sys.client, err = app.Auth(context.Background())
if err != nil {
options.Log.Errorln(err)
return
}
return
}
type Google struct {
options Options
api coze.CozeAPI
client *auth.Client
}
// Auth 使用 Firebase Admin SDK 校验 ID Token。
// 参数:
// - ctx: 上下文
// - idToken: 客户端传入的 Firebase ID Token
//
// 返回值:
// - info: 校验成功后返回的 Token 信息
// - err: 校验失败或邮箱未验证时返回错误
func (this *Google) Auth(ctx context.Context, idToken string) (info *auth.Token, err error) {
// 验证 Token
token, err := this.client.VerifyIDToken(ctx, idToken)
if err != nil {
return nil, fmt.Errorf("invalid ID Token: %v", err)
}
// 检查邮箱是否已验证(仅当 email_verified 明确为 false 时拒绝;缺失/非 bool 不再导致 panic)
// if v, ok := token.Claims["email_verified"]; ok && v != nil {
// if verified, ok := v.(bool); ok && !verified {
// return nil, fmt.Errorf("email not verified")
// }
// }
return token, nil
}

41
apps/services/sys/auth/firebase/core.go

@ -1,41 +0,0 @@
package firebase_auth
import (
"context"
"errors"
"firebase.google.com/go/v4/auth"
)
type (
ISys interface {
Auth(ctx context.Context, idToken string) (info *auth.Token, err error)
}
)
var defsys ISys
// ErrNotInited Firebase 登录未初始化。OnInit 失败时调用方可选择降级;此时包级函数返回本错误,而不是 panic。
var ErrNotInited = errors.New("firebase_auth 未初始化:配置缺失")
func OnInit(config map[string]interface{}, option ...Option) error {
sys, err := newSys(newOptions(config, option...))
if err != nil {
// 出错时不要给 defsys 赋值,避免「非 nil 接口 + nil 指针」让守卫失效。
return err
}
defsys = sys
return nil
}
func NewSys(option ...Option) (sys ISys, err error) {
sys, err = newSys(newOptionsByOption(option...))
return
}
func Auth(ctx context.Context, idToken string) (info *auth.Token, err error) {
if defsys == nil {
return nil, ErrNotInited
}
return defsys.Auth(ctx, idToken)
}

44
apps/services/sys/auth/firebase/options.go

@ -1,44 +0,0 @@
package firebase_auth
import (
"yunyan/lego/sys/log"
"yunyan/lego/utils/mapstructure"
)
type Option func(*Options)
type Options struct {
Debug bool //日志是否开启
Log log.ILogger
ApiKeyFile string
}
func SetApiKeyFile(v string) Option {
return func(o *Options) {
o.ApiKeyFile = v
}
}
func newOptions(config map[string]interface{}, opts ...Option) Options {
options := Options{}
if config != nil {
mapstructure.Decode(config, &options)
}
for _, o := range opts {
o(&options)
}
if options.Log == nil {
options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.tavily", 3))
}
return options
}
func newOptionsByOption(opts ...Option) Options {
options := Options{}
for _, o := range opts {
o(&options)
}
if options.Log == nil {
options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.tavily", 3))
}
return options
}

20
apps/services/sys/auth/firebase/sys_test.go

@ -1,20 +0,0 @@
package firebase_auth_test
import (
//"lego_bighealth/sys/coze"
"context"
firebase_auth "yunyan/sys/auth/firebase"
"fmt"
"testing"
)
func Test_Sys_Chat(t *testing.T) {
if sys, err := firebase_auth.NewSys(); err != nil {
fmt.Printf("Sys Init err:%v", err)
} else {
info, err := sys.Auth(context.Background(), "eyJhbGciOiJSUzI1NiIsImtpZCI6IjMwYjIyMWFiNjU2MTdiY2Y4N2VlMGY4NDYyZjc0ZTM2NTIyY2EyZTQiLCJ0eXAiOiJKV1QifQ.eyJuYW1lIjoiY2h1YW5neGluIHl1bnFpIiwicGljdHVyZSI6Imh0dHBzOi8vbGgzLmdvb2dsZXVzZXJjb250ZW50LmNvbS9hL0FDZzhvY0p3OTZwSHFvM3VjZXl4eXJXemlRcjhlR1MtQ1paX05oUkJOQ19Fdm1Ha0F5ZjZUUT1zOTYtYyIsImlzcyI6Imh0dHBzOi8vc2VjdXJldG9rZW4uZ29vZ2xlLmNvbS9kZWVwc291bmQtZTBlM2QiLCJhdWQiOiJkZWVwc291bmQtZTBlM2QiLCJhdXRoX3RpbWUiOjE3NDI2MTA5MjMsInVzZXJfaWQiOiJVeXJrbTJBTDdEWnJuSHpmTmh6cTk4NWxRdjEyIiwic3ViIjoiVXlya20yQUw3RFpybkh6Zk5oenE5ODVsUXYxMiIsImlhdCI6MTc0MjYxMDkyMywiZXhwIjoxNzQyNjE0NTIzLCJlbWFpbCI6Inl1bnFpaW5ub3ZhdGlvbkBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJnb29nbGUuY29tIjpbIjEwNjgyMjc5MDg3MTQ0MTY4NDkxMSJdLCJlbWFpbCI6WyJ5dW5xaWlubm92YXRpb25AZ21haWwuY29tIl19LCJzaWduX2luX3Byb3ZpZGVyIjoiZ29vZ2xlLmNvbSJ9fQ.rw6DkZUM97GypdB91FMpn4UY1PvMqXegvH5u34R4zVjpF55OmVOJEI2TNl6j1K9yzL4Kq4p5_vrLLfhBqFOjkc8UfX98xKZT5oLMrfkNrlR_W8omXJ0W3HAUEaG4KGLGSJn-hiQwgp2ZEBl7zvbWaapjlpVvDhfhV5AZMaHXUalxV9iure8ubo-36F0NY3YM63YQb9QPXJ9KYIzc413mUu5Ee0oaHt_jl6Jc0HZbW7Q3qvmNFh608n79cgkToMNPJTDYLCpSOblRucJEXP7OfW7vanUOHjJyPA1EI-w_kchss664OESjnn-YGw6igkKcRDAbIZk1HEL7o3hoBfF4og")
fmt.Printf("Sys info:%+v err:%v", info, err)
}
}

16
apps/services/sys/auth/google/auth.go

@ -3,6 +3,7 @@ package google_auth
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"strings" "strings"
firebase "firebase.google.com/go/v4" firebase "firebase.google.com/go/v4"
@ -19,10 +20,23 @@ import (
// - sys: 创建成功的实例 // - sys: 创建成功的实例
// - err: 创建失败时返回错误 // - err: 创建失败时返回错误
func newSys(options Options) (sys *Google, err error) { func newSys(options Options) (sys *Google, err error) {
// 密钥在每次 Auth 时才真正读取(支持热替换文件),但**能不能用**要在启动时就说清楚:
// 原先这里无条件返回成功,日志打「init sys.google_auth success!」,而配置里的 json 根本不存在,
// 直到有用户点 Google 登录才失败——启动日志与实际能力相反,比没有日志更糟。
// 判定只做「配得上」这一层:空配置、或写的是路径而文件不在,都算未配置;内嵌 JSON 与
// 文件内容是否合法留给调用时报错(那属于凭据本身的问题,不是配置缺失)。
cred := strings.TrimSpace(options.ApiKeyFile)
switch {
case cred == "":
return nil, fmt.Errorf("google_auth: 未配置服务账号密钥(ApiKeyFile)")
case !strings.HasPrefix(cred, "{"):
if _, e := os.Stat(cred); e != nil {
return nil, fmt.Errorf("google_auth: 服务账号密钥文件不存在: %s", cred)
}
}
sys = &Google{ sys = &Google{
options: options, options: options,
} }
return return
} }

4
apps/services/sys/auth/google/options.go

@ -27,7 +27,7 @@ func newOptions(config map[string]interface{}, opts ...Option) Options {
o(&options) o(&options)
} }
if options.Log == nil { if options.Log == nil {
options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.tavily", 3)) options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.google_auth", 3))
} }
return options return options
} }
@ -38,7 +38,7 @@ func newOptionsByOption(opts ...Option) Options {
o(&options) o(&options)
} }
if options.Log == nil { if options.Log == nil {
options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.tavily", 3)) options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.google_auth", 3))
} }
return options return options
} }

4
apps/services/sys/auth/wechat/options.go

@ -32,7 +32,7 @@ func newOptions(config map[string]interface{}, opts ...Option) Options {
o(&options) o(&options)
} }
if options.Log == nil { if options.Log == nil {
options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.tavily", 3)) options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.wechat_auth", 3))
} }
return options return options
} }
@ -43,7 +43,7 @@ func newOptionsByOption(opts ...Option) Options {
o(&options) o(&options)
} }
if options.Log == nil { if options.Log == nil {
options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.tavily", 3)) options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.wechat_auth", 3))
} }
return options return options
} }

6
deploy/app/README.md

@ -88,10 +88,14 @@ cd deploy/voitrans
以及统计上报 `ANALYZE_APP_NAME`(须与 console 注册表应用名一致)/ `ANALYZE_REGION`、集群隔离 `CLUSTER_TAG`、 以及统计上报 `ANALYZE_APP_NAME`(须与 console 注册表应用名一致)/ `ANALYZE_REGION`、集群隔离 `CLUSTER_TAG`、
网关 `GATEWAY_TOKEN_KEY` 与 v2 加密 `GATEWAY_ENCRYPT_KEY`(须与 Flutter 端 AES key 一致)。 网关 `GATEWAY_TOKEN_KEY` 与 v2 加密 `GATEWAY_ENCRYPT_KEY`(须与 Flutter 端 AES key 一致)。
- **confs 引用的附属文件**(按需放入服务器部署目录,会随工作目录挂入容器 `/app`): - **confs 引用的附属文件**(按需放入服务器部署目录,会随工作目录挂入容器 `/app`):
- `home.yaml`:`./smart-bluetooth-447104-2b269474bd72.json`(Google)、firebase/google 登录 json、 - `home.yaml`:`./smart-bluetooth-447104-2b269474bd72.json`(Google 语音)、`google_auth.ApiKeyFile`
指的那份 Firebase 服务账号 json(Google 登录;也可把 json 内容直接内嵌进 yaml,以 `{` 开头即可,就不用发文件)、
`./apiclient_key.pem`(微信支付)、`confs/ip2region_v4.xdb` 与 `v6.xdb`(IP 库)、 `./apiclient_key.pem`(微信支付)、`confs/ip2region_v4.xdb` 与 `v6.xdb`(IP 库)、
`wordfilter/*.txt`(敏感词,模板里**默认已全部注释**,要用才放文件并取消注释)。 `wordfilter/*.txt`(敏感词,模板里**默认已全部注释**,要用才放文件并取消注释)。
- `confs/ip2region_v4.xdb` / `v6.xdb` 由 `dev-deploy.sh` / `prod-deploy.sh` **自动下发**(prod 只在远端缺失时传一次),不用手工放。 - `confs/ip2region_v4.xdb` / `v6.xdb` 由 `dev-deploy.sh` / `prod-deploy.sh` **自动下发**(prod 只在远端缺失时传一次),不用手工放。
- ⚠️ 登录/支付/翻译这几类缺文件**不会**杀容器,只在启动日志留一条
`init sys.xxx err: ...(xx未配置,该功能不可用)` 的 WARN,对应功能静默不可用——
阿龙测试机现在就缺 Google 登录与微信支付/支付宝那几份。真正会 panic 的是下面这几类:
- ⚠️ 缺这些文件会让对应 sys 在初始化时 **panic**,entrypoint 随即杀掉整个容器 → 无限重启。 - ⚠️ 缺这些文件会让对应 sys 在初始化时 **panic**,entrypoint 随即杀掉整个容器 → 无限重启。
这种失败的日志有欺骗性:最扎眼的往往是 `Table 'xxx.userdevice' doesn't exist`(那些表本该由 home 里的 这种失败的日志有欺骗性:最扎眼的往往是 `Table 'xxx.userdevice' doesn't exist`(那些表本该由 home 里的
user 模块启动时建,home 起不来自然没表),真凶却是上面几十行处的 `panic: init sys.wordfilter err: no found file:...`。 user 模块启动时建,home 起不来自然没表),真凶却是上面几十行处的 `panic: init sys.wordfilter err: no found file:...`。

1
deploy/app/confs/gateway.yaml.example

@ -25,6 +25,7 @@ modules:
- api_getuser - api_getuser
- user_getappconfig - user_getappconfig
- user_getappconfig_v2 #加密版应用配置(apiV2Comp.GetAppConfig,路由=user_<方法名小写>_v2);改方法名要同步这里,否则未登录取配置会被拒 - user_getappconfig_v2 #加密版应用配置(apiV2Comp.GetAppConfig,路由=user_<方法名小写>_v2);改方法名要同步这里,否则未登录取配置会被拒
- user_getappconfig_v3 # 加密版 v3:结构化 thirdsvcs + env,客户端 2026-09-14 起走它;splash 未登录就要取配置,必须放行
- user_getchannelapp - user_getchannelapp
# ⚠️ 复数那个也必须放行:客户端读的是它(Api.getChannelApps → /api/home/user_getchannelapps), # ⚠️ 复数那个也必须放行:客户端读的是它(Api.getChannelApps → /api/home/user_getchannelapps),
# 而游客登录入口的显隐结论就在这个响应里 —— 入口恰恰只在**未登录**时才需要显示, # 而游客登录入口的显隐结论就在这个响应里 —— 入口恰恰只在**未登录**时才需要显示,

6
deploy/app/confs/home.yaml.example

@ -77,8 +77,10 @@ sys:
FromEmail: liwei@yunqiinnovation.com FromEmail: liwei@yunqiinnovation.com
FromName: DeepSound FromName: DeepSound
Password: "" Password: ""
firebase_auth: #需放置对应 json # firebase_auth 段已随 sys/auth/firebase 一起删除(2026-09-14),留着也不会被读。
ApiKeyFile: ./deepsound-e0e3d-firebase-adminsdk-fbsvc-269043a0f8.json # google_auth: 客户端 Google 登录拿到的 Firebase ID Token 由它校验。ApiKeyFile 支持两种写法:
# ① 文件路径——那个 json 要真的放进本目录(confs/),否则启动会警告「Google 登录未配置」;
# ② 直接内嵌 json 内容(以 { 开头),不用额外发文件。
google_auth: #需放置对应 json google_auth: #需放置对应 json
ApiKeyFile: ./deepsound-e0e3d-firebase-adminsdk-fbsvc-269043a0f8.json ApiKeyFile: ./deepsound-e0e3d-firebase-adminsdk-fbsvc-269043a0f8.json
facebook_auth: facebook_auth:

7
deploy/app/env/env.example

@ -24,7 +24,7 @@ DB_DEVICE_READ_DSN=
REDIS_ADDR=redis:6379 REDIS_ADDR=redis:6379
REDIS_PASSWORD=li13451234 REDIS_PASSWORD=li13451234
REDIS_DB=1 REDIS_DB=1
REDIS_KEY_PREFIX=VoitransDev REDIS_KEY_PREFIX=EAIMAR
# ── NATS(统计快照推送到 console,须与 console 端一致)── # ── NATS(统计快照推送到 console,须与 console 端一致)──
NATS_URL=nats://nats:4222 NATS_URL=nats://nats:4222
@ -59,7 +59,10 @@ ID_HASH_SALT=
# 留空则一律落到「全局默认」作用域('',0):全局默认层仍生效,但按应用/区域的细分配置不生效。 # 留空则一律落到「全局默认」作用域('',0):全局默认层仍生效,但按应用/区域的细分配置不生效。
# ANALYZE_REGION 填 pb.Region 枚举整数(1=中国 2=美国 …,见 comm/region.go);查注册表时 # ANALYZE_REGION 填 pb.Region 枚举整数(1=中国 2=美国 …,见 comm/region.go);查注册表时
# 由 comm.AppRegion() 归一成后台的两档(1→cn,其余→hw),与后台的「国内/海外」选项对齐。 # 由 comm.AppRegion() 归一成后台的两档(1→cn,其余→hw),与后台的「国内/海外」选项对齐。
ANALYZE_APP_NAME=starpivot-app-dev # ⚠️ 这个值必须与 console 应用注册表 app_registry 的 name 与 app_name **两列同时**一致
# (大小写敏感):配置作用域认 app_name、统计落库认 name,只对上一半必然坏掉另一半,
# 且两边都不报错——只表现为「后台配了但客户端读不到」或「看板恒为 0」。
ANALYZE_APP_NAME=EAIMAR
ANALYZE_REGION=1 ANALYZE_REGION=1
# ── 业务杂项 ── # ── 业务杂项 ──

Loading…
Cancel
Save