Browse Source

services,admin: 凭据归一到 svc_config;后台服务配置菜单收敛;品牌商与公码下架

这一提交里有两条相对独立的线,落在同一批文件上(server.go / core.go / model.go /
CLAUDE.md 等被两边同时改到),按文件拆不开,故合并提交。

── 一、第三方凭据归一到 svc_config,后台「服务配置」从 11 项收敛到 6 项 ──

起因:凭据同时躺在两张扁平 key/value 表里——应用业务库的 config 与 console 库的
global_config,而合并下发时**全局那张赢**。同一个 AZURE_SPEECH_REGION /
OPENAI_API_KEY / VOLC_OPENSPEECH_APP_ID / XUNFEI 密钥在两边值不一样,运营在
「应用环境配置」里改半天没有任何效果,且不报错。global_config 还按区域整份复制
(14 区 × 31 键 = 473 行),实测其中 27 个键是各区同值。

- comm.SvcField 新增 EnvKey(后台字段表里的「下发键名」列):非空时该字段的明文值
  同时以这个名字出现在 user_getappconfig 的 env 里。已发布的客户端只读 env,
  靠它继续供货、无需发新包;新客户端直接读结构化的 thirdsvcs。
- env 覆盖层与 thirdsvcs 走同一份 VIP 过滤,否则 VIP 过期的用户虽拿不到 AST 条目,
  却仍能从 env 里读出同一套通话翻译凭据,那道闸门就形同虚设。
- 同作用域内 env_key 不允许重名(checkSvcEnvKeys 在保存时拦):重名时下发结果取决于
  服务 id 排序,表现为「凭据时对时不对」。
- 一次性迁移 migrate_envtosvc.go(console 每次启动跑、幂等):基础值取中国区现值,
  只有与基础值不同的区域才写 svc_region_override,473 行收敛到几十行。
  顺带修了两行填错列的历史数据(group 里是键名、key 里是值),正是它让
  ALIBABA_BAILIAN_APP_ID/WORKSPACE_ID 恒为 null、客户端一直用代码里写死的兜底值。
  不删 global_config 的行(不可逆的线上凭据,留作回退依据),确认跑稳后由人手工 DROP。
- 后台:「第三方服务配置」改名「服务与环境配置」,「应用环境配置」并为其第二个标签页;
  「全局环境配置」下线只留重定向;Agent 配置、通话翻译配置、会议记录服务三页删除
  (前两者对应的 agent_config、call_translate_rule 两表在测试机与正式机上都是 0 行)。
  ⚠️ 会议记录服务删的只是后台入口,echomeet_orch 的数据与运行时都还在(两台机各 4 行,
  是语音纪要识别/翻译/总结的服务选型来源),要换服务商现在只能直接改库。

阿龙测试环境实测:迁移前后 env 逐键比对,48/54 键值完全一致,丢失的 8 个全部是预期
丢弃(ALIBABA_OSS_* 与 COMMENT_* 无人读取、两个填错列的垃圾键名),无一个值发生变化。
验证中发现并修掉四个都不报错的坑:空值不能下发成 env 键(Dart 的 ?? 只兜 null,
空串会把默认值顶掉)、区域覆盖里的空串会把基础值抹成空、服务 enable=false 等于凭据
一个都不下发、启动时的模板补字段会给实例加空的加密字段导致误报「缺凭据」且保存被拒。

── 二、品牌商(identity=3)与公码下架 ──

brand 表、公码 authcode 及其全部后台页面与接口移除;账号作用域不再有 brandId,
设备/结算的归属改由渠道商推导。这部分不是本次会话中由 Claude 编写的。

验证:go build ./... + go vet ./... 通过,comm/console/user/echomeet/api 各包测试全绿;
admin npm run build 通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 4 weeks ago
parent
commit
268ce26bc9
  1. 581
      CLAUDE.md
  2. 264
      apps/admin/app/components/AppEnvTab.vue
  3. 168
      apps/admin/app/pages/accounts.vue
  4. 1206
      apps/admin/app/pages/agents.vue
  5. 312
      apps/admin/app/pages/appconfig.vue
  6. 622
      apps/admin/app/pages/calltranslate.vue
  7. 445
      apps/admin/app/pages/code.vue
  8. 20
      apps/admin/app/pages/dashboard.vue
  9. 33
      apps/admin/app/pages/device.vue
  10. 2
      apps/admin/app/pages/environments.vue
  11. 354
      apps/admin/app/pages/factory.vue
  12. 236
      apps/admin/app/pages/globalconfig.vue
  13. 629
      apps/admin/app/pages/meetingsvc.vue
  14. 45
      apps/admin/app/pages/serviceconfig.vue
  15. 23
      apps/admin/app/stores/auth.ts
  16. 43
      apps/admin/app/utils/menus.ts
  17. 4
      apps/services/comm/const.go
  18. 2
      apps/services/comm/nats.go
  19. 43
      apps/services/comm/svcpool.go
  20. 57
      apps/services/modules/api/api_addbrand.go
  21. 28
      apps/services/modules/api/api_addglobalconfigs.go
  22. 30
      apps/services/modules/api/api_addproduct.go
  23. 22
      apps/services/modules/api/api_channel.go
  24. 8
      apps/services/modules/api/api_createauthcodes.go
  25. 87
      apps/services/modules/api/api_createfactorypubliccode.go
  26. 24
      apps/services/modules/api/api_delbrand.go
  27. 29
      apps/services/modules/api/api_delfactorypubliccode.go
  28. 27
      apps/services/modules/api/api_delglobalconfigs.go
  29. 28
      apps/services/modules/api/api_getbrand.go
  30. 47
      apps/services/modules/api/api_getbrands.go
  31. 23
      apps/services/modules/api/api_getfactorypubliccodes.go
  32. 27
      apps/services/modules/api/api_getglobalconfig.go
  33. 53
      apps/services/modules/api/api_updatebrand.go
  34. 51
      apps/services/modules/api/api_updatefactorypubliccode.go
  35. 27
      apps/services/modules/api/api_updateglobalconfig.go
  36. 6
      apps/services/modules/api/core.go
  37. 13
      apps/services/modules/api/interceptor_permission.go
  38. 114
      apps/services/modules/api/model.go
  39. 67
      apps/services/modules/console/api_account.go
  40. 357
      apps/services/modules/console/api_agent.go
  41. 58
      apps/services/modules/console/api_agent_test.go
  42. 249
      apps/services/modules/console/api_calltranslate.go
  43. 106
      apps/services/modules/console/api_channel.go
  44. 96
      apps/services/modules/console/api_config.go
  45. 182
      apps/services/modules/console/api_device.go
  46. 324
      apps/services/modules/console/api_echomeet_orch.go
  47. 12
      apps/services/modules/console/api_settlement.go
  48. 47
      apps/services/modules/console/api_svcconfig.go
  49. 50
      apps/services/modules/console/api_svctemplate.go
  50. 34
      apps/services/modules/console/api_svctemplate_test.go
  51. 4
      apps/services/modules/console/core.go
  52. 629
      apps/services/modules/console/migrate_envtosvc.go
  53. 191
      apps/services/modules/console/migrate_envtosvc_test.go
  54. 40
      apps/services/modules/console/model_account.go
  55. 80
      apps/services/modules/console/model_device.go
  56. 40
      apps/services/modules/console/model_device_cache.go
  57. 8
      apps/services/modules/console/model_registry.go
  58. 35
      apps/services/modules/console/registry.go
  59. 29
      apps/services/modules/console/scope.go
  60. 34
      apps/services/modules/console/scope_app.go
  61. 91
      apps/services/modules/console/scope_channel.go
  62. 267
      apps/services/modules/console/server.go
  63. 185
      apps/services/modules/user/api_bindauthcode.go
  64. 3
      apps/services/modules/user/api_binddevice.go
  65. 16
      apps/services/modules/user/api_getappconfig.go
  66. 16
      apps/services/modules/user/api_v2_getappconfig.go
  67. 49
      apps/services/modules/user/api_v3_getappconfig.go
  68. 27
      apps/services/modules/user/model_config.go
  69. 41
      apps/services/modules/user/model_user.go
  70. 131
      apps/services/modules/user/module_test.go
  71. 71
      apps/services/modules/user/svcresolve.go

581
CLAUDE.md

@ -74,23 +74,37 @@ iOS 首次还需 `cd ios && pod install`。
./build.sh admin
# 测试环境发布(在 deploy/<服务>/ 下,服务 = app | console | admin)
cd deploy/console && ./dev-deploy.sh yunyan # 云雁测试环境(广州阿里云 8.138.86.223)
cd deploy/console && ./dev-deploy.sh along # 阿龙测试环境(阿里云 8.133.166.29)
cd deploy/console && ./dev-deploy.sh # 不带参数 → 交互选环境:lingpu | yunyan | along
cd deploy/console && ./dev-deploy.sh along # 阿龙测试环境(阿里云 8.133.166.29);目前只有这一个环境
# 生产:先构建带版本号的镜像,再部署
cd deploy/console && ./prod-build.sh 0.2.1 yunyan # 第 2 个参数选公司(yunyan|lingpu|along),省略则交互选
cd deploy/console && ./prod-deploy.sh 0.2.1 yunyan # 第 2 个参数选生产环境,须与构建时所选公司一致;省略则交互选
cd deploy/console && ./prod-build.sh 0.2.1 along # 第 2 个参数选公司(yunyan|lingpu|along),省略则交互选
cd deploy/console && ./prod-deploy.sh 0.2.1 along # 第 2 个参数选生产环境(目前只有 along)
cd deploy/app && ./prod-deploy.sh ym along 0.2.1 # app 按「应用 区域 版本」部署
```
⚠️ **本机跑部署要带 SSH 私钥**:脚本里 along 段默认写的是 `/Users/liwei1dao/…`(另一台开发机的路径),
本机(yunyanzhineng)实际那把是 `~/Documents/keys/loginscre.pem`。写成了 `${DEV_KEY:-…}` / `${DEPLOY_KEY:-…}`
可被环境变量覆盖,**不用改脚本**:
```bash
cd deploy/console && DEV_KEY=$HOME/Documents/keys/loginscre.pem ./dev-deploy.sh along
cd deploy/console && DEPLOY_KEY=$HOME/Documents/keys/loginscre.pem ./prod-deploy.sh 0.2.1 along
```
⚠️ **2026-09-13:云雁(yunyan)/灵谱(lingpu) 的部署目标已从所有脚本移除**,`ENVS`/`PROD_ENVS`/`REGIONS`
都只剩 `along`。那 6 台(云雁测试/上海/日本/新加坡、灵谱测试/正式)的 SSH 私钥路径指向另一台开发机,
本机**一台都连不上**,留在菜单里只会误选。**IP 与私钥路径不再记在这里,需要时从 git 历史取回。**
**镜像仓库没有动**:`prod-build.sh` 仍可选 `yunyan` / `lingpu` 推镜像,三家仓库本机都能读写。
**镜像仓库按公司区分**,地址/账号统一在 [deploy/registry-profiles.sh](deploy/registry-profiles.sh)(换仓库只改这一处,各脚本 source 它):
- `yunyan` → `registry.voitrans.net`(仓库实体在广州机 8.138.86.223)
- `lingpu` → `registry.lingpu.net`(灵谱香港机)
- `yunyan` → `registry.voitrans.net`(云雁自有仓库;**只用于推镜像,本仓库已无云雁的部署目标**)
- `lingpu` → `registry.lingpu.net`(灵谱自有仓库;同上)
- `along` → `registry.ymaikj.com`(仓库实体就在阿龙测试机 8.133.166.29 上,**账号是 `admin` 不是 `liwei`**);阿龙测试机与正式机 47.116.104.181 都从这里拉镜像
测试环境档案在各 `dev-deploy.sh` 的 `env_profile()`:
- `along` → 8.133.166.29(阿里云,x86_64/Docker 24.0.9),目录 `/home/work/starpivot/{app,admin,console}`,密钥 `/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem`
该机已自带 postgres:16 / redis:7 / mysql / NPM / gitea / docker-registry,配 `.env` 时优先复用,别另起一套
测试环境档案在各 `dev-deploy.sh` 的 `env_profile()`,**只有 `along` 一个**:
- `along` → 8.133.166.29(阿里云,x86_64/Docker 24.0.9),目录 `/home/work/starpivot/{app,admin,console}`
该机已自带 postgres:16 / redis:7 / mysql / NPM / gitea / docker-registry,配 `.env` 时优先复用,别另起一套。
另外还跑着 `deepglass-a11` 与另一产品 `keemir-{server,admin}`,别误当成本项目的容器。
**生产镜像名统一为 `starpivot-<服务>`**(admin/console/app 三个服务、各家公司都一样),映射在
`registry-profiles.sh` 的 `prod_image_name()`——build 与 deploy 共用这一份,别在各脚本里写死。
@ -103,14 +117,12 @@ compose 的 image 由脚本注入 `IMAGE_NAME` 变量,同样不写死。
改了服务互相发现不到)、`home.yaml` 的 `ANALYZE_APP_NAME`(须与 console 注册表的 `name`/`app_name` 两列同值,见下文「新应用首次部署 checklist」第 3 条)、
`prod-deploy.sh` APPS 里的远端目录 `/home/work/voitrans-a11`。
admin/console 的 `prod-deploy.sh` 已改为**多生产环境档案**(`env_profile()`,同 `dev-deploy.sh` 的写法):
- `yunyan` → 139.224.212.237(上海阿里云,与业务后端 app 同机),目录 `/home/work/starpivot/{admin,console}`,密钥 `/Users/liwei1dao/work/密钥/release_shanghai.pem`
(2026-08-05 由美国机 47.253.88.66 迁来;美国机上的 `yunyan-admin`/`console-a11` 容器仍在跑,待验证后停,脚本已不再指向它)
- `lingpu` → 8.166.114.65(广州阿里云),目录 `/home/work/starpivot/{admin,console}`,密钥 `~/.ssh/lingpu-gz.pem`
- `along` → 47.116.104.181(阿龙正式机,2026-08-16 补),目录 `/home/work/starpivot/{admin,console}`,密钥 `/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem`(与测试机同一把);镜像走阿龙仓库 `registry.ymaikj.com`
admin/console 的 `prod-deploy.sh` 用 `env_profile()`(同 `dev-deploy.sh` 的写法),**只有 `along` 一个**:
- `along` → 47.116.104.181(阿龙正式机),目录 `/home/work/starpivot/{admin,console}`,与测试机同一把钥匙;镜像走阿龙仓库 `registry.ymaikj.com`
该机上跑着 `ym-a11`(业务后端)、`deepglass-a11`、`starpivot-console`、`starpivot-admin`,外加自己的 postgres/mysql/redis/nats/etcd/NPM
app 的 `prod-deploy.sh` 是**按区域**(`region_profile()`):`shanghai|japan|singapore` 是云雁三地,新增 `along` → 47.116.104.181。
该函数除服务器连接信息外还给 `REGION_COMPANY`(这个区域拉哪家仓库,云雁三地=yunyan、阿龙=along;可用环境变量 `COMPANY` 覆盖)。
app 的 `prod-deploy.sh` 是**按区域**(`region_profile()`),**也只剩 `along` → 47.116.104.181**。
该函数除服务器连接信息外还给 `REGION_COMPANY`(这个区域拉哪家仓库,阿龙=along;可用环境变量 `COMPANY` 覆盖)。
**远端目录与容器名一律按「应用」区分**(2026-08-18 起):目录只由 `APPS` 表给(`ym` → `/home/work/ym-a11`),
区域里**不再有 `DEPLOY_DIR_OVERRIDE`**(阿龙原先覆盖成 `/home/work/starpivot/app`,导致部 ym 时落错目录、还和已有容器撞名,已移除;
@ -171,7 +183,8 @@ ym 首次部署容器一直叫 `starpivot-app` 正是如此)。service 名变
- ⚠️ 调用时 `method` **不要带 `api_` 前缀**,否则变成 `api_api_xxx` → `code:11`。
- 方法名以 `get/list/load/query/fetch/search/count/stat/export/check` 开头视为只读,不弹全屏遮罩;其余(add/update/del/save/create…)视为写操作,请求期间套遮罩防重复提交。
- 选中应用通过请求头 `X-App-Id` 传给后端。
- **应用下拉统一走 `api_getmyapps`**(超管/管理员例外,仍用 `console/api/apps/list`):后端按账号绑定下发可选部署。品牌商/渠道商账号的可见应用**不靠手工勾选**,而是由「名下产品绑定的应用」推导——产品的应用归属存 `DBProduct.appnames`(应用名 CSV,产品管理页多选),见 [scope_app.go](apps/services/modules/console/scope_app.go)。
- **应用下拉统一走 `api_getmyapps`**(超管/管理员例外,仍用 `console/api/apps/list`):后端按账号手工勾选的 `apps` 下发可选部署,见 [scope_app.go](apps/services/modules/console/scope_app.go)。
⚠️ 2026-09-12 前品牌商账号走的是另一条路——按「名下产品绑定的应用」推导(`brandAppNames`)。品牌商下线后该函数已删除,产品的应用归属 `DBProduct.appnames` 仍在,只是不再参与账号作用域推导。
- **后端返回常多包一层**:如 `getfactorys → {factorys}`、`getproducts → {products}`、`getproduct → {product}`;保存 payload 也常包一层(厂家 `{Factory:{...}}`、产品 `{product:{...}}`);统计 trend 日期字段是 `stat_day`。列表 ID 多以十六进制展示(`id.toString(16).toUpperCase()`)。
- **「版本控制」与「游客显隐控制」共用 `app_release` 的同一行**(一应用一行,见
[comm/apprelease.go](apps/services/comm/apprelease.go))。两个页面各自只提交自己那组字段,
@ -181,6 +194,246 @@ ym 首次部署容器一直叫 `starpivot-app` 正是如此)。service 名变
(强制主库),否则刚在另一页保存完就可能读到副本的旧行、把对方打回上一版。
- 样式:`app/assets/css/console.css` 由 nuxt.config 引用;DaisyUI 固定 4.x。
## Client 架构内核(2026-09-09 重构,先读这一节)
目标:多硬件(不同芯片/协议/编解码)+ 可持续增加的 Agent 能力模块,业务层不认厂商、不写 switch。
**已量产硬件(恒玄耳机 / EaiRec 支架 / Smartcar 香薰)的线上协议一个字节、一个毫秒都没改**,
重构只在协议之上加了一层抽象(见下)。
### 设备内核 `lib/devices/`
```
业务层 lib/modules | lib/core | lib/data 只 import lib/devices/*.dart 与 device_plugin_interface
lib/devices/device_hub.dart DeviceHub:sessions / anyConnected / capabilities / inCall / wakeEvents / sessionEvents
lib/devices/device_bootstrap.dart installDeviceHub():注册所有厂商插件(新芯片在这里加一行)
lib/devices/device_auth.dart DeviceAuth:登出 reset / 解绑 forget / 补校 verifyConnected
lib/devices/bes/ 恒玄:bes_protocol(纯函数帧编解码) / bes_bluetooth_service(协议实现,冻结)
/ bes_device_plugin(DevicePlugin+DeviceSession 适配) / bes_ota_port / bes_device_auth
lib/devices/holder/ EaiRec 支架:holder_device_service(冻结) / holder_device_plugin
local_plugins/device_plugin_interface/ 纯 Dart 契约:DevicePlugin / DeviceSession / DeviceCapability / AudioRoute
/ DeviceAudioSource|Sink / DeviceOtaPort / DeviceFeatures / DeviceFeatureKeys
```
- **业务层怎么用**:`DeviceConnection.any / inCall / has(cap)`(`core/utils/device_connection.dart`,DeviceHub 的薄门面);
要操作设备就 `DeviceHub.to.sessionWith(DeviceCapability.xxx)` 拿会话,然后
`session.invokeFeature(DeviceFeatures.callStart)`、`session.openMic(route: AudioRoute.mic)`、
`session.openSpeaker(route: AudioRoute.ai)`、`session.otaPort()`。**音频进业务层一律 PCM16/16k/mono**
(`AudioFormat.pcm16kMono`),编解码(G.722/mSBC/Opus)在插件出口。
- **厂商无关的常驻服务**(都在 `lib/data/services/`):`device_recording_service`(现场录音,替代 bes/holder 两份)、
`device_call_recording_service`(通话录音)、`device_ai_session_service`(按键唤醒 AI,替代 BesAiSessionService;
`EmaiSessionOwner` 接口在这个文件里)、`device_stereo_recorder`(语音纪要页用的通话双路录 WAV)、`wav_writer`。
固件升级是 `modules/ota_upgrade/controllers/device_ota_controller.dart`(下载/看门狗/常亮)+ 各厂商的 `DeviceOtaPort`。
- **feature ↔ 字节的对应表**在 `BesDeviceSession.featureCommands`,由 `test/devices/bes_session_commands_test.dart` 逐条断言;
`BB 0A`/固件版本帧解析由 `test/devices/bes_protocol_test.dart` 用真机帧断言。**改协议先看这两个测试为什么红。**
- **分层边界由测试守着**:`test/devices/device_hub_boundary_test.dart` 会 fail 掉任何在 `lib/modules|core|data` 里
import `lib/devices/<vendor>/` 或出现 `BesBluetoothService`/`HolderDeviceService`/`package:bluetooth_manager` 的文件。
- **新接一种芯片**:① `lib/devices/<vendor>/` 写一个 `DevicePlugin` + `DeviceSession`(协议按厂商文档/真机实测,
加金测试);② `device_bootstrap.dart` 加一行 `_register(hub, XxxPlugin())`;③ `device_vendors.dart` 加 key;
④ `agent_module_defaults.dart` 的 `_vendorCategory` 加品类映射;⑤ 连接页按品类进 `device_category_view`。业务层零改动。
Smartcar 的接入设计见 `docs/Smartcar车载香薰-接入设计与落地文档.md`(它是杰理方案,`jl_opus` 已从工程删除,
接入时从 git 历史恢复或改用 recorder_holder 那套 Opus 解码)。
- **杰理链路已整体删除**(BleManager / device_jieli 的 Dart 适配 / 杰理扫描页 / 杰理 OTA / jieli_call_recorder)。
`device_jieli`、`classic_bluetooth`、`test`、`chat_api`、`ble_service`、`bytedance_speech`、`swift_sdk_local`
这几个插件 **Dart 侧零引用但原生侧被别的插件依赖**(recorder_holder→device_jieli 的 JLAudioUnitKit、
agent_service→test/ble_service/chat_api/bytedance_speech、azure_speech→classic_bluetooth、chat_api→swift_sdk_local),
所以目录和 pubspec 条目都保留着;`device_jieli` 改成了 iOS-only 插件。
- ⚠️ **iOS 本地插件的相对路径依赖**:这些插件的 Package.swift 用 `../../../<plugin>/ios/<plugin>` 引用兄弟插件,
而 Flutter 把插件软链在 `ios/Flutter/ephemeral/Packages/.packages/` 下,SPM 不解析软链,`rm -rf ios/Flutter/ephemeral`
之后必然报 "the package at 'ephemeral/<plugin>/…' cannot be accessed"。**`flutter pub get` 之后跑一次
`./ios/sync_spm_platform.sh`**,它会在 ephemeral/ 下补同名软链(幂等),顺便对齐平台版本。
### EaiRec 支架(`Echomeet`)的 BLE 现状 —— 2026-09-10 真机实测
协议文档 `local_plugins/recorder_holder/EaiRec_protocol.md`。**核对结论:UUID、帧头
(`AA` 请求 / `BB` 响应 / `CC` 上报)、CRC8(poly `0x31`)、通知使能顺序
(`ae02` → `abc2` → `FDA6`)、CCCD 串行队列、MTU 延时 1s 后 `requestMtu(512)`
—— 代码与文档逐条一致。** 唯一出入是「连接」那一步,已修(见下)。
⚠️ **在用的这台支架广播名是 `Echomeet`,不是 `EaiRec`**。切换名字的唯一入口是
`EchoMeetDeviceSdk.defaultNameFilters`(`sdk/lib/src/client.dart`),协议 §2.1 规定
App 不按 Service UUID 过滤、**只按设备名前缀筛**。名字对不上的表现是
**扫描页一台设备都不出现且不报任何错**。现在 `Echomeet` / `EaiRec` 两个前缀都留着,
设备刷成新名也不用改代码。
⚠️ **Android 上「连接」以前根本不发 GATT 连接**(已修):
`EchoMeetDeviceSdk.connect()` 只调 `registerBackgroundScan(mac)`,而它仅仅是
`savePairedMacAddress` + 注册一个 `SCAN_MODE_LOW_POWER` 的 PendingIntent 扫描,
一次 `connectGatt` 都不发;真正连接要等系统扫到广播 → `BleWakeReceiver`(还带 10s 去抖)
→ 拉起前台服务。于是「点连接」立刻返回 true、`waitConnected()` 干等 25s 超时 ——
**扫得到但永远连不上**。原生早就暴露了 `connectToDevice` → `BleService.connect(mac)`
→ `device.connectGatt`,只是 Dart 侧没调。现在注册完后台回连**立刻再直连一次**。
⚠️ **这台固件整个 GATT 只有一个服务**(2026-09-10 真机枚举,日志在
`BleService.onServicesDiscovered` 的「🔍 服务发现完成」那几行):
```
🔍 服务发现完成,共 1 个服务
└─ service 0000fda5 : 0000fda6, 0000fda7 ← 只有通道 B(TF 卡文件)
```
**没有通道 A(`0000abc0`/`abc1`/`abc2`)**,也没有 `ae00` 音频服务、没有 `0x180A`。
后果是链式的,且每一条的表象都不指向真因:
| 想做的事 | 协议里在哪条通道 | 这台固件上的结果 |
|---|---|---|
| 查电量(`0x04`) | 通道 A | **做不到** |
| 查固件版本 | 通道 A / `0x2A26` | **做不到**,固件升级页「当前版本」恒为 `--` |
| 实时音频上行 | 通道 A'(`ae00`) | **做不到**,录音 0 帧 |
| TF 卡列表/下载/删除 | 通道 B(`FDA5`) | ✅ 唯一可用的 |
⚠️ **原生那句 `发送命令失败: 设备未连接` 是误导**:链路好好的,`checkConn()`
挂在 `writeChar != null` 上,而 `writeChar` 来自 `abc0` 服务 —— 服务不存在,
它就恒为 null。**排查前先看服务清单,别去查连接。**
`HolderDeviceService._kickBatteryQuery()` 连上后带重试地问 4 次电量(服务发现
比 `connected` 回调晚约 700ms,头两次必然失败,这个重试是必要的),
问不到就打一条指名道姓的日志,不再假装是时序问题。
⚠️ **`DeviceCapability.battery` 仍然声明着**,因为这是协议能力不是这台样机的能力;
设备页按「快照里有什么显示什么」,拿不到就不显示那一行,行为是对的。
⚠️ **这台设备没有实时音频上行通道**(协议 §1 的通道 A' `0000ae00`/`0000ae02`)。
真机服务发现稳定打出:
```
📁 发现通道 B(TF 卡文件)服务 0xFDA5 ← 有
⚠️ 音频服务特征未找到 ← ae00/ae02 根本不存在
⚠️ 未找到下行音频特征
📦 未发现 0x180A 服务 → 旧固件,音频规格 LEGACY (16kbps/40B)
✅ MTU 协商成功: 512
```
所以 `DeviceSession.openMic(AudioRoute.mic)`(走 `audioFrames` 实时流)在这台设备上
**必然 0 帧**,`DeviceRecordingService` 会如实报「本次没有收到音频数据,删除空文件」。
**这台支架的录音是「录到 TF 卡、事后经通道 B 下载」**,不是边录边传。
### 设备端录音同步进语音纪要(2026-09-10 打通,真机验证)
上面那条已经落地了 —— **不是改 `openMic`,而是新加了一条厂商无关的通用链路**:
```
DeviceCapability.fileStore 设备契约新增的能力
DeviceSession.fileStore() → DeviceFileStore list / download / delete / listPushes / isDeviceRecording
lib/devices/holder/holder_file_store.dart 支架实现(包 RecClient 的通道 B)
lib/data/services/device_file_import_service.dart 业务层导入服务(厂商无关,常驻)
lib/data/utils/ogg_opus_duration.dart 时长解析(granulepos)
RecordingArchive.archive() 复用的归档入口(挪盘→建记录→写库→上传)
```
**新接一种「设备自己录、事后同步」的硬件**:实现 `DeviceFileStore` + 会话声明
`fileStore` 能力即可,导入服务零改动。
导入触发三处:设备上线后 5s、**设备推送文件列表**(录完自动推,「录完立刻出现在
列表里」靠这个)、`importNow()` 手动。进度显示在语音纪要首页顶部一条(
`meeting_home_view._buildDeviceSyncBanner`,复用已有的 `importing` 文案键)。
⚠️ **不删设备上的文件**,只在本地记「已导入」指纹(`设备id|文件名|字节数`)。
指纹带字节数是因为 8.3 短名会**回绕复用**(`MIC0001.OGG` 删了下次可能又叫这名),
只按名字记会把新录音当成已导入永远跳过。
⚠️ **原生 `writeRecCommand` 曾经用 `checkConn()` 判前置条件,而它要求
`writeChar != null` —— 那是通道 A(`abc1`)的写特征。** 支架没有通道 A,于是
通道 B 明明齐全(`0xFDA5` 发现了、CCCD 也写成功了)却一个字节都发不出去,
日志只有一句误导性的「通道 B 写入失败:未连接或未发现 0xFDA7 特征」。
已改用 `isRecChannelReady()`(只看它自己那两个特征)。**iOS 侧本来就是对的。**
⚠️ **`_initRecChannel()` 必须重试**(现在 6 次 × 800ms)。原来一次探不到就
`return`,而原生的 `connected` 回调比服务发现早约 700ms,于是必然第一次就失败 ——
后果不只是「这次没握上手」,而是**对时没做(文件 FAT 时间全错)、文件列表推送
没订阅(录完不会自动同步)、设备状态没拉**;偏偏 `ensureRecChannel()` 是每次
重新探的,手动拉列表反而能成,形成「列表能拉、录完却不会自动出现」的半死状态。
⚠️ **时长不能用播放器读,也不能按码率估**。文件是标准 Ogg Opus(`OggS`+`OpusHead`,
16kHz 单声道),但 `just_audio.setFilePath` 在 Android/iOS **两端都报 `Source error`**;
按 16kbps 估会差一倍(真机 38400 字节那条实际 9.16s,估成 19.2s)。
正解是读最后一个 Ogg page 的 **granulepos**(Opus 恒以 48kHz 计样本,RFC 7845 §4,
**除数写成 16000 会算成 3 倍**),减去 OpusHead 的 pre-skip。
金测试 `test/ogg_opus_duration_test.dart` 用的是真机那条录音的原始字节。
⚠️ **列表上的日期目前是「导入时刻」不是「录制时刻」,这是服务端限制**:
`echomeet_addrecord` 把 `Creationtime` 写死成 `time.Now().Unix()`
(`EchomeetAddRecordReq` 里没这个字段),而 `Synchrodata._synchrodata()` 会拿
服务端的值**整行 update** 覆盖本地库。客户端已经按「服务端接受」的形状把设备时间
送上去了(见 `lib/data/utils/recording_time.dart`),**那段不是死代码**,
服务端加上字段就自动生效。录制时间当前只活在标题里
(`holder_20260907_181224_MIC0049.ogg`)。
### iOS 上支架扫描起不来:进程里第二个申请状态恢复的 central 拿不到电源状态(2026-09-11 iPhone 12 / iOS 26 真机定性)
表象:扫描页永远「正在搜索」、一台设备都不出现,syslog 里原生每次都打
`❌ 蓝牙未就绪,无法开始扫描 (central.state=4)`(4=poweredOff),而同一进程、
同一个插件里另一个**不带 restore identifier** 的 central(`ClassicBluetoothHelper`)报 5=poweredOn。
根因:`local_plugins/ble_service`(Dart 侧零引用的僵尸插件,只因 `agent_service` 的原生
依赖还链在工程里)在 `GeneratedPluginRegistrant` 里排在 `recorder_holder` 前面,注册时就
建了一个带 `CBCentralManagerOptionRestoreIdentifierKey` 的 central。**同一进程里第二个
申请状态恢复的 central 恒为 poweredOff** —— 与 id 是否同名无关(两边原来都叫
`"BLEAssistantCentral"`,改成不同名之后依然是 4,改掉 ble_service 那边的 restore 选项
之后支架立刻变 5)。修法是**把僵尸插件的 restore identifier 去掉**,名额留给真正在用的支架
(`agent_service` 只调它的 `closeCodec()`,不依赖状态恢复)。
支架自己的 id 顺手改成了 `"com.recorder.holder.central"`,`startScan` 失败日志带 `central.state=N`,
状态跳变日志提到 `.default` 级别。
⚠️ 别按上一轮的记录去查「两个 CBCentralManager」本身——多个 central 并存是合法的,
问题只在「带 restore identifier 的」只能有一个真正工作。也别去查权限:
`requestBluetoothPermission` 在 iOS 插件里虽是 `FlutterMethodNotImplemented`,
但 `HolderDeviceService.requestPermission()` 在非 Android 上直接放行,走不到那条。
iOS 上 TF 卡导入这条链与 Android 共用同一套 Dart(`DeviceFileImportService` /
`HolderFileStore`);原生侧 `writeRecCommand` 本来就只看通道 B 自己的两个特征,
`FDA6` 的 notify 走 `discoverDescriptors → setNotifyValue`,`recData` 事件形状与 Android 一致。
自动回连在 iOS 是原生对已知外设发 pending connect(设备一广播就接上,不需要扫描)。
⚠️ 设备 FAT 时间戳在 RTC 对时之前是垃圾值(真机那张卡上有一批 `2018-08-10`),
所以只采信「2020 年之后且不在未来」的值,其余退回当前时刻 —— 显示成「刚导入」
好过让记录沉到列表最底下再也找不到。
⚠️ **反复热重装会留下僵尸 GATT 客户端**:现象是连上了但服务发现不回调、
`checkConn()` 因 `writeChar == null` 失败、所有命令返回 `发送命令失败: 设备未连接`,
约 30 秒后断开重连,如此循环;`bt_stack` 日志里能看到**另一个 `gatt_if`** 每秒在收
这台设备的 notification。`adb shell am force-stop` 后冷启动即恢复。排查支架问题时
先冷启动一次,别对着这种状态查代码。
⚠️ **绑定接口不要传 pid**:服务端 `UserBindDeviceReq.pid` 是 `int32`,而支架能填的
`advertisementHex` 是十六进制**字符串**(常常还是空串),传上去必然
`readUint32: unexpected character` → `code:22` 整个请求被拒。恒玄那条链路就是不传 pid 的。
改对之后服务端正常受理,返回 `{"code":2005,"msg":"设备未登记"}` —— 那是**数据问题**:
这台的 MAC 要先由后台导入 `device_mac` 表(协议 §2.3)。绑定失败不阻断连接与录音。
MAC 导入后已实测打通(2026-09-10):`user_binddevice` 返回
`{"code":0,...,"device":{"id":32,"productid":45601,...}}`,设备管理页正常列出。
⚠️ **绑定登记不能只挂在 `connect()` 上**:那是扫描页手动连接才走的路径,
**自动回连不经过它**。设备一旦被记住,之后每次都是原生回连上线,绑定接口
一次都不会打 —— 换账号、或后台补录完 MAC 之后,设备管理页会一直空着。
现在连接状态变 `connected` 时也会调一次(`bindConnectedToServer()`),
成功的 MAC 记进进程内 `_bound` 去重,**失败的不记**,下次连上还能重试。
⚠️ **固件升级页按名字找不到产品**:支架广播名是 `Echomeet`,产品表里叫 `EaiRec`
(pid 45601),两者互不包含,`DeviceOtaController._findProduct` 的「名字互相包含」
匹配必然落空 → 恒报「未找到设备对应的产品配置」,一次服务端读取都不发生。
现在**先用绑定关系里的 `productid`**(服务端从 `device_mac` 命中行读出来的权威值),
名字匹配退为兜底;`User.instance.devices` 为空时页面会自己补拉一次 `user_getdevices`。
### Agent 模块注册表 `lib/modules/module_registry.dart`
- 一个能力模块 = `lib/modules/<name>/module.dart` 导出的一个 `AgentModuleDescriptor`(`lib/modules/module_descriptor.dart`):
卡片元数据 + `pages`(自带路由)+ `entryRoute`/`onLaunch`(卡片点击)+ `requiredCapabilities`(依赖的设备能力)
+ `directives`(能落地的端侧指令)+ `translations`(模块自带文案,与全局语言表合并)+ `registerServices`。
- **新增一个模块只做两件事**:建目录写 `module.dart`;在 `ModuleRegistry.all` 加一行。
路由(`AppPages.pages` 末尾 `...ModuleRegistry.pages`)、卡片跳转(`module_card.dart` 的 `launchModule` 走
`desc.launch()`)、指令(`AssistantDirectiveService.registerHandlers`)、文案(`AppTranslations.keys` 走
`ModuleRegistry.mergeTranslations`)都不用再改。`test/module_registry_test.dart` 守着 id 唯一 / 有跳转 / 路由已注册 / 文案有落点。
- `AgentModules.all`(data 层)由 `ModuleRegistry.install()` 在 InitialBinding 注入,data 层不反向依赖 modules 层。
- 路由表现在只放核心页面;`test/routes_test.dart` 要求每条 `Routes` 常量都已注册且有跳转方(死路由会红)。
`Routes.OTA_UPGRADE` 已改名 `Routes.otaUpgrade`,`Routes.devices`(杰理扫描页)已删,用 `Routes.deviceCategory`。
### 本轮一并删掉的东西(别去找)
`modules/agent`(老 Azure→火山对话页,9000 行,路由早已不可达)、`modules/realtime|timbre|usage|ble|eq_settings|speech_test|opus_test|music|qqmusic`、
`data/services/{kouzi_ai_service,open_ai_service_adapter,ble_manager,bluetooth_manager,face_to_face_ble_service}`、
`data/providers/agent_provider.dart`、`local_plugins/{jl_opus,realtime,open_ai_service,volcano_speech,asr_tr_tts_manager}`、
根目录的 `test.json/test2.json/*.py`。`auth_interceptor.dart` 不再把签名原文 print 出来,签名 key 可由 `.env` 的 `API_SIGN_KEY` 覆盖。
`analysis_options.yaml` 把 unused_* / dead_code / avoid_print 提成 warning,CI 用 `dart analyze --fatal-warnings lib`。
## Client 移动端约定(Flutter,2026-08-28 并入 monorepo)
`apps/client` 从 `/Users/yunyanzhineng/work/eaimar_client-main` 搬入,**未带原 `.git`**,历史留在原仓库。
@ -208,6 +461,8 @@ ym 首次部署容器一直叫 `starpivot-app` 正是如此)。service 名变
- `dotenv.env['X']` = `apps/client/.env`,本机/渠道级,编译进包;
- `AppConfig.env('X')`([appconfig.dart](apps/client/lib/data/models/appconfig.dart))= 后端
`user_getappconfig` 下发的配置(env / agents / mcps / products),由 **console 后台**维护。
⚠️ 2026-09-12 起 `env` 里的**凭据来自 `svc_config` 的 `env_key`**,不再来自 `global_config`;
业务库 `config` 表只剩应用自有的业务参数。详见下文「凭据归一」。
- 客户端 `UserGetAppConfigResp`、`DBAgent`、`DBProduct` 等模型是**手写镜像**服务端
`pb.UserGetAppConfigResp`([user_msg.proto:168](apps/proto/user/user_msg.proto#L168))的,
**没有代码生成把两边绑在一起**。改 proto 字段后要手动同步
@ -293,7 +548,7 @@ ym 首次部署容器一直叫 `starpivot-app` 正是如此)。service 名变
链路:协议层解析 → [assistant_directive_service.dart](apps/client/lib/data/services/assistant_directive_service.dart)
分发 + 落本地记录 → 「代办」页展示(`CalendarController._rebuildEvents`)。
App 内对话页与耳机唤醒(`BesAiSessionService`)两条链路都往同一个 service 送,按指令 id 去重。
App 内对话页与耳机唤醒(`DeviceAiSessionService`,原 `BesAiSessionService`)两条链路都往同一个 service 送,按指令 id 去重。
抓包实测(原始帧见 `test/assistant_directive_test.dart` 的用例,全是真实 payload):
@ -638,7 +893,7 @@ Android/iOS 两端同改,改动都在各自的 `AliyunBailianE2EHelper` 的 `h
充电状态得从别处读(那 `& 0x7F` 这条就是错的,且会在充电时把电量算错)。
**给充电盒插电、看 L/R 原字节会不会 >127** 就能定论,做之前别把这条当已验证事实。
⚠️ 此前电量一直不显示,是 [bes_bluetooth_service.dart](apps/client/lib/data/services/bes_bluetooth_service.dart)
⚠️ 此前电量一直不显示,是 [bes_bluetooth_service.dart](apps/client/lib/devices/bes/bes_bluetooth_service.dart)
里**四个错叠加**,且每一个都不报错、只表现为「界面上那块干脆不出现」:
1. **请求码写成了响应码**:`queryBattery()` 发 `AA 0A`,把耳机的响应号当成了请求号。
固件不认,静默无回包。正确请求码是 `AA 09`(见上表),与响应码 `0x0A` 是两个不同的号。
@ -678,7 +933,7 @@ UI 在 [device_tab.dart](apps/client/lib/modules/main_tab/views/tabs/device_tab.
所以在此之前,只用恒玄耳机的账号打开设备管理页**永远是空列表**,
看着像「接口没打通」,其实是两端都通、中间没人调。
挂点选在 `BesBluetoothService._listen()` 的 `event == 2` 分支,**不是扫描页**——
挂点选在 `BesBluetoothService._listen()`(现在在 `lib/devices/bes/`)的 `event == 2` 分支,**不是扫描页**——
被动回连(原生 ACL 广播触发)不经过任何页面,那条状态流才是所有连接路径的必经之地。
⚠️ **服务端对经典蓝牙耳机是「按 MAC 反查授权码」**:`api_binddevice.go` 里
@ -742,7 +997,7 @@ redis-cli -a <pw> -n 1 HGET '<REDIS_KEY_PREFIX>:cache:product' '<pid>'
### 设备确权:连上就校验 MAC,不通过直接断(2026-09-02 加)
上节那次「登记」的返回值原先是**丢掉的**——绑不上只在日志里留一行,设备照用不误。
现在把同一次调用升级成了闸门:[bes_device_auth.dart](apps/client/lib/data/services/bes_device_auth.dart)
现在把同一次调用升级成了闸门:[bes_device_auth.dart](apps/client/lib/devices/bes/bes_device_auth.dart)
的 `BesDeviceAuth`(由 `bes_device_binding.dart` 改名而来,旧类名已不存在)。
**服务端没动,也不需要动**——`user_binddevice` 对经典耳机本来就是按 MAC 校验的
@ -849,6 +1104,66 @@ data 置 null 后 **resolve(不抛)**,网络层出问题才抛 `DioExcepti
**导入**模式的 MAC 是厂商已烧录的(如 `F0:A9:...`,F0A9 是 OUI 不是 PID),
别再拿 MAC 前缀去推产品。
### 产品上的「设备类型」已废弃(2026-09-08)
`DBProduct.devicetype`(后台 产品管理 → 新增产品 那个下拉)**已删除**:列删了、表单去掉了、
服务端不再读也不再写。原因是上一节那次事故的根:它没有任何权威定义,枚举还把
「连接方式」(经典/BLE单双端) 和「产品形态」(录音笔/眼镜/卡片) 混在一根轴上,
客户端早就另建了 `DeviceCategories` 做品类分类,这个字段只剩误配来源。
改动落点:
- `db.proto` 的 `DBProduct.devicetype` 降成 `gorm:"-"` 的**纯下发兼容字段**,恒为 0。
⚠️ **不能整条删**:已发布的 App 包按「非空 int」解析它,字段一旦不下发,
整个 `user_getappconfig` 就抛 TypeError(产品表、设备列表全废)——与 `factoryid`
同一个坑、同一套办法,见 [compat.go](apps/services/modules/user/compat.go)。
`module_test.go` 的 `TestDevicetypeNotPersisted` / `TestDevicetypeStillEmittedInJSON` 守着这两条。
- 存量列由启动迁移 `ALTER TABLE product DROP COLUMN IF EXISTS devicetype` 清掉
(console `registry.go` 与 api `model.go` 两处建表点都有,幂等)。**不可逆**。
- `api_binddevice.go` 不再把它抄进 `userdevice`。`DBUserDivice.devicetype` 这一列**暂时保留**:
存量行原样不动,新绑定的行为 0。
⚠️ 客户端还没跟着改(本次刻意不动),两处已知退化:
1. 「我的设备」页新绑的设备显示成「未知设备」(`_MyDeviceCategory.fromValue(0)`)。
2. `BesOtaUpgradeController._findProduct` 的兜底分支(「唯一的 `devicetype==1` 产品」)
永远命中不了,只剩**设备名与产品名互相包含**这一条匹配路径。当前 Echo-one 靠名字能匹配上,
所以 OTA 仍可用;**改产品名或接新设备时会踩到**。正解是改用绑定关系里的
`UserDevice.productid`(服务端从 `device_mac` 命中行读出来的权威值),别再猜产品。
### 会员与算力:VIP 管准入、算力管计量(2026-09-11 落地一期)
设计文档 [docs/会员权益与用量统计-设计与开发文档.md](docs/会员权益与用量统计-设计与开发文档.md)。两层权益各管一件事,
**所有判定与记账走 [comm/compute.go](apps/services/comm/compute.go),别各写一份**:
| 层 | 管什么 | 来源 | 判定/记账入口 |
|---|---|---|---|
| VIP | 功能准入:无 VIP 时 `user_translate`、`echomeet_starttask` 返回 `VipRequired(5302)` | 自购(`user.vipexptime`,**只记自购**)+ 设备赋予(`device_mac.usedtime + vipdays`) | `comm.ResolveUserVip` |
| 算力 | 用量计量:翻译/会议/AI 对话按后台系数折算,先扣设备 `computegrant-computeused`、再扣 `user.computebalance` | 设备赠送(批次快照)+ 充值/后台调整 | `comm.ApplyComputeUsage` |
- ⚠️ **设备赋予的 VIP 不写用户表**。以前绑定礼把天数累加进 `vipexptime`,与自购混成一个数解绑拆不开;
现在设备行存激活日 + 天数,判定时按「当前绑定着且未到期」算,解绑什么都不用做。
`user_getinfo`/`user_binddevice` 回给客户端的 `viplv/vipexptime` 是**合并后的有效值**(老客户端不改代码也能看到设备 VIP),库里仍只记自购。
- **激活日 = 首绑时写的 `device_mac.usedtime`**(换绑/接管不覆盖),设备 VIP 期按日历天连续走、解绑不暂停、没有定时任务。
- **三个旧额度桶(`tradeintegral/meetintegral/aichatintegral`)已停用**:不再作为闸门、不再发放。存量余额由 home 启动时
`migrateLegacyBucketsToCompute` 按系数一次性折进 `computebalance`(config 表 `COMPUTE_LEGACY_MIGRATED` 打标记),旧列保留不清。
老商品 / 公码 / 后台赠送里配的分钟数、次数,发放时用 `comm.LegacyBucketsToCompute` 折算。
- **算力闸门 `COMPUTE_GATE` 默认关**:余额不够也放行,差额记流水 `computeover`。⚠️ 开闸门前必须先做预扣——现在 `user_usages`
是用完才上报,没预扣的闸门形同虚设。换算系数 `COMPUTE_RATE_*` 与闸门存业务库 `config` 表(后台「会员与算力」页),
服务端读取带 30s 缓存(`comm.LoadComputeRates`)。
- **客户端只上报原始量**(翻译秒、会议秒、AI 输入/输出字符数),不算算力;`user_getcompute` 一次返回合并 VIP + 算力明细 + 系数。
AI 字数存的是 `user_statistics.aichatuptoken/aichatdowntoken`——列名带 token 但**存的是字符数**,与模型 tokenizer 无关。
- **通话翻译走端到端直连服务商,服务端拦不住**;唯一手段是 VIP 过期时不下发 `SvcCatAST(4)` 类凭据(`filterSvcsByVip`)。
凭据启动时拉一次,VIP 中途过期到下次拉配置之间的窗口已接受。
- **运营参数也在同一张 config 表、同一个后台页**:`NEWUSER_GIFT_VIPDAYS` / `NEWUSER_GIFT_COMPUTE`(开户礼)、
`VIP_WARN_DAYS` / `COMPUTE_WARN`(客户端提示阈值,随 `user_getcompute` 下发)。
⚠️ 这四项**允许配 0**(= 不送 / 不提示),与换算系数不同——解析只在「键不存在 / 非数字」时回默认,
解析出 0 就用 0;前端读取要用 `??` 不能用 `||`。开户礼的参数在 `api_sgin` 取一次传给发放与流水两处,
保证两者用同一份快照。
- **算力包商品**:`UsageType.Compute = 5`。旧的 1/2/3(翻译/会议/智能体资源点包)不再新建,
存量商品仍可卖,发货时 `grantOfAppGoods` 会把旧资源点按换算系数折成算力。
- 后台:批次只配 VIP 天数 / 等级 / 算力(翻译/会议分钟数两列不再读写);用户余额只能按**增量 + 备注**调(`api_adjustusercompute`,
写 `AdminAdjust` 流水),不能改绝对值;`api_getcomputestats` 的用户 Top N 是额度闸门取消后唯一能发现滥用的地方。
- 二期(带存储设备:禁止接管、清空标签 `erasestate/lastuid`、后台强制解绑)只在设计文档里,代码未动。
### 两种赠送:开户礼按人、绑定礼按设备(2026-09-02 收紧)
额度有两个来源,**判据完全不同,别混**:
@ -868,10 +1183,12 @@ data 置 null 后 **resolve(不抛)**,网络层出问题才抛 `DioExcepti
([api_unbinddevice.go](apps/services/modules/user/api_unbinddevice.go),只清 `Uid`),
status 一旦 0→1 就回不去,所以「绑了解、解了绑」拿不到第二份。
⚠️ **`user.Isgiveaway = true` 那行不能跟着一起删**:厂家公码
(`api_bindauthcode.go`)拿它当防重领标记(见 `model_user.go` 的说明),
删了会让绑过设备的用户还能再领一次公码。它现在的含义是「这个用户领过某种赠送了」,
**不再**参与绑定礼的发放判断。
⚠️ **`user.Isgiveaway = true` 那行仍然要写,但理由已经变了**:它原先是厂家公码
(`api_bindauthcode.go`)的防重领标记,而**厂家公码已于 2026-09-12 整体下线**
(页面、接口、`factory_public_code` 表全删),这个字段因此**没有任何读取方**了。
保留写入是因为它是 `DBUser` 上的存量列、老客户端仍会解析它,删字段会让已发布的 App
整个 `fromJson` 抛 TypeError(同 `factoryid`/`devicetype` 那个坑)。
它**不**参与绑定礼的发放判断。
⚠️ 赠送额度三个桶**不同构**:`Tradeintegral`/`Meetintegral` 是**秒**(配置填的是分钟,
要 ×60),`Aichatintegral` 是**次数**,分钟在这个桶里没意义。
@ -979,6 +1296,91 @@ google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel
得先把设置页改成三档(浅色/深色/跟随系统)——存储层的 `themeModePreference`
已经支持 `system` 值了,缺的只是 UI。
### Smartcar 车载香薰(杰理方案):BLE 必须先过杰理 RCSP 认证(2026-09-10)
设备连上后固件要求在杰理服务 `AE00/AE01/AE02` 上跑 RCSP 认证(闭源 `JL_BLEKit` 的 `JL_Assist`),
**认证通过前设备屏幕显示 BLE 未连接、`ABC0` 私有通道的命令也不应答**。第一版用 flutter_blue_plus
纯 Dart 只订阅 `ABC0`,真机上"扫得到、连得上、设备说没连"。设计与踩坑记录见
[docs/Smartcar车载香薰-接入设计与落地文档.md](docs/Smartcar车载香薰-接入设计与落地文档.md) 5.5。
- 链路在原生插件 `apps/client/local_plugins/device_smartcar`(自持 GATT,同一条连接复用 AE00 + ABC0),
Dart 侧 `lib/devices/smartcar/` 只做组帧/广播解析/DevicePlugin 适配。
- ⚠️ iOS 的 `JL_BLEKit` 是**动态库**,只能有一份:由 `device_jieli` 的 `Package.swift` 以 `jl-blekit`
产品暴露,`device_smartcar` 按路径依赖。再 vendor 一份会「Class implemented in both」。
- ⚠️ Android 若搬 lumi 的认证(`jl_rcsp`/`jl_watch`/`jl_bluetooth_connect` aar),**不要再放
`jl_audio_decode`**——`device_jieli` 已带,重复即 Duplicate class。
- `ready` = 认证通过,不是 GATT 连上;失败原因(无 AE00 / AE02 通知失败 / 固件拒绝 / SDK 超时)
必须原样报到扫描页,这条链路两次排查都栽在"失败不可见"。
- ⚠️ **认证通过还不够**:屏幕要亮「BLE 已连接」,握手得依次发 `0x10 蓝牙连接成功`
(`AA 10 02 <3=iOS|2=安卓> 00`)、RCSP 收尾(设时间 / `cmdTargetFeature` / `cmdGetSystemInfo`,
lumi 的 JL SDK 会自动发,我们自持 GATT 得自己发)、`0x17` 时间同步。只发 0x10 不亮,全套发了才亮,
尚未二分是哪一步起作用(TODO(smartcar-handshake))。见设计文档 5.6/5.7。
- **单活动设备**:`DeviceHub._attach` 把新上线会话插到最前并断开其它厂商会话(切设备时旧设备断链、设备页图片跟着换)。
- **A2DP / opus 上行 / 设备唤醒(2026-09-11)**:
- `classic_bluetooth` 新增 `connectDevice(mac)` / `isA2dpConnected(mac)`,**纯增量**,
没动任何现有方法 —— 依赖它的 `azure_speech`(iOS `MicrophoneCapture` 用 `ClassicBluetoothHelper.shared`
选麦克风输入)行为不变。⚠️ **iOS 上 `connectDevice` 恒返回 `unsupported_ios`**:
Apple 没有发起经典蓝牙配对的公开 API,只能引导用户去系统设置。
- 设备麦克风是 **opus,与 RCSP 命令共用 AE02**,靠 `FE DC BA 80 01 00 8E |seq| 04` 这个头区分,
`seq` 循环去重。iOS 解码用 `ble_service` 的 `opus-binary`、Android 用
`com.jieli.audio:jl_audio_decode` —— **两个都不要再 vendor 第二份**。
排查看 `audioStats()`:`framesIn`/`framesDup`/`pcmBytesOut` 能立刻分清是没收到、被当重复丢了、还是没解出字节。
- `openSpeaker(ai)` 是**手机扬声器**(`PcmStreamPlayer`),下行不经 BLE;所以刻意不声明 `speakerDownlink`。
- **设备唤醒接哪个助手**:`AgentModule.bailianAppId`(在 **data 层基类**,不是 modules 层的
`AgentModuleDescriptor` —— `DeviceAiSessionService` 在 data 层,**data 层不能反向依赖 modules 层**)。
车载香薰因此接 Smartcar 车载助手而不是 EMAI;耳机/支架没声明,行为不变。
- **命令表已复核(2026-09-11 样机)**:`0x05` = 手机音乐播放状态、`0x07` = AI 状态
(`BleConst.kt` 里 `CMD_CLOSE_AI_DIALOG`/`CMD_CONTROL_MOTION` 两个名字都是错的,判据是 lumi
运行时真正发的字节);`0x11` 双向共号不冲突。`0x15` 汇总帧已写成金测试,偏移由
`[4]`=电量与 `0x04`、`[5..7]`=版本与 `0x01` 两处交叉验证钉死。
- OTA:原生 `SmartcarOta.swift`(lumi 的 cmdOTAData + 0xfc stage2 扫描回连),`JL_OTALib` 由 `device_jieli`
以 `jl-otalib` 产品共享;**未经样机验证**,stage2 广播名未知。
- **Android 侧(2026-09-10 补齐,未上真机)**:`SmartcarGatt.kt`(自持 GATT + **操作队列**,Android
一次只允许一个在途 GATT 操作)、`SmartcarRcsp.kt`(`jl_rcsp` 的 `RcspAuth`+`RcspOpImpl`,认证在
**MTU 协商完**之后起,带 6s 兜底)、`SmartcarOta.kt`(`jl_bt_ota`)。
⚠️ **`jl_bt_ota` 只能走 maven 坐标**(`ble_service`/`recorder_holder` 已依赖,解析到 1.11.0),
vendor 第二份直接 Duplicate class;1.11.0 没有 `startBLEScan` 可 override,且
`BluetoothOTAConfigure` 的开关只有 setter(Kotlin 属性赋值编不过)。`jl_rcsp` maven 上没有坐标,
只能解包成 jar+jniLibs vendor(`android/tool/unpack_aars.sh`)。
⚠️ 这条链路不走 flutter_blue_plus,**运行时权限要自己申请**(`ensureReady()` 里做),
漏了的表现是安卓上永远扫不到设备且不报错。
- 2026-09-11 与 iOS 二次对齐:Ogg 拆包、`audioStats` 键、认证后 RCSP 收尾(`SmartcarRcsp.runPostAuth`,
用 `CommandBuilder` 四条命令逐条对齐 iOS)都已同步;方法/事件通道两端逐字一致。
- A2DP 用哪个 MAC 在安卓上要看 `classicMacCandidate`:广播 MAC == BLE MAC 时按 lumi 规则首字节减一,
否则原样用。**未经安卓真机验证**。
- 广播里的 MAC 是厂商数据 CID 之后 6 字节**原样**(`05 85 a9 1a b3 21 e4 dd 00`),协议文档那套
26 字节/XOR 0x5A 布局这台固件没发。
### iOS 在 Xcode 里构建报「requires minimum platform version 16.0 ... but this target supports 13.0」
**不是代码问题,是 Flutter 的 SPM 集成和 Xcode 的构建顺序对不上。跑
[apps/client/ios/sync_spm_platform.sh](apps/client/ios/sync_spm_platform.sh) 即可,几毫秒。**
Flutter 3.29+ 给 iOS 插件生成一个中间包
`ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift`,所有插件挂在它下面。
它**生成时的平台版本写死是 Flutter 自己的最低版本 iOS 13.0**,而本项目有 13 个本地插件声明
`.iOS("16.0")`(agent_service / azure_speech / ble_service / chat_api / chat_storage /
classic_bluetooth / jl_opus / location_service / music_service / realtime / recorder_holder /
swift_sdk_local / user_portrait)。SPM 不允许依赖的平台版本高于上层包,于是报错。
报哪个插件名取决于扫描顺序,**换一个插件名不代表换了问题**。
Flutter 自己有补救 —— `SwiftPackageManager.updateMinimumDeployment()` 读 Runner 的
`IPHONEOS_DEPLOYMENT_TARGET`(16.0) 把生成包改写上去 —— 但它**只在 `flutter build` / `flutter run`
里执行**(flutter_tools 的 `ios/mac.dart`)。Xcode 直接构建时 SPM 解析发生在所有 Flutter 步骤之前,
看到的还是 13.0。所以现象是「`flutter build ios` 能过、Xcode 里点构建就炸」。
⚠️ **这不是一次性问题**:`flutter pub get` / `clean` / `install`、改完 pubspec 的自动 pub get,
任何重新生成插件清单的操作都会把它打回 13.0。
⚠️ **别改插件的 `Package.swift` 去迁就它**:降了 user_portrait 只会让 Xcode 转头报 agent_service,
13 个要挨个降,而且多数插件确实用了新 API,降了编不过。
⚠️ **也别手改那个生成文件**:它头上写着 `Generated file. Do not edit.`,下次 pub get 就被覆盖。
`sync_spm_platform.sh` 做的就是 Flutter 那一步,但可重复执行、不依赖 `flutter` 命令
(Xcode pre-action 的 PATH 很干净,调 flutter 常找不到)。它另有一条守卫:
插件要求**高于**工程最低 target 时直接报错并点名是哪个文件 —— 那种情况脚本救不了,
只能抬工程 target 或降插件声明。`--check` 只检查不改写,可用于 CI。
### 必备本机文件
- **`.env` 是硬性前置**:`main.dart` 启动第一步就 `dotenv.load(fileName: ".env")`,且 `.env` 同时被
@ -1036,9 +1438,130 @@ google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel
`asr_tr_tts_manager` 在 pubspec 里**是注释掉的**(缺 deepvoice 的 `audio_recorder` 插件,打开会 Gradle 报错)。
- `doc/` 下 41 个语种目录是国际化文案,`tools/i18n_audit.py` 做占位符校验。
## 品牌商与厂家公码整体下线(2026-09-12)
后台「生产管理」组现在**就是六项,不多不少**:芯片管理 / 方案商管理 / 工厂管理 /
产品管理 / MAC生成管理 / 渠道商管理。品牌商管理(`/factory`) 与 公码管理(`/code`)
是**彻底删除**,不是 2026-09-02 那次的「注释掉但页面接口都还在」。
| 删掉的 | 范围 |
|---|---|
| 页面 | `admin/app/pages/factory.vue`、`code.vue` |
| console 接口 | `api_getbrands`/`addbrand`/`updatebrand`/`delbrand`、四个 `*factorypubliccode` |
| api 模块 | `api_{add,del,get,gets,update}brand.go`、四个 `api_*factorypubliccode.go`、`user/api_bindauthcode.go` |
| 表 | `brand`、`factory_public_code` —— **console 启动时 `DROP TABLE IF EXISTS`**,不可逆 |
| 角色 | 品牌商账号 `identity=3`(`pb.Identity_Agent`)不再接受新建 |
⚠️ **各业务表的 `brandid` 列刻意保留,没有删列**:`product.brandid` 被
[compat.go](apps/services/modules/user/compat.go) 用来给老 App 回填 `DBProduct.factoryid`,
不下发会让已发布客户端整个 `fromJson` 抛 TypeError;`production_batch.brandid`、
`device_mac.brandid` 是历史批次留痕,`payorder.src_brandid` 是订单分成溯源。
这些列今后只写 0,不再指向任何档案。同理 `DBUser.Isgiveaway`、`console_account.brandid`
与 `balance_*` 三列也都留着。
⚠️ **`identity=3` 的存量账号必须处理,这是安全问题不是洁癖**:这类账号原先靠
`acctScope.brandId` 把数据收敛到自家品牌,brandId 随品牌商一起删掉后,`scopeOf` 只剩
apps/products/regions/channelid 四维,而它们这四项**本来就是空的**——按「非空才限制」
的语义会当场从「只看自己品牌」变成**看全平台**。所以
[migrateBrandAccountsToOperator](apps/services/modules/console/model_account.go)
在 console 启动时把它们 `identity=4 + enabled=false`:降级救不了(运营空绑定同样不限),
必须同时禁用,把可见性交回超管重配。
⚠️ **api 模块不能再 `CreateTable(comm.TableBrand)`**:console 启动 DROP、api 启动 CREATE,
结果取决于谁后启动。两边必须同时改。
一并下线的附带项:
- 「官方渠道」不再产出新的(它原先随品牌商自动创建)。存量 `channel.official=true` 的行
仍禁止删除——上面挂着历史批次与结算。
- MAC生成的产品通知邮件收件人少了「品牌商邮箱」这一路,现在是 **方案商 → 渠道商**。
原先只靠品牌商邮箱收通知的,要给方案商或渠道商补邮箱。
- 后台账号页的「代理充值/调整资源点」入口删了(没有代理就没有充值对象)。
后端 `/console/api/accounts/topup` 与 `balance_*` 三列都还在,存量余额不动。
- 客户端 `Api.bindauthcode` 删除——它在 App 里**零调用方**,本来就没有入口。
## 凭据归一:后台只剩一个「服务与环境配置」(2026-09-12)
后台「服务配置」分组从 11 项收敛到 6 项,凭据只有 **一个** 出处。逐项核过库之后的处置:
| 原菜单项 | 处置 | 依据 |
|---|---|---|
| 全局环境配置 | **下线**(页面只留重定向,表保留待人工 DROP) | `global_config` 473 行按区域整份复制凭据,且**悄悄覆盖**业务库 `config` 表的同名键 |
| 应用环境配置 | 并入「服务与环境配置 → 应用参数」标签页 | 凭据搬走后只剩应用自己的业务参数 |
| 第三方服务配置 | 改名「服务与环境配置」,成为凭据唯一出处 | — |
| Agent 配置 / 通话翻译配置 | **删除**(页面 + console 接口) | `agent_config`、`call_translate_rule` 在测试机与正式机上**都是 0 行**;通话翻译选路全在端侧 `language_manager.findBestMatchingProvider` |
| 会议记录服务 | **删除页面**,⚠️ 数据与运行时保留 | 见下方警告 |
⚠️ **两张扁平 env 表的坑(这是本次改造的起因)**:同一个
`AZURE_SPEECH_REGION` / `OPENAI_API_KEY` / `VOLC_OPENSPEECH_APP_ID` / `XUNFEI_ASR_*`
在 `global_config` 与业务库 `config` 里**值不一样**,而合并时**全局那张赢**。
于是运营在「应用环境配置」里改半天没有任何效果,且不报任何错。别再把凭据写进 `config` 表。
**现在的下发链路**([svcresolve.go](apps/services/modules/user/svcresolve.go) 的 `appEnvForUser`):
```
env = 业务库 config 表(应用自有业务参数) ← svc_config 按字段的 env_key 覆盖
```
- `comm.SvcField` 多了一个 **`EnvKey`**(后台字段表里的「下发键名」列):非空时该字段的明文值
同时以这个名字出现在 `user_getappconfig` 的 `env` 里。**已发布的客户端只会读 `env`**,
靠它继续供货、无需发新包;新客户端直接读结构化的 `thirdsvcs`。
- ⚠️ **改 `env_key` 等于改已发布客户端读的键名**,改一个字母老包就拿不到凭据,
且只表现为「某个能力鉴权失败」,不报配置错误。客户端读取点搜 `AppConfig.env(`。
- ⚠️ **env 覆盖层与 `thirdsvcs` 走同一份 VIP 过滤**(`filterSvcsByVip`)。分开做的话,
VIP 过期的用户虽然拿不到 AST 服务条目,却仍能从 `env` 里读出同一套通话翻译凭据,
那道闸门就形同虚设。
- 同一作用域内 `env_key` **不允许重名**(console 的 `checkSvcEnvKeys` 在保存时拦):
重名时下发结果取决于服务 id 排序,表现为「凭据时对时不对」,改哪边都像没生效。
**一次性迁移**在 [migrate_envtosvc.go](apps/services/modules/console/migrate_envtosvc.go),
console 每次启动跑、幂等(只建不存在的服务、只填空着的字段值,运营改过的值不会被刷回去):
- 基础值取**中国区**现值(→ 区域 0 → 业务库 `config` 兜底),**只有与基础值不同的区域**才写
`svc_region_override`。实测 31 个键里 27 个是 14 个区域同值复制,不收敛就等于换个地方继续乱。
- 落点:`stt_azure` / `mt_azure` / `ast_alibaba` / `sts_bailian` / `stt_iflytek` /
`ast_bytedance` / `llm_doubao` / `mt_volcano`。`ALIBABA_OSS_*` 与 `COMMENT_*` 确认无人读取,直接丢弃。
- 顺手修了两行**填错列**的历史数据(`group` 里是键名、`key` 里是值、`value` 空),
正是它让 `ALIBABA_BAILIAN_APP_ID` / `WORKSPACE_ID` 恒为 null、客户端一直用代码里写死的兜底值。
- ⚠️ **迁移不删 `global_config` 的行**(不可逆的线上凭据,留着是唯一回退依据,同
`migrateLicenseToDeviceMac` 的取舍)。业务库 `config` 里**已确认迁走**的键则会删除。
确认新链路跑稳后由人手工 `DROP TABLE global_config;`。
### svc_config 的四个坑(2026-09-12 阿龙测试环境实测踩出来的,都不报错)
这四条对**任何**改第三方服务配置的人都成立,不限于那次迁移:
1. **空值不能下发成 env 键。** 客户端大量写 `AppConfig.env('X') ?? 默认值`,Dart 的 `??`
只兜 null——下发一个空串会把默认值顶掉。`AZURE_TRANSLATION_ENDPOINT` 就这么把请求打到过空地址。
`comm.ResolveSvcEnvKeys` 因此跳过空值:「没配」必须表现为**键不存在**。
2. **区域覆盖里的空串会把基础值抹成空。** `ResolveSvcPlainFields` 判的是「这个 key 在
`Overrides` 里**存不存在**」,存在就用,哪怕值是空串。库里早就躺着 14 行
`{"region":"","subscription_key":""}` 的空壳覆盖(建服务时留下的),于是基础行填得好好的,
所有区域拿到的还是空。**排查「字段明明填了却不生效」先看这张表有没有空覆盖。**
3. **服务 `enable=false` = 该服务的凭据一个都不下发**(`resolveThirdSvcs` 整条跳过)。
`stt_azure` 当时正好是停用的,照搬这个状态就等于把 `AZURE_SPEECH_KEY/REGION` 从 env 里抹掉。
4. **启动时的模板补字段会给服务实例加上空的加密字段**(`patchSvcConfigFields` 按
provider+category 匹配内置模板)。空的加密字段会被 `svcCredMissing` 判成「⚠ 缺凭据」、
被 `checkSvcRequiredFields` 判成**不许保存**——那个服务在后台既报警告又改不动。
现在 `deprecatedTemplateFields` 里声明的字段会从实例上删掉(**只删空的**,有值的是数据不能碰)。
⚠️ 顺带修正:`ast_alibaba` 模板原先照云账号 AccessKey ID/Secret 建,而这条链路
(百炼 qwen3.5-livetranslate)用的是 DashScope `app_key`,AK/SK 一个都用不到。
⚠️ **多应用部署下删业务库 `config` 的行要分清死值和活值**:全局表里有这个键 → 应用自己那行
迁移前就被盖住、从来没生效过,删掉不改变行为;全局表没有 → 那行才是生效值,只能从
「值确实取自它」的那个应用删,其余应用保留并告警(否则等于让它改用别人的凭据)。
⚠️ **「会议记录服务」删的只是后台入口,`echomeet_orch` 的数据与运行时都还在**
(两台机各 4 行:`asrfile_alibaba`/`asrfile_azure` 识别、`mt_alibaba` 翻译、`llmv_qwen` 总结),
它是语音纪要整条流水线的服务选型来源,见
[orch_resolver.go](apps/services/modules/echomeet/orch_resolver.go)。**要换服务商现在只能直接改库。**
⚠️ 存量后台账号 access CSV 里的 `globalconfig`/`appconfig`/`agents`/`meetingsvc`/`calltranslate`
不再对应任何菜单项,权限编辑器里也勾不到了。只勾过 `appconfig` 的账号由
[auth.ts](apps/admin/app/stores/auth.ts) 的 `hasAccess` 兼容到 `svcconfig`,不会因为一次合并丢入口。
## 第三方服务配置:服务端专用类别(2026-08-28 新增)
`svc_config`(后台「第三方服务配置」)默认是**面向客户端**的:[svcresolve.go](apps/services/modules/user/svcresolve.go)
`svc_config`(后台「服务与环境配置 → 第三方服务」)默认是**面向客户端**的:[svcresolve.go](apps/services/modules/user/svcresolve.go)
的 `resolveThirdSvcs` 会把 `encrypted` 字段**解密成明文**塞进 `user_getthirdsvcs_v2` /
`user_getappconfig_v3` 的响应下发。
@ -1128,8 +1651,8 @@ google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel
## 配置与密钥约定(重要)
- 各 `conf/*.yaml` 与 `deploy/<svc>/deploy.sh` 中**写死了真实的 DSN / 密钥 / 服务器 IP / 仓库密码,且纳入 git**——这是用户在私有仓库下**明确接受**的取舍。修改这些文件时注意它们会随仓库提交。
- ⚠️ **本仓库若要公开,必须先清理** `deploy.sh` 的 `deploy_profile`(`DEPLOY_HOST`/`REGISTRY_PASS`)与各 `*.yaml` 里的真实凭据。
- 改部署逻辑要同步两份 `deploy/console/deploy.sh` 和 `deploy/admin/deploy.sh`(内容相同,建议改一份再 cp 覆盖)。
- ⚠️ **本仓库若要公开,必须先清理** 各 `*-deploy.sh` 的 `DEPLOY_HOST`、`deploy/registry-profiles.sh` 的 `REGISTRY_PASS`,以及各 `*.yaml` 里的真实凭据。
- 早期那套 `deploy.sh <env> [action]` 已删除(只指向灵谱、生产位留空),统一走 `dev-deploy.sh` / `prod-build.sh` / `prod-deploy.sh`。
## 迁移上下文

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

@ -0,0 +1,264 @@
<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>

168
apps/admin/app/pages/accounts.vue

@ -32,7 +32,7 @@
<span v-else class="text-slate-400 text-xs">停用</span>
</td>
<td>
<template v-if="a.identity === 3 || a.identity === 4">
<template v-if="a.identity === 4">
<span v-if="scopeParts(a).length" class="text-xs text-slate-500">{{ scopeParts(a).join(' · ') }}</span>
<span v-else class="text-xs text-slate-300">未绑定(不限)</span>
</template>
@ -41,14 +41,10 @@
<span v-else class="text-xs text-slate-300">默认(除SaaS/系统管理)</span>
</template>
<span v-else class="text-xs text-slate-300">全部权限</span>
<div v-if="a.identity === 3" class="text-[11px] text-emerald-600 mt-0.5">
余额 VIP{{ a.balance_vipday || 0 }}天 · 翻译{{ a.balance_trademin || 0 }}分 · 会议{{ a.balance_meetmin || 0 }}分
</div>
</td>
<td class="text-xs text-slate-500">{{ a.remark || '' }}</td>
<td>
<div class="flex gap-1 justify-end">
<button v-if="a.identity === 3" class="btn btn-ghost btn-xs text-emerald-600" @click="openTopup(a)">充值</button>
<button class="btn btn-ghost btn-xs" @click="openEdit(a)">编辑</button>
<button class="btn btn-ghost btn-xs text-amber-600" @click="resetPwd(a.id, a.username)">改密</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delAccount(a.id, a.username)">删除</button>
@ -80,7 +76,6 @@
<label class="text-xs text-slate-500">角色</label>
<select v-model.number="form.identity" class="select select-bordered select-sm w-full">
<option :value="2">管理员(默认除 SaaS/系统管理外全部)</option>
<option :value="3">品牌商(绑定品牌商,见名下全部渠道商)</option>
<option :value="5">渠道商(绑定单个渠道商,只见自己数据)</option>
<option :value="4">运营(内部,按绑定限制)</option>
<option :value="1">超管(全部权限)</option>
@ -121,35 +116,28 @@
</div>
</div>
<!-- 渠道分成体系绑定(品牌商 / 渠道商账号)-->
<div v-show="form.identity === 3 || form.identity === 5" class="mt-4 border-t border-slate-100 pt-4">
<!-- 归属绑定(渠道商账号)-->
<div v-show="form.identity === 5" class="mt-4 border-t border-slate-100 pt-4">
<div class="text-sm font-semibold text-slate-600 mb-2">
归属绑定
<span class="text-xs text-slate-400 font-normal">(品牌商账号只绑品牌商;渠道商账号两者都要绑)</span>
<span class="text-xs text-slate-400 font-normal">(渠道商账号必须绑一个渠道商)</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-slate-500">所属品牌商 *</label>
<select v-model.number="form.brandid" class="select select-bordered select-sm w-full" @change="form.channelid = ''">
<option :value="0">请选择品牌商</option>
<option v-for="b in brands" :key="b.id" :value="b.id">{{ b.brand }}</option>
</select>
</div>
<div v-show="form.identity === 5">
<label class="text-xs text-slate-500">所属渠道商 *</label>
<select v-model="form.channelid" class="select select-bordered select-sm w-full" :disabled="!accountChannelOptions.length">
<option value="">{{ form.brandid ? (accountChannelOptions.length ? '请选择渠道商' : '该品牌商暂无渠道商') : '请先选择品牌商' }}</option>
<option value="">{{ accountChannelOptions.length ? '请选择渠道商' : '暂无渠道商' }}</option>
<option v-for="ch in accountChannelOptions" :key="ch.id" :value="ch.id">{{ ch.name }} ({{ ch.id }})</option>
</select>
</div>
</div>
<p class="text-xs text-slate-400 mt-2">
数据隔离由后端强制:品牌商账号只能看到本品牌商及其名下渠道商的数据,渠道商账号只能看到自己渠道的数据。
数据隔离由后端强制:渠道商账号只能看到自己渠道的数据。
</p>
</div>
<!-- 作用域绑定(仅 代理/运营 显示)-->
<div v-show="form.identity === 3 || form.identity === 4" class="mt-4 border-t border-slate-100 pt-4">
<div v-show="form.identity === 4" class="mt-4 border-t border-slate-100 pt-4">
<div class="text-sm font-semibold text-slate-600 mb-2">作用域绑定 <span class="text-xs text-slate-400 font-normal">(代理/运营生效;限制仪表盘数据范围)</span></div>
<div class="mb-3">
@ -172,21 +160,18 @@
</div>
</div>
<div>
<div class="text-xs text-slate-500 mb-1.5">绑定产品 <span class="text-slate-300">(按厂家分组;仪表盘只显示这些产品数据;不勾=不限)</span></div>
<div class="text-xs text-slate-500 mb-1.5">绑定产品 <span class="text-slate-300">(仪表盘只显示这些产品数据;不勾=不限)</span></div>
<div class="max-h-56 overflow-y-auto pr-1 border border-slate-100 rounded-lg p-2">
<span v-if="prodLoading" class="text-xs text-slate-300">加载产品中...</span>
<span v-else-if="!productGroups.length" class="text-xs text-slate-300">无产品</span>
<div v-for="g in productGroups" v-else :key="g.fid" class="mb-2 last:mb-0">
<div class="text-[11px] font-semibold text-slate-500 mb-1 sticky top-0 bg-white py-0.5">🏭 {{ g.label }}</div>
<div class="flex flex-wrap gap-2">
<label v-for="p in g.products" :key="p.id" class="chk">
<span v-else-if="!productOptions.length" class="text-xs text-slate-300">无产品</span>
<div v-else class="flex flex-wrap gap-2">
<label v-for="p in productOptions" :key="p.id" class="chk">
<input v-model="sel.products" type="checkbox" :value="String(p.id)" />{{ (p.devicename || '产品') + ' #' + p.id }}
</label>
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-3 justify-end mt-6">
<button class="btn btn-ghost btn-sm" @click="closeEdit()">取消</button>
@ -194,24 +179,6 @@
</div>
</div>
</div>
<!-- ── 代理充值/调整资源点弹窗 ── -->
<dialog ref="topupModal" class="modal">
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-1">充值 / 调整资源点</h3>
<p class="text-xs text-slate-400 mb-3">代理:<span class="font-medium text-slate-600">{{ topup.name }}</span>。填增量(可填负数下调),当前余额:<span class="text-emerald-600">{{ topup.cur }}</span></p>
<div class="grid grid-cols-3 gap-3">
<div><label class="text-xs text-slate-500">VIP天数</label><input v-model.number="topup.vipday" type="number" class="input input-bordered input-sm w-full" /></div>
<div><label class="text-xs text-slate-500">翻译分钟</label><input v-model.number="topup.trademin" type="number" class="input input-bordered input-sm w-full" /></div>
<div><label class="text-xs text-slate-500">会议分钟</label><input v-model.number="topup.meetmin" type="number" class="input input-bordered input-sm w-full" /></div>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" @click="closeTopup()">取消</button>
<button class="btn btn-primary btn-sm text-white" @click="submitTopup()">确认</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
</div>
</template>
@ -234,24 +201,20 @@ interface Account {
apps: string
regions: string
products: string
balance_vipday: number
balance_trademin: number
balance_meetmin: number
brandid: number
channelid: string
[key: string]: unknown
}
interface ChannelItem { id: string; brandid: number; name: string; status: number }
interface ChannelItem { id: string; name: string; status: number }
interface AppItem { id: number; name: string; app_name?: string }
interface Brand { id: number; brand: string }
interface ProductItem { id: number; devicename: string; brandid: number }
interface ProductGroup { fid: string; label: string; products: ProductItem[] }
interface ProductItem { id: number; devicename: string }
const { webApi, consoleApi } = useApi()
const { success, error, warn } = useToast()
const ROLE: Record<number, string> = { 1: '超管', 2: '管理员', 3: '品牌商', 4: '运营', 5: '渠道商' }
const ROLE_CLS: Record<number, string> = { 1: 'badge-error', 2: 'badge-primary', 3: 'badge-warning', 4: 'badge-info', 5: 'badge-accent' }
// 3=品牌商 已于 2026-09-12 下线:不再出现在角色下拉里,但存量行可能还有(后端已禁用并降级),
// 所以标签表里保留一条,免得列表显示成裸数字。
const ROLE: Record<number, string> = { 1: '超管', 2: '管理员', 3: '品牌商(已下线)', 4: '运营', 5: '渠道商' }
const ROLE_CLS: Record<number, string> = { 1: 'badge-error', 2: 'badge-primary', 3: 'badge-ghost', 4: 'badge-info', 5: 'badge-accent' }
// 页面访问项统一取自 utils/menus.ts 的菜单目录(MENU_GROUPS),与侧边栏严格一致。
const view = ref<'list' | 'form'>('list')
@ -271,10 +234,9 @@ const form = reactive({
identity: 2,
enabled: '1',
remark: '',
brandid: 0,
channelid: '',
})
// 角色菜单上限(品牌商/渠道商):编辑器只呈现该角色允许的页面,勾了上限外的也不会生效(见 stores/auth hasAccess)。
// 角色菜单上限(渠道商):编辑器只呈现该角色允许的页面,勾了上限外的也不会生效(见 stores/auth hasAccess)。
const roleCeiling = computed<string[] | null>(() => ROLE_ACCESS_CEILING[form.identity] ?? null)
const allowedMenuIds = computed<string[]>(() => roleCeiling.value ?? ALL_MENU_IDS)
const menuGroupsForRole = computed(() => {
@ -284,11 +246,9 @@ const menuGroupsForRole = computed(() => {
.map((g) => ({ ...g, items: g.items.filter((m) => ceiling.includes(m.id)) }))
.filter((g) => g.items.length > 0)
})
// 全量渠道商(含未启用),按所选品牌商过滤出下拉项
// 全量渠道商(含未启用):渠道商是全局实体,直接全列
const channels = ref<ChannelItem[]>([])
const accountChannelOptions = computed(() =>
form.brandid ? channels.value.filter((ch) => ch.brandid === form.brandid) : [],
)
const accountChannelOptions = computed(() => channels.value)
// 勾选集合(v-model 数组)
const sel = reactive({
access: [] as string[],
@ -297,8 +257,7 @@ const sel = reactive({
products: [] as string[],
})
const brands = ref<Brand[]>([])
const productGroups = ref<ProductGroup[]>([])
const productOptions = ref<ProductItem[]>([])
const prodLoading = ref(false)
function fromCsv(s?: string): string[] {
@ -337,36 +296,22 @@ async function loadData() {
}
}
// ── 产品分组加载(按品牌商分组勾选)──
// ── 可绑定的产品 / 渠道商 ──
// 2026-09-12 之前产品是「按品牌商分组」勾的;品牌商下线后 brandid 恒为 0,分组只剩「未分组」
// 一组,徒增一层嵌套,故改成扁平列表。
async function renderScopeOptions() {
prodLoading.value = true
productGroups.value = []
productOptions.value = []
try {
const [fd, pd, cd] = await Promise.all([
webApi<{ brands: Brand[] }>('getbrands', {}),
const [pd, cd] = await Promise.all([
webApi<{ products: ProductItem[] }>('getproducts', {}),
// 全量渠道商(含未启用):给渠道商账号选绑定用
webApi<{ channels: ChannelItem[] }>('getchannels', { status: -1 }),
])
const facs = fd?.brands ?? []
brands.value = facs
channels.value = cd?.channels ?? []
const list = pd?.products ?? []
const facName: Record<string, string> = {}
facs.forEach((b) => { facName[b.id] = b.brand || ('品牌商#' + b.id) })
const groups: Record<string, ProductItem[]> = {}
list.forEach((p) => {
const fid = String(p.brandid || 0)
;(groups[fid] = groups[fid] || []).push(p)
})
const fids = Object.keys(groups).sort((a, b) => (parseInt(a) || 0) - (parseInt(b) || 0))
productGroups.value = fids.map((fid) => ({
fid,
label: facName[fid] || (parseInt(fid) ? ('品牌商#' + fid) : '未分组'),
products: groups[fid] ?? [],
}))
productOptions.value = (pd?.products ?? []).slice().sort((a, b) => a.id - b.id)
} catch {
productGroups.value = []
productOptions.value = []
} finally {
prodLoading.value = false
}
@ -379,7 +324,6 @@ function openEdit(a?: Account) {
form.identity = a ? a.identity : 2
form.enabled = (a && !a.enabled) ? '0' : '1'
form.remark = a ? (a.remark || '') : ''
form.brandid = a ? (Number(a.brandid) || 0) : 0
form.channelid = a ? (a.channelid || '') : ''
// 回填勾选;管理员未显式配置过权限时,预填默认权限集(与登录侧 hasAccess 的默认回退一致)
sel.access = fromCsv(a?.access)
@ -392,7 +336,7 @@ function openEdit(a?: Account) {
}
// 切换角色时联动权限勾选:切到管理员且未勾选任何页面 → 预填默认集;
// 切到品牌商/渠道商 → 把已勾选项裁到该角色上限内(超出的勾了也不生效,留着只会误导)。
// 切到渠道商 → 把已勾选项裁到该角色上限内(超出的勾了也不生效,留着只会误导)。
watch(() => form.identity, (idt) => {
if (idt === 2 && sel.access.length === 0) sel.access = DEFAULT_ADMIN_ACCESS.slice()
const ceiling = ROLE_ACCESS_CEILING[idt]
@ -413,24 +357,19 @@ async function saveAccount() {
access: '', apps: '', regions: '', products: '',
}
if (idt !== 1) {
body.access = csv(sel.access) // 管理员/代理/运营均存显式菜单权限(超管恒全量不存)
body.access = csv(sel.access) // 管理员/运营/渠道商均存显式菜单权限(超管恒全量不存)
}
if (idt === 3 || idt === 4) {
if (idt === 4) {
body.apps = csv(sel.apps)
body.regions = csv(sel.regions)
body.products = csv(sel.products)
}
// 归属绑定:品牌商账号只传 brandid;渠道商账号两者都传;其余角色一律留空(后端会校验自洽)。
body.brandid = 0
// 归属绑定:只有渠道商账号传 channelid,其余角色一律留空(后端会校验自洽)。
body.channelid = ''
if (idt === 3 || idt === 5) {
if (!form.brandid) { error('请选择所属品牌商'); return }
body.brandid = Number(form.brandid)
if (idt === 5) {
if (!form.channelid) { error('请选择所属渠道商'); return }
body.channelid = form.channelid
}
}
submitting.value = true
try {
if (editId.value) {
@ -469,45 +408,10 @@ function delAccount(id: number, name: string) {
.catch((e: any) => error(e?.message ?? '失败'))
}
// ── 代理充值 / 调整资源点 ──
const topupModal = ref<HTMLDialogElement | null>(null)
const topupId = ref(0)
const topup = reactive({
name: '',
cur: '',
vipday: 0,
trademin: 0,
meetmin: 0,
})
function openTopup(a: Account) {
topupId.value = a.id
topup.name = a.username || ('#' + a.id)
topup.cur = 'VIP' + (a.balance_vipday || 0) + '天 · 翻译' + (a.balance_trademin || 0) + '分 · 会议' + (a.balance_meetmin || 0) + '分'
topup.vipday = 0
topup.trademin = 0
topup.meetmin = 0
topupModal.value?.showModal()
}
function closeTopup() {
topupModal.value?.close()
}
async function submitTopup() {
const vipday = Number(topup.vipday) || 0
const trademin = Number(topup.trademin) || 0
const meetmin = Number(topup.meetmin) || 0
if (!vipday && !trademin && !meetmin) { warn('请至少填写一项'); return }
try {
await consoleApi('accounts/topup', { account_id: topupId.value, vipday, trademin, meetmin })
success('余额已更新')
closeTopup()
loadData()
} catch (e: any) {
error(e?.message ?? '操作失败')
}
}
// ⚠️ 「代理充值/调整资源点」入口已随品牌商账号(identity=3)一并下线(2026-09-12):
// 那套资源点余额只有代理账号用得上,没有代理就没有充值对象。
// 后端 /console/api/accounts/topup 与 console_account 的 balance_* 三列**都还在**,
// 存量余额不动;要恢复入口,把这段 UI 与列表里的「充值」按钮加回来即可。
onMounted(loadData)
</script>

1206
apps/admin/app/pages/agents.vue

File diff suppressed because it is too large

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

@ -1,310 +1,16 @@
<template>
<div>
<!-- 列表视图 -->
<div v-show="view === 'list'">
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-lg font-semibold text-slate-800">应用环境配置</h2>
<p class="text-xs text-slate-400 mt-1">应用自有的 key/value 配置(音乐、OSS 等),直接读写所选应用的业务库。结构同「全局环境配置」但不分区域。</p>
</div>
<button class="btn btn-primary btn-sm text-white" :disabled="!curApp" @click="showForm()">+ 新增配置</button>
</div>
<div class="flex items-center gap-3 mb-4 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="onAppChange"
>
<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>
<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="showForm(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>
<!-- 表单视图 -->
<div v-show="view === 'form'">
<div class="flex items-center gap-3 mb-5">
<button class="btn btn-ghost btn-sm" @click="showList()">&larr; 返回列表</button>
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑配置' : '新增配置' }}</h2>
<span class="text-xs text-slate-400">配置目标:{{ curAppLabel }}</span>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl">
<form @submit.prevent="saveCfg">
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">分组 (group)</span></label>
<input v-model="form.group" type="text" class="input input-bordered w-full" placeholder="如 music / oss / other" />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">键 (key) *</span></label>
<input v-model="form.key" type="text" class="input input-bordered w-full" required placeholder="如 oss_access_key" />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">值 (value)</span></label>
<textarea v-model="form.value" class="textarea textarea-bordered w-full font-mono text-sm" rows="3"></textarea>
</div>
<div class="form-control mb-6">
<label class="label"><span class="label-text font-medium">描述</span></label>
<input v-model="form.description" type="text" class="input input-bordered w-full" />
</div>
<div class="flex gap-3">
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存</button>
</div>
</form>
</div>
</div>
<div class="py-16 text-center text-slate-400">
<span class="loading loading-spinner loading-sm"></span>
<p class="text-sm mt-3">本页已并入「服务与环境配置」,正在跳转…</p>
</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'
definePageMeta({ title: '应用环境配置' })
// 部署应用(与应用中心 apps/list 同源;id 为部署注册 id,用于连接该应用业务库)
// 同一个应用会在多个环境各有一条部署记录,靠 env_id 区分——配置按「应用 × 环境」独立。
interface Deployment { id: number; app_name: string; status: string; name: string; region: string; env_id: number }
// 部署环境(envs/list 同源,这里只用来给部署显示环境名)
interface EnvOption { id: number; name: string; code: string; enabled: boolean }
// 应用 config 项(结构同 DBGlobalConfigItem 去掉 region)
interface AppConfig {
id: number
group: string
key: string
value: string
description: string
}
const { consoleApi } = useApi()
const { success, error } = useToast()
const view = ref<'list' | 'form'>('list')
const apps = ref<Deployment[]>([]) // 全部部署(跨应用、跨环境)
const envs = ref<EnvOption[]>([]) // 全部环境
const curAppName = ref('') // 一级:选中的应用
const curApp = ref(0) // 二级:选中的部署 app_id(= 应用 × 环境)
const items = ref<AppConfig[]>([])
const loading = ref(false)
const submitting = ref(false)
const isEditing = ref(false)
const keyword = ref('')
const form = reactive({ id: 0, group: '', key: '', value: '', description: '' })
// 应用名列表(一级选项):部署记录按 app_name 去重
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)}` : ''
})
// 关键字过滤(按 group + key)
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
// 2026-09-12:「全局环境配置」下线(凭据统一收进第三方服务配置),
// 「应用环境配置」并入「服务与环境配置」的「应用参数」标签页。
// 保留本路由做重定向,兼容旧书签/直链。
definePageMeta({ title: '服务与环境配置' })
onMounted(() => {
navigateTo('/serviceconfig')
})
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 onAppChange() {
loadData()
}
function showList() {
view.value = 'list'
loadData()
}
function showForm(cfg?: AppConfig) {
if (cfg) {
isEditing.value = true
form.id = cfg.id || 0
form.group = cfg.group || ''
form.key = cfg.key || ''
form.value = cfg.value || ''
form.description = cfg.description || ''
} else {
isEditing.value = false
form.id = 0
form.group = ''
form.key = ''
form.value = ''
form.description = ''
}
view.value = 'form'
}
async function saveCfg() {
if (!curApp.value) {
error("请先选择应用和部署环境")
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 (isEditing.value) {
data.id = Number(form.id)
await consoleApi('appcfg/update', data)
} else {
await consoleApi('appcfg/add', data)
}
success('保存成功')
showList()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
function delCfg(c: AppConfig) {
if (!window.confirm('确定删除该配置项吗?')) return
consoleApi('appcfg/del', { app_id: curApp.value, ids: [c.id] })
.then(() => { success('删除成功'); loadData() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
onMounted(loadApps)
</script>

622
apps/admin/app/pages/calltranslate.vue

@ -1,622 +0,0 @@
<template>
<div>
<!-- 顶部 -->
<div class="flex items-center justify-between mb-5 flex-wrap gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-800">通话翻译规则</h2>
<p class="text-xs text-slate-400 mt-1">
一条规则覆盖「多源语言 × 多目标语言」,命中后走它指定的 Agent。多条规则按优先级从高到低匹配,第一条命中的生效。
</p>
</div>
<div class="flex items-center gap-3 flex-wrap">
<span class="text-sm text-slate-500">作用域</span>
<select v-model="scopeApp" class="select select-bordered select-sm" @change="loadAll">
<option value="">全局默认</option>
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
</select>
<span class="text-sm text-slate-500">区域</span>
<select v-model.number="curRegion" class="select select-bordered select-sm" @change="loadRules">
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
<button class="btn btn-ghost btn-sm" @click="openTest">🔍 选路试算</button>
<button class="btn btn-primary btn-sm text-white" @click="startAdd">+ 新增规则</button>
</div>
</div>
<!-- 行内编辑器 -->
<div v-show="editorOpen" ref="editorRef" class="editor-panel">
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<h3 class="font-semibold text-slate-800 text-sm">{{ editing ? '编辑规则' : '新增规则' }}</h3>
<span class="region-chip">{{ scopeApp || '全局默认' }}</span>
</div>
<button class="btn btn-ghost btn-xs" @click="closeEditor">✕ 关闭</button>
</div>
<!-- 1) 匹配条件 -->
<section class="ed-section">
<div class="ed-section-title">匹配条件</div>
<div class="lang-row">
<div class="lang-field">
<div class="flex items-center justify-between">
<span class="ed-label">源语言</span>
<label class="any-toggle">
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="ed.srcAny" @change="toggleAny('src')" />
任意语言
</label>
</div>
<div v-if="ed.srcAny" class="any-box">任意源语言</div>
<LangSelect v-else v-model="ed.srcLangs" multiple />
</div>
<div class="dir-col">
<button
type="button"
class="dir-btn"
:class="{ on: ed.bidir }"
:title="ed.bidir ? '点击改为单向' : '点击改为双向(反向也走这条规则)'"
@click="ed.bidir = !ed.bidir"
>{{ ed.bidir ? '⇄' : '→' }}</button>
<span class="dir-tip">{{ ed.bidir ? '双向互译' : '单向翻译' }}</span>
</div>
<div class="lang-field">
<div class="flex items-center justify-between">
<span class="ed-label">目标语言</span>
<label class="any-toggle">
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="ed.dstAny" @change="toggleAny('dst')" />
任意语言
</label>
</div>
<div v-if="ed.dstAny" class="any-box">任意目标语言</div>
<LangSelect v-else v-model="ed.dstLangs" multiple />
</div>
</div>
<div class="grid grid-cols-3 gap-3 mt-3">
<div>
<span class="ed-label">生效区域</span>
<select v-model.number="ed.region" class="select select-bordered select-sm w-full">
<option :value="0">全部区域(兜底)</option>
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</div>
<div>
<span class="ed-label">优先级(越大越先匹配)</span>
<input v-model.number="ed.priority" type="number" class="input input-bordered input-sm w-full" />
</div>
<div>
<span class="ed-label">规则名(便于识别)</span>
<input v-model="ed.name" class="input input-bordered input-sm w-full" placeholder="如 中日韩端到端" />
</div>
</div>
</section>
<!-- 2) 命中后走哪个 Agent -->
<section class="ed-section">
<div class="ed-section-title">命中后使用的 Agent</div>
<div class="grid grid-cols-2 gap-3">
<div>
<span class="ed-label">翻译 Agent <i class="req">*</i></span>
<select v-model="ed.agentId" class="select select-bordered select-sm w-full">
<option value="" disabled>{{ transAgents.length ? '请选择 Agent' : '(无可用 Agent,请先去 Agent 配置里创建)' }}</option>
<optgroup v-for="g in agentGroups" :key="g.type" :label="g.label">
<option v-for="a in g.items" :key="a.id" :value="a.id">
{{ a.name || a.id }}{{ a.global ? '(全局兜底)' : '' }}{{ a.enable ? '' : '(已停用)' }}
</option>
</optgroup>
</select>
</div>
<div>
<span class="ed-label">备注</span>
<input v-model="ed.remark" class="input input-bordered input-sm w-full" placeholder="可留空" />
</div>
</div>
<div v-if="curAgent" class="agent-preview">
<span class="type-badge" :style="typeStyle(curAgent.type)">{{ typeLabel(curAgent.type) }}</span>
<span class="text-xs text-slate-500">{{ curAgent.description || '(无描述)' }}</span>
<span v-if="curAgent.support_langs" class="text-[11px] text-slate-400">支持:{{ curAgent.support_langs }}</span>
<span v-if="!curAgent.enable" class="text-[11px] text-amber-600">⚠ 该 Agent 当前为停用状态</span>
</div>
<p v-if="langWarn" class="ed-warn">⚠ {{ langWarn }}</p>
</section>
<div class="ed-footer">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="ed.enable" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>启用</span>
<span class="text-xs text-slate-400">停用的规则不参与选路</span>
</label>
<div class="flex gap-2">
<button class="btn btn-ghost btn-sm" @click="closeEditor">取消</button>
<button class="btn btn-outline btn-sm" @click="openSyncModal">保存并同步…</button>
<button class="btn btn-primary btn-sm text-white" @click="saveOnly">保存</button>
</div>
</div>
</div>
<!-- 规则列表:顺序即匹配顺序 -->
<div v-if="!rules.length" class="text-center py-20 text-slate-300">
<div class="text-5xl mb-3">🌐</div>
<p class="text-sm">当前区域还没有翻译规则</p>
<p class="text-xs mt-1">建议先加一条「任意 → 任意」的兜底规则,再往上叠精确规则</p>
</div>
<div v-else class="rule-list">
<div class="rule-head">
<span>优先级</span><span>匹配条件</span><span>Agent</span><span>区域</span><span class="text-right">操作</span>
</div>
<div v-for="(r, i) in rules" :key="r.id" class="rule-row" :class="{ off: !r.enable }" @click="startEdit(r)">
<div class="prio-cell">
<span class="prio">{{ r.priority }}</span>
<span class="order">#{{ i + 1 }}</span>
</div>
<div class="cond-cell">
<div class="flex items-center gap-2 flex-wrap">
<span class="lang-badge" :class="{ any: isAny(r.src_langs) }">{{ langsLabel(r.src_langs) }}</span>
<span class="text-slate-400 font-bold">{{ r.bidir ? '⇄' : '→' }}</span>
<span class="lang-badge" :class="{ any: isAny(r.dst_langs) }">{{ langsLabel(r.dst_langs) }}</span>
</div>
<div v-if="r.name || r.remark" class="text-[11px] text-slate-400 mt-1">{{ r.name }}<span v-if="r.remark"> · {{ r.remark }}</span></div>
</div>
<div class="agent-cell">
<span class="type-badge" :style="typeStyle(agentType(r.agent_id))">{{ typeLabel(agentType(r.agent_id)) }}</span>
<span class="text-xs text-slate-700">{{ agentName(r.agent_id) }}</span>
</div>
<div class="text-xs text-slate-500">{{ r.region === 0 ? '全部区域' : regionLabel(r.region) }}</div>
<div class="text-right">
<span v-if="r.enable" class="badge badge-success badge-xs mr-2">启用</span>
<span v-else class="badge badge-ghost badge-xs mr-2">停用</span>
<button class="btn btn-ghost btn-xs text-error" @click.stop="delRule(r)">删除</button>
</div>
</div>
</div>
<!-- 选路试算 -->
<div v-show="testOpen" class="ct-overlay">
<div class="ct-box">
<h3 class="text-base font-semibold text-slate-800 mb-1">选路试算</h3>
<p class="text-xs text-slate-400 mb-4">用后端运行时同一套匹配逻辑,算一遍这个方向实际会走哪条规则。</p>
<div class="grid grid-cols-2 gap-3">
<div>
<span class="ed-label">源语言</span>
<LangSelect v-model="testSrc" />
</div>
<div>
<span class="ed-label">目标语言</span>
<LangSelect v-model="testDst" />
</div>
</div>
<div v-if="testResult" class="test-result" :class="{ miss: !testResult.matched }">
<template v-if="testResult.matched">
<div class="text-sm font-semibold text-slate-800 mb-1">
命中规则:{{ testResult.rule?.name || '#' + testResult.rule?.id }}(优先级 {{ testResult.rule?.priority }})
</div>
<div class="text-xs text-slate-600">
{{ langsLabel(testResult.rule?.src_langs) }} {{ testResult.rule?.bidir ? '⇄' : '→' }} {{ langsLabel(testResult.rule?.dst_langs) }}
· Agent:{{ testResult.agent?.name || testResult.rule?.agent_id }}
<span v-if="testResult.agent">({{ typeLabel(testResult.agent.type) }})</span>
</div>
</template>
<template v-else>
<div class="text-sm text-amber-700">没有任何规则命中 —— 这个方向的通话翻译会不可用,建议补一条兜底规则。</div>
</template>
</div>
<div class="flex gap-3 justify-end mt-5">
<button class="btn btn-ghost" @click="testOpen = false">关闭</button>
<button class="btn btn-primary text-white" @click="runTest">试算</button>
</div>
</div>
</div>
<!-- 同步弹窗 -->
<div v-show="syncOpen" class="ct-overlay">
<div class="ct-box">
<h3 class="text-base font-semibold text-slate-800 mb-1">同步到其他区域</h3>
<p class="text-xs text-slate-400 mb-3">{{ syncDesc }}</p>
<div class="text-xs px-3 py-2 rounded-lg mb-3" style="background:#f0fdf4;border:1px solid #bbf7d0;color:#15803d">
✦ 同步内容:语言条件、Agent、优先级、启停状态一并复制到目标区域。
</div>
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-slate-500">选择目标区域</span>
<div class="flex gap-2">
<button class="btn btn-ghost btn-xs" @click="syncSelAll(true)">全选</button>
<button class="btn btn-ghost btn-xs" @click="syncSelAll(false)">清空</button>
</div>
</div>
<div class="sync-region-grid">
<label
v-for="r in syncRegions"
:key="r.value"
class="sync-region-item"
:class="{ checked: syncTargets.includes(r.value) }"
@click.prevent="toggleSyncR(r.value)"
>
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="syncTargets.includes(r.value)" @click.stop="toggleSyncR(r.value)" />
<span>{{ r.label }}</span>
</label>
</div>
<p class="text-[11px] text-slate-400 mb-5">⚠ 目标区域已有「同 Agent + 同语言条件」的规则则覆盖,否则新建。</p>
<div class="flex gap-3 justify-end">
<button class="btn btn-ghost" @click="syncOpen = false">取消</button>
<button class="btn btn-primary text-white" :disabled="!syncTargets.length" @click="doSaveAndSync">
保存并同步{{ syncTargets.length ? '(' + syncTargets.length + ' 个区域)' : '' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { langName } from '~/composables/useLangOptions'
import LangSelect from '~/components/LangSelect.vue'
definePageMeta({ title: '通话翻译规则' })
// ===== 类型 =====
interface Agent {
id: string
name: string
type: string
description: string
enable: boolean
support_langs: string
global?: boolean // 来自「全局默认」作用域的兜底 agent(当前应用没配同 id 时可引用)
}
interface Rule {
id: number
app_name: string
region: number
name: string
src_langs: string
dst_langs: string
bidir: boolean
agent_id: string
priority: number
enable: boolean
remark: string
}
// 语言通配符,与后端 comm.LangAny 一致
const LANG_ANY = '*'
const REGIONS = [
{ value: 1, label: '中国' }, { value: 2, label: '美国' }, { value: 3, label: '东亚' },
{ value: 4, label: '东南亚' }, { value: 5, label: '南亚' }, { value: 6, label: '中东' },
{ value: 7, label: '欧洲' }, { value: 8, label: '俄罗斯' }, { value: 9, label: '非洲' },
{ value: 10, label: '南美' }, { value: 11, label: '北美' }, { value: 12, label: '大洋洲' },
{ value: 13, label: '巴西' }, { value: 14, label: '印度' },
]
function regionLabel(v: number): string {
return REGIONS.find((x) => x.value === v)?.label ?? '区域' + v
}
// 通话翻译只能引用这两类 agent(与后端 callTranslateAgentTypes 一致)
const TRANS_TYPES: Record<string, { label: string; color: string; bg: string }> = {
translate: { label: '🧩 三段式翻译', color: '#059669', bg: '#dcfce7' },
'ast-translate': { label: '🚀 端到端翻译', color: '#c2410c', bg: '#fff7ed' },
}
function typeLabel(t?: string): string {
return t && TRANS_TYPES[t] ? TRANS_TYPES[t].label : (t || '未知')
}
function typeStyle(t?: string) {
const s = t ? TRANS_TYPES[t] : undefined
return s ? { background: s.bg, color: s.color } : { background: '#f1f5f9', color: '#64748b' }
}
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
// ===== 状态 =====
const scopeApp = ref('')
const scopeApps = ref<string[]>([])
const curRegion = ref<number>(REGIONS[0]!.value)
const rules = ref<Rule[]>([])
const agents = ref<Agent[]>([])
const editorOpen = ref(false)
const editing = ref<Rule | null>(null)
const editorRef = ref<HTMLElement | null>(null)
const pendingId = ref(0)
const ed = reactive({
name: '',
srcLangs: '',
dstLangs: '',
srcAny: false,
dstAny: false,
bidir: true,
region: 0,
priority: 100,
agentId: '',
remark: '',
enable: true,
})
// 只列可用于通话翻译的 agent,按类型分组
const transAgents = computed(() => agents.value.filter((a) => a.type in TRANS_TYPES))
const agentGroups = computed(() =>
Object.keys(TRANS_TYPES).map((t) => ({
type: t,
label: TRANS_TYPES[t]!.label,
items: transAgents.value.filter((a) => a.type === t),
})).filter((g) => g.items.length),
)
const curAgent = computed(() => agents.value.find((a) => a.id === ed.agentId) || null)
// 规则选的语言若不在 agent 声明的支持语言里,运行时多半会失败,配置期就提示出来
const langWarn = computed(() => {
const a = curAgent.value
if (!a || !a.support_langs) return ''
const sup = a.support_langs.split(',').map((x) => x.trim()).filter(Boolean)
if (!sup.length) return ''
const picked = [
...(ed.srcAny ? [] : ed.srcLangs.split(',')),
...(ed.dstAny ? [] : ed.dstLangs.split(',')),
].map((x) => x.trim()).filter(Boolean)
const miss = picked.filter((c) => !sup.includes(c))
if (!miss.length) return ''
return `Agent「${a.name || a.id}」未声明支持:${miss.join('、')}`
})
function isAny(s?: string): boolean {
return (s || '').trim() === LANG_ANY
}
function langsLabel(s?: string): string {
if (isAny(s)) return '任意语言'
const arr = (s || '').split(',').map((x) => x.trim()).filter(Boolean)
if (!arr.length) return '—'
if (arr.length <= 3) return arr.map((c) => langName(c)).join('、')
return arr.slice(0, 3).map((c) => langName(c)).join('、') + ` 等 ${arr.length} 种`
}
function agentName(id: string): string {
return agents.value.find((a) => a.id === id)?.name || id || '未配置'
}
function agentType(id: string): string {
return agents.value.find((a) => a.id === id)?.type || ''
}
// ===== 加载 =====
async function loadScopeApps() {
try {
const list = await consoleApi<Array<{ name: string }>>('products/list', {})
scopeApps.value = (list ?? []).map((p: { name: string }) => p.name).filter(Boolean)
} catch {
scopeApps.value = []
}
}
// 选了应用时要连全局默认的 agent 一起拉:规则可以引用全局兜底的翻译 Agent
// (后端 findAgentForRule 就是「应用作用域优先、未命中回退全局」),同 id 以本应用的为准。
async function loadAgents() {
const app = scopeApp.value
const [own, global] = await Promise.all([
webApi<{ items: Agent[] }>('getagents', { app_name: app }).catch(() => null),
app ? webApi<{ items: Agent[] }>('getagents', { app_name: '' }).catch(() => null) : Promise.resolve(null),
])
const list = own?.items ?? []
const ids = new Set(list.map((a) => a.id))
agents.value = [...list, ...(global?.items ?? []).filter((a) => !ids.has(a.id)).map((a) => ({ ...a, global: true }))]
}
async function loadRules() {
try {
const d = await webApi<{ items: Rule[] }>('getcalltranslaterules', { app_name: scopeApp.value, region: curRegion.value })
rules.value = d?.items ?? []
} catch (e: any) {
error(e?.message ?? '加载失败')
rules.value = []
}
}
async function loadAll() {
await Promise.all([loadAgents(), loadRules()])
}
// ===== 编辑 =====
function revealEditor() {
nextTick(() => editorRef.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }))
}
function fillEditor(r: Rule | null) {
ed.name = r?.name ?? ''
ed.srcAny = isAny(r?.src_langs)
ed.dstAny = isAny(r?.dst_langs)
ed.srcLangs = ed.srcAny ? '' : (r?.src_langs ?? '')
ed.dstLangs = ed.dstAny ? '' : (r?.dst_langs ?? '')
ed.bidir = r ? !!r.bidir : true
ed.region = r ? r.region : curRegion.value
ed.priority = r?.priority ?? 100
ed.agentId = r?.agent_id ?? ''
ed.remark = r?.remark ?? ''
ed.enable = r ? r.enable !== false : true
}
function startAdd() {
editing.value = null
fillEditor(null)
editorOpen.value = true
revealEditor()
}
function startEdit(r: Rule) {
editing.value = r
fillEditor(r)
editorOpen.value = true
revealEditor()
}
function closeEditor() {
editorOpen.value = false
editing.value = null
}
function toggleAny(which: 'src' | 'dst') {
if (which === 'src') {
ed.srcAny = !ed.srcAny
if (ed.srcAny) ed.srcLangs = ''
} else {
ed.dstAny = !ed.dstAny
if (ed.dstAny) ed.dstLangs = ''
}
}
function collect(): Rule {
return {
id: editing.value ? editing.value.id : 0,
app_name: scopeApp.value,
region: ed.region,
name: ed.name.trim(),
src_langs: ed.srcAny ? LANG_ANY : ed.srcLangs,
dst_langs: ed.dstAny ? LANG_ANY : ed.dstLangs,
bidir: ed.bidir,
agent_id: ed.agentId,
priority: ed.priority || 0,
enable: ed.enable,
remark: ed.remark.trim(),
}
}
async function performSave(): Promise<boolean> {
const data = collect()
if (!data.src_langs) { error('源语言必选(或勾选「任意语言」)'); return false }
if (!data.dst_langs) { error('目标语言必选(或勾选「任意语言」)'); return false }
if (!data.agent_id) { error('请选择翻译 Agent'); return false }
try {
const res = await webApi<Rule>('savecalltranslaterule', data)
pendingId.value = res?.id ?? 0
await loadRules()
return true
} catch (e: any) {
error(e?.message ?? '保存失败')
return false
}
}
async function saveOnly() {
if (await performSave()) { closeEditor(); success('保存成功') }
}
function delRule(r: Rule) {
if (!window.confirm(`确定删除规则「${r.name || '#' + r.id}」吗?`)) return
webApi('delcalltranslaterules', { ids: [r.id] })
.then(() => { success('已删除'); loadRules() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
// ===== 同步 =====
const syncOpen = ref(false)
const syncDesc = ref('')
const syncTargets = ref<number[]>([])
const syncRegions = computed(() => REGIONS.filter((r) => r.value !== ed.region))
function openSyncModal() {
if (!ed.agentId) { error('请先选择翻译 Agent'); return }
syncDesc.value = `将「${ed.srcAny ? '任意语言' : langsLabel(ed.srcLangs)} ${ed.bidir ? '⇄' : '→'} ${ed.dstAny ? '任意语言' : langsLabel(ed.dstLangs)}」这条规则复制到其他区域。`
syncTargets.value = []
syncOpen.value = true
}
function toggleSyncR(v: number) {
const i = syncTargets.value.indexOf(v)
if (i >= 0) syncTargets.value.splice(i, 1)
else syncTargets.value.push(v)
}
function syncSelAll(val: boolean) {
syncTargets.value = val ? syncRegions.value.map((r) => r.value) : []
}
async function doSaveAndSync() {
const targets = [...syncTargets.value]
syncOpen.value = false
if (!(await performSave())) return
closeEditor()
try {
const res = await webApi<{ synced: number }>('synccalltranslaterules', { rule_ids: [pendingId.value], target_regions: targets })
success(`已保存并同步到:${targets.map(regionLabel).join('、')}(${res?.synced ?? 0} 条)`)
} catch (e: any) {
error(e?.message ?? '同步失败')
}
}
// ===== 选路试算 =====
const testOpen = ref(false)
const testSrc = ref('zh-CN')
const testDst = ref('en-US')
const testResult = ref<{ matched: boolean; rule?: Rule; agent?: Agent } | null>(null)
function openTest() {
testResult.value = null
testOpen.value = true
}
async function runTest() {
if (!testSrc.value || !testDst.value) { error('请选择源语言与目标语言'); return }
try {
testResult.value = await webApi('testcalltranslaterule', {
app_name: scopeApp.value,
region: curRegion.value,
src_lang: testSrc.value,
dst_lang: testDst.value,
})
} catch (e: any) {
error(e?.message ?? '试算失败')
}
}
onMounted(async () => {
await loadScopeApps()
await loadAll()
})
</script>
<style scoped>
/* 规则列表 */
.rule-list { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; overflow: hidden; }
.rule-head, .rule-row { display: grid; grid-template-columns: 92px 1fr 220px 92px 160px; gap: 12px; align-items: center; padding: 11px 16px; }
.rule-head { font-size: 11px; font-weight: 700; color: #94a3b8; background: #f8fafc; border-bottom: 1px solid #eef2f7; }
.rule-row { border-bottom: 1px solid #f6f8fb; cursor: pointer; transition: background .15s; }
.rule-row:last-child { border-bottom: none; }
.rule-row:hover { background: #f8fafc; }
.rule-row.off { opacity: .55; }
.prio-cell { display: flex; align-items: baseline; gap: 6px; }
.prio { font-size: 15px; font-weight: 800; color: #4338ca; }
.order { font-size: 10px; color: #cbd5e1; }
.cond-cell { min-width: 0; }
.agent-cell { display: flex; align-items: center; gap: 6px; min-width: 0; }
.lang-badge { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 8px; background: #eef2ff; color: #4338ca; }
.lang-badge.any { background: #f1f5f9; color: #64748b; font-style: italic; }
.type-badge { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 5px; white-space: nowrap; }
/* 行内编辑器 */
.editor-panel { background: #fff; border-radius: 16px; border: 1px solid #c7d2fe; box-shadow: 0 8px 32px rgba(99, 102, 241, .1); padding: 22px; margin-bottom: 16px; }
.region-chip { font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 6px; background: #eef2ff; color: #4338ca; }
.ed-section { padding: 14px 0; border-top: 1px dashed #eef2f7; }
.ed-section:first-of-type { padding-top: 0; border-top: none; }
.ed-section-title { font-size: 11px; font-weight: 700; letter-spacing: .04em; color: #94a3b8; margin-bottom: 10px; }
.ed-label { display: block; font-size: 11px; color: #64748b; margin-bottom: 4px; }
.ed-warn { font-size: 11px; color: #b45309; margin-top: 8px; }
.req { color: #dc2626; font-style: normal; }
.any-toggle { display: flex; align-items: center; gap: 5px; font-size: 11px; color: #64748b; cursor: pointer; }
.any-box { display: flex; align-items: center; height: 34px; padding: 0 12px; border-radius: 8px; border: 1.5px dashed #cbd5e1; background: #f8fafc; color: #64748b; font-size: 12px; font-style: italic; }
.lang-row { display: grid; grid-template-columns: 1fr 92px 1fr; align-items: start; gap: 12px; }
.lang-field { min-width: 0; }
.dir-col { display: flex; flex-direction: column; align-items: center; gap: 4px; padding-top: 22px; }
.dir-btn { width: 46px; height: 32px; border-radius: 9px; border: 1.5px solid #e2e8f0; background: #f8fafc; color: #64748b; font-size: 17px; line-height: 1; cursor: pointer; transition: all .15s; }
.dir-btn:hover { border-color: #c7d2fe; background: #f5f3ff; color: #4338ca; }
.dir-btn.on { border-color: #6366f1; background: #eef2ff; color: #4338ca; }
.dir-tip { font-size: 10px; color: #94a3b8; white-space: nowrap; }
.agent-preview { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; padding: 8px 12px; border-radius: 9px; background: #f8fafc; border: 1px solid #eef2f7; }
.ed-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding-top: 14px; border-top: 1px solid #eef2f7; }
/* 弹窗 */
.ct-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); z-index: 100; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); padding: 24px; }
.ct-box { background: #fff; border-radius: 20px; padding: 28px; width: 560px; max-width: 95vw; box-shadow: 0 24px 80px rgba(0, 0, 0, .18); }
.sync-region-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 14px 0; }
.sync-region-item { display: flex; align-items: center; gap: 7px; padding: 8px 10px; border-radius: 9px; border: 1.5px solid #e2e8f0; cursor: pointer; transition: all .15s; font-size: 13px; font-weight: 500; color: #475569; }
.sync-region-item:hover { border-color: #c7d2fe; background: #f5f3ff; }
.sync-region-item.checked { border-color: #6366f1; background: #eef2ff; color: #4338ca; }
.test-result { margin-top: 14px; padding: 12px 14px; border-radius: 10px; background: #eef2ff; border: 1px solid #c7d2fe; }
.test-result.miss { background: #fffbeb; border-color: #fde68a; }
@media (max-width: 1000px) {
.rule-head, .rule-row { grid-template-columns: 70px 1fr 150px; }
.rule-head > :nth-child(4), .rule-row > :nth-child(4) { display: none; }
}
@media (max-width: 860px) {
.lang-row { grid-template-columns: 1fr; }
.dir-col { flex-direction: row; padding-top: 0; }
}
</style>

445
apps/admin/app/pages/code.vue

@ -1,445 +0,0 @@
<template>
<div class="space-y-6">
<!-- ========== 顶部筛选:选品牌商 ========== -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4">
<div class="flex items-end gap-3 flex-wrap">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">品牌商</span></label>
<select v-model.number="currentBrandId" class="select select-bordered select-sm" style="min-width:200px;" @change="onBrandChange">
<option :value="0">全部品牌商</option>
<option v-for="b in brands" :key="b.id" :value="b.id">{{ brandMap[b.id] }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">状态</span></label>
<select v-model.number="filterStatus" class="select select-bordered select-sm" @change="loadCodes">
<option :value="-1">全部</option>
<option :value="0">启用</option>
<option :value="1">停用</option>
</select>
</div>
<button class="btn btn-primary btn-sm text-white" title="新增公码需先选择具体品牌商" :disabled="currentBrandId === 0" @click="showCodeForm()">+ 新增公码</button>
</div>
</div>
<!-- ========== 公码列表 ========== -->
<div v-show="view === 'list'">
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>公码</th><th>品牌商</th><th>关联产品</th><th>备注</th>
<th>VIP天/翻译分/会议分</th>
<th>核销/上限</th><th>失效时间</th><th>状态</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="9" class="text-center text-slate-400 py-8">加载中…</td>
</tr>
<tr v-else-if="!codes.length">
<td colspan="9" class="text-center text-slate-400 py-8">{{ currentBrandId ? '暂无公码,点击右上角“新增公码”创建' : '暂无公码' }}</td>
</tr>
<tr v-for="c in codes" :key="c.code">
<td class="font-mono text-base font-bold text-indigo-700">{{ c.code || '' }}</td>
<td class="text-sm">{{ facName(c) }}</td>
<td class="text-sm">{{ prodName(c) }}</td>
<td class="text-sm text-slate-500">{{ c.remark || '-' }}</td>
<td class="text-sm">{{ (c.bindrewardvpitime || 0) }} / {{ (c.bindrewardtranslate || 0) }} / {{ (c.bindrewardmeeting || 0) }}</td>
<td class="text-sm">{{ (c.useduses || 0) }} / {{ c.maxuses ? c.maxuses : '∞' }}</td>
<td class="text-sm">{{ formatTs(c.expiretime) }}</td>
<td>
<span v-if="c.status === 0" class="badge badge-success badge-sm text-white">启用</span>
<span v-else class="badge badge-ghost badge-sm">停用</span>
</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs text-indigo-600" @click="showQrCode(c.code)">二维码</button>
<button class="btn btn-ghost btn-xs" @click="showCodeForm(c)">编辑</button>
<button class="btn btn-ghost btn-xs text-amber-600" @click="toggleCodeStatus(c)">{{ c.status === 0 ? '停用' : '启用' }}</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delCode(c.code)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ========== 公码表单 ========== -->
<div v-show="view === 'form'">
<div class="flex items-center gap-3 mb-5">
<button class="btn btn-ghost btn-sm" @click="backToCodes">&larr; 返回公码列表</button>
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? `编辑公码 ${form.code}` : '新增公码' }}</h2>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl">
<form @submit.prevent="saveCode">
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">关联产品(选填,仅作分类)</span></label>
<select v-model.number="form.productid" class="select select-bordered w-full">
<option :value="0">不关联</option>
<option v-for="p in allProducts" :key="p.id" :value="p.id">{{ p.devicename || ('#' + p.id) }}</option>
</select>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">备注</span></label>
<input v-model="form.remark" type="text" class="input input-bordered w-full" placeholder="例如:包装海报-2026春季" />
</div>
<div class="grid grid-cols-3 gap-3 mb-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">奖励VIP天数</span></label>
<input v-model.number="form.bindrewardvpitime" type="number" min="0" class="input input-bordered" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">奖励翻译分钟</span></label>
<input v-model.number="form.bindrewardtranslate" type="number" min="0" class="input input-bordered" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">奖励会议分钟</span></label>
<input v-model.number="form.bindrewardmeeting" type="number" min="0" class="input input-bordered" />
</div>
</div>
<div class="grid grid-cols-2 gap-3 mb-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">最大核销人数(0=不限)</span></label>
<input v-model.number="form.maxuses" type="number" min="0" class="input input-bordered" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">失效时间(留空=永久)</span></label>
<input v-model="form.expire" type="datetime-local" class="input input-bordered" />
</div>
</div>
<div v-show="isEditing" class="form-control mb-6">
<label class="label cursor-pointer justify-start gap-3">
<input v-model="form.disabled" type="checkbox" class="checkbox" />
<span class="label-text font-medium">停用</span>
</label>
</div>
<div class="flex gap-3">
<button type="button" class="btn btn-ghost" @click="backToCodes">取消</button>
<button type="submit" class="btn btn-primary text-white">保存</button>
</div>
</form>
</div>
</div>
<!-- 二维码弹窗 -->
<dialog ref="qrModal" class="modal">
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-4 text-slate-800">公码二维码</h3>
<div class="flex flex-col items-center gap-4">
<div ref="qrCanvas" class="p-3 bg-white border border-slate-200 rounded-lg"></div>
<div class="text-center">
<div class="text-xs text-slate-500 mb-1">公码</div>
<div class="font-mono text-2xl font-bold text-indigo-700 tracking-widest">{{ currentQrCode }}</div>
</div>
<div class="text-xs text-slate-400 text-center">
新用户使用 App 扫描即可领取奖励,每人仅可领取 1 次
</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-outline btn-sm" @click="downloadQr">下载图片</button>
<button type="button" class="btn btn-outline btn-sm" @click="printQr">打印</button>
<form method="dialog"><button class="btn btn-sm">关闭</button></form>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '公码管理' })
interface PublicCode {
code: string
brandid: number
productid: number
remark: string
status: number
maxuses: number
useduses: number
expiretime: number
bindrewardvpitime: number
bindrewardtranslate: number
bindrewardmeeting: number
[key: string]: unknown
}
interface Brand { id: number; brand: string }
interface Product { id: number; devicename: string }
const { webApi } = useApi()
const { success, error, warn } = useToast()
const view = ref<'list' | 'form'>('list')
const loading = ref(false)
const isEditing = ref(false)
const currentBrandId = ref(0) // 0 = 全部品牌商
const filterStatus = ref(-1)
const codes = ref<PublicCode[]>([])
const brands = ref<Brand[]>([])
const allProducts = ref<Product[]>([]) // 缓存产品列表给 select 用 + 表格产品名展示
const brandMap = reactive<Record<number, string>>({}) // brandid -> 品牌商名
const form = reactive({
code: '',
productid: 0,
remark: '',
bindrewardvpitime: 0,
bindrewardtranslate: 0,
bindrewardmeeting: 0,
maxuses: 0,
expire: '',
disabled: false,
})
// ---- 时间工具 ----
function formatTs(ts?: number) {
if (!ts) return '永久'
return new Date(ts * 1000).toLocaleString()
}
function tsToLocalInput(ts?: number) {
if (!ts) return ''
const d = new Date(ts * 1000)
const pad = (n: number) => (n < 10 ? '0' + n : '' + n)
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes())
}
function localInputToTs(val?: string) {
if (!val) return 0
return Math.floor(new Date(val).getTime() / 1000)
}
// ---- 表格展示 ----
function facName(c: PublicCode) {
return brandMap[c.brandid] || (c.brandid ? ('品牌商#' + c.brandid) : '-')
}
function prodName(c: PublicCode) {
const prod = allProducts.value.find((p) => p.id === c.productid)
return prod ? (prod.devicename || ('#' + c.productid)) : (c.productid ? ('#' + c.productid) : '-')
}
// ---- 视图切换 ----
function backToCodes() {
view.value = 'list'
loadCodes()
}
// ---- 品牌商切换 ----
function onBrandChange() {
backToCodes()
}
// ---- 公码列表 ----
async function loadCodes() {
loading.value = true
try {
// brandid=0 → 后端不过滤品牌商,返回全部公码
const d = await webApi<{ codes: PublicCode[] }>('getfactorypubliccodes', {
brandid: currentBrandId.value,
status: filterStatus.value,
})
codes.value = d?.codes ?? []
} catch (e: any) {
error(e?.message ?? '获取公码列表失败')
codes.value = []
} finally {
loading.value = false
}
}
function showCodeForm(code?: PublicCode) {
if (!currentBrandId.value) { warn('请先选择品牌商'); return }
if (code) {
isEditing.value = true
form.code = code.code
form.productid = code.productid || 0
form.remark = code.remark || ''
form.bindrewardvpitime = code.bindrewardvpitime || 0
form.bindrewardtranslate = code.bindrewardtranslate || 0
form.bindrewardmeeting = code.bindrewardmeeting || 0
form.maxuses = code.maxuses || 0
form.expire = tsToLocalInput(code.expiretime)
form.disabled = code.status === 1
} else {
isEditing.value = false
form.code = ''
form.productid = 0
form.remark = ''
form.bindrewardvpitime = 0
form.bindrewardtranslate = 0
form.bindrewardmeeting = 0
form.maxuses = 0
form.expire = ''
form.disabled = false
}
view.value = 'form'
}
async function saveCode() {
const common: Record<string, unknown> = {
productid: Number(form.productid) || 0,
remark: form.remark,
maxuses: Number(form.maxuses) || 0,
expiretime: localInputToTs(form.expire),
bindrewardvpitime: Number(form.bindrewardvpitime) || 0,
bindrewardtranslate: Number(form.bindrewardtranslate) || 0,
bindrewardmeeting: Number(form.bindrewardmeeting) || 0,
}
try {
if (isEditing.value) {
common.code = form.code
common.status = form.disabled ? 1 : 0
await webApi('updatefactorypubliccode', common)
success('保存成功')
} else {
common.brandid = currentBrandId.value
const d = await webApi<{ code?: { code?: string } }>('createfactorypubliccode', common)
success('新公码已生成:' + (d?.code?.code || ''))
}
backToCodes()
} catch (e: any) {
error(e?.message ?? '保存失败')
}
}
async function toggleCodeStatus(code: PublicCode) {
const newStatus = code.status === 0 ? 1 : 0
try {
await webApi('updatefactorypubliccode', {
code: code.code,
productid: code.productid || 0,
remark: code.remark || '',
status: newStatus,
maxuses: code.maxuses || 0,
expiretime: code.expiretime || 0,
bindrewardvpitime: code.bindrewardvpitime || 0,
bindrewardtranslate: code.bindrewardtranslate || 0,
bindrewardmeeting: code.bindrewardmeeting || 0,
})
success(newStatus === 1 ? '已停用' : '已启用')
loadCodes()
} catch (e: any) {
error(e?.message ?? '操作失败')
}
}
function delCode(code: string) {
if (!window.confirm(`确定删除公码 "${code}" 吗?删除后该码无法再被领取(已领取记录保留)`)) return
webApi('delfactorypubliccode', { code })
.then(() => { success('删除成功'); loadCodes() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
// =================== 公码二维码 ===================
const qrModal = ref<HTMLDialogElement | null>(null)
const qrCanvas = ref<HTMLElement | null>(null)
const currentQrCode = ref('')
// 动态加载 qrcodejs(保持原版 CDN 方案,不引入新依赖)
const QR_CDN = 'https://cdn.jsdelivr.net/gh/davidshimjs/qrcodejs/qrcode.min.js'
function loadQrLib(): Promise<any> {
return new Promise((resolve, reject) => {
const w = window as any
if (w.QRCode) { resolve(w.QRCode); return }
const s = document.createElement('script')
s.src = QR_CDN
s.onload = () => resolve(w.QRCode)
s.onerror = () => reject(new Error('二维码库加载失败'))
document.head.appendChild(s)
})
}
async function showQrCode(codeStr: string) {
currentQrCode.value = codeStr
qrModal.value?.showModal()
let QRCode: any
try {
QRCode = await loadQrLib()
} catch {
error('二维码库加载失败')
return
}
const box = qrCanvas.value
if (!box || !QRCode) return
box.innerHTML = ''
// eslint-disable-next-line no-new
new QRCode(box, {
text: codeStr,
width: 240,
height: 240,
colorDark: '#1E293B',
colorLight: '#FFFFFF',
correctLevel: QRCode.CorrectLevel.H,
})
}
function getQrDataUrl(): string {
const box = qrCanvas.value
if (!box) return ''
const canvas = box.querySelector('canvas')
if (canvas) return canvas.toDataURL('image/png')
const img = box.querySelector('img')
return img ? img.src : ''
}
function downloadQr() {
const url = getQrDataUrl()
if (!url) { warn('二维码尚未生成'); return }
const a = document.createElement('a')
a.href = url
a.download = 'publiccode_' + currentQrCode.value + '.png'
document.body.appendChild(a); a.click(); document.body.removeChild(a)
}
function printQr() {
const url = getQrDataUrl()
if (!url) { warn('二维码尚未生成'); return }
const w = window.open('', '_blank')
if (!w) { warn('浏览器拦截了打印窗口'); return }
w.document.write(
'<html><head><title>' + currentQrCode.value + '</title>' +
'<style>body{font-family:sans-serif;text-align:center;padding:48px;}' +
'img{width:320px;height:320px;}h2{font-size:36px;letter-spacing:6px;margin:24px 0 8px;}' +
'p{color:#666;font-size:14px;}</style></head><body>' +
'<img src="' + url + '" />' +
'<h2>' + currentQrCode.value + '</h2>' +
'<p>新用户扫码领取奖励</p>' +
'<scr' + 'ipt>window.onload=function(){window.print();}</scr' + 'ipt>' +
'</body></html>'
)
w.document.close()
}
// =================== 初始化 ===================
async function initData() {
// 加载品牌商列表
try {
const fr = await webApi<{ brands: Brand[] }>('getbrands', {})
const facs = fr?.brands ?? []
brands.value = facs
facs.forEach((b) => { brandMap[b.id] = b.brand || ('品牌商#' + b.id) })
} catch {
brands.value = []
}
// 预加载产品列表(用于公码表单下拉 + 表格产品名展示)
try {
const pr = await webApi<{ products: Product[] }>('getproducts', {})
allProducts.value = pr?.products ?? []
} catch {
allProducts.value = []
}
// 默认厂家=全部,直接展示所有公码
loadCodes()
}
onMounted(initData)
</script>

20
apps/admin/app/pages/dashboard.vue

@ -220,12 +220,12 @@ const PANELS_ALL: Panel[] = [
},
]
// 老代理账号(identity=3 且未绑定品牌商):隐藏用户数据 + 锁定 apps/products 绑定范围。
// 注意与「品牌商账号」区分:品牌商/渠道商账号(绑定了 brandid/channelid)按约定要能看到用户与
// 支付金额,且它们的收敛由后端按品牌商/渠道商做,不走 apps/products 那套绑定。
const IS_AGENT = computed(() => auth.identity === 3 && !auth.brandid)
// 渠道分成体系账号(品牌商 / 渠道商):数据范围由后端按归属强制收敛,前端只需隐藏无意义的筛选项。
const IS_SCOPED = computed(() => auth.isBrandAccount || auth.isDealer)
// ⚠️ identity=3(代理/品牌商)已于 2026-09-12 下线,存量账号被后端禁用并降级,
// 所以 IS_AGENT 现在恒为 false。保留这条链路只为让下面几个 filter 的写法维持原样——
// 真要清掉得把 HIDE_FOR_AGENT / globalCards / todayCards / panels 一起简化,属于另一件事。
const IS_AGENT = computed(() => false)
// 渠道分成体系账号(渠道商):数据范围由后端按归属强制收敛,前端只需隐藏无意义的筛选项。
const IS_SCOPED = computed(() => auth.isDealer)
const BOUND_APPS = computed(() => auth.apps)
const BOUND_REGIONS = computed(() => auth.regions)
const BOUND_PRODUCTS = computed(() => auth.products.map((p) => parseInt(p)).filter((n) => !isNaN(n)))
@ -235,11 +235,9 @@ const globalCards = computed(() => IS_AGENT.value ? GLOBAL_CARDS_ALL.filter((c)
const todayCards = computed(() => IS_AGENT.value ? TODAY_CARDS_ALL.filter((c) => !HIDE_FOR_AGENT.includes(c[0])) : TODAY_CARDS_ALL)
const panels = computed(() => IS_AGENT.value ? PANELS_ALL.filter((p) => p.key !== 'user' && p.key !== 'qrcode') : PANELS_ALL)
// 筛选项的「全部」文案。品牌商/渠道商账号的数据范围由后端按归属强制收敛(品牌商=名下渠道商∪产品,
// 渠道商=自己那一个渠道商),下拉里的选项也全部由「品牌商关联产品」推导:
// 应用 = 名下产品绑定的应用(api_getmyapps)、产品 = 名下产品、渠道商 = 名下渠道商。
// 此时再写「全部应用/全部产品」会让人误以为看的是全平台,故按归属改写成「本品牌/本渠道全部 XX」。
const SCOPE_PREFIX = computed(() => auth.isDealer ? '本渠道' : (auth.isBrandAccount ? '本品牌' : ''))
// 筛选项的「全部」文案。渠道商账号的数据范围由后端按归属强制收敛(只看自己那一个渠道商),
// 此时再写「全部应用/全部产品」会让人误以为看的是全平台,故改写成「本渠道全部 XX」。
const SCOPE_PREFIX = computed(() => auth.isDealer ? '本渠道' : '')
function allLabel(noun: string): string {
return SCOPE_PREFIX.value ? `${SCOPE_PREFIX.value}全部${noun}` : `全部${noun}`
}

33
apps/admin/app/pages/device.vue

@ -365,7 +365,6 @@
</div>
<div v-if="preview" class="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 font-mono text-sm leading-7 mb-4">
<div>生产批次(BatchNo):<b class="text-indigo-600">{{ preview.batchno || '生产时分配' }}</b></div>
<div>品牌商:{{ preview.brandname || '-' }}<span v-if="!preview.brandid" class="text-xs text-slate-400 ml-2">(无品牌归属,由所选渠道商推出)</span></div>
<div>渠道商:{{ preview.channelname }}({{ preview.channelid }})</div>
<div>渠道分成比例:<b class="text-indigo-600">{{ (Number(cSharepct) || 0).toFixed(2) }}%</b></div>
<div>代工厂:{{ nameOfFactory(cFactory) }}</div>
@ -480,11 +479,6 @@ import { loadChipVendors, chipVendorName } from '~/utils/chipvendors'
definePageMeta({ title: 'MAC生成管理' })
interface Brand {
id: number
brand: string
contactmails?: string
}
interface Product {
id: number
devicename: string
@ -593,7 +587,6 @@ function switchPageTab(t: 'codes' | 'batches' | 'produce') {
}
// ========== 全局状态 ==========
const brands = ref<Brand[]>([])
const channels = ref<Channel[]>([]) // 全量渠道商(含未启用),前端按品牌商过滤
// 全量代工厂(含已停用):列表列名要能显示历史批次里已停用的工厂,生产下拉再单独筛 enabled
const factories = ref<OemFactory[]>([])
@ -820,8 +813,7 @@ function codeState(d: DeviceRow): { label: string; dot: string; text: string } {
// ========== 初始化 ==========
async function init() {
try {
const [fRes, pRes, cRes, facRes, pvRes] = await Promise.all([
webApi<{ brands: Brand[] }>('getbrands', {}),
const [pRes, cRes, facRes, pvRes] = await Promise.all([
webApi<{ products: Product[] }>('getproducts', {}),
// status=-1 取全部状态:列表列名要能显示历史批次里已停用的渠道商,生产下拉再单独筛 status=1
webApi<{ channels: Channel[] }>('getchannels', { status: -1 }),
@ -830,14 +822,12 @@ async function init() {
// 芯片厂商:生产表单要按「方案商.vendor → 芯片名」展示 CID,拿不到只影响那一栏的显示
loadChipVendors(webApi),
])
brands.value = fRes?.brands ?? []
allProducts.value = pRes?.products ?? []
channels.value = cRes?.channels ?? []
factories.value = facRes?.factories ?? []
providers.value = pvRes?.providers ?? []
} catch (e: any) {
error(e?.message ?? '加载基础数据失败')
brands.value = []
allProducts.value = []
channels.value = []
factories.value = []
@ -1190,7 +1180,7 @@ async function submitCreate() {
success('成功生成 ' + devs.length + ' 条,批次 ' + (data?.batchno || ''))
buildCSV(devs)
// 品牌商由服务端推出,收件人取不到品牌邮箱时回退渠道商邮箱
openEmailDialog(data ?? {}, data?.brandid ?? 0, productId)
openEmailDialog(data ?? {}, productId)
} else {
success('创建成功')
tab.value = 'batches'
@ -1270,23 +1260,25 @@ function downloadBlob(blob: Blob, name: string) {
// ========== 邮件 ==========
// 生产通知的收件人:**方案公司**(产品归属的方案商)的联系邮箱优先——货是方案公司在打,
// MAC 清单也是给它烧录用的。方案公司没填邮箱时才依次退到品牌商、本次所选渠道商,
// 三个都没有就留空让人手填;没有回退的话表单会经常是空的,运营每次都得自己查邮箱。
function productionMailTo(productId: number, brandMails?: string): string[] {
// MAC 清单也是给它烧录用的。方案公司没填邮箱时退到本次所选渠道商,
// 两个都没有就留空让人手填;没有回退的话表单会经常是空的,运营每次都得自己查邮箱。
//
// ⚠️ 2026-09-12 品牌商下线前,中间还有一路「品牌商联系邮箱」。brand 表已删,这一路没了——
// 原先只靠品牌商邮箱收生产通知的场景,现在要么给方案商补邮箱,要么给渠道商补。
function productionMailTo(productId: number): string[] {
const p = allProducts.value.find((x) => x.id === productId)
const provider = p?.providerid ? providers.value.find((v) => v.id === p.providerid) : undefined
const channel = channels.value.find((ch) => ch.id === cChannel.value)
const raw = [provider?.contactmails, brandMails, channel?.contactmails]
const raw = [provider?.contactmails, channel?.contactmails]
.map((x) => (x || '').trim())
.find((x) => x !== '')
return (raw || '').split(',').map((e) => e.trim()).filter(Boolean)
}
function openEmailDialog(createResult: CreateResult, brandId: number, productId: number) {
const brand = brands.value.find((f) => f.id === brandId)
function openEmailDialog(createResult: CreateResult, productId: number) {
const product = allProducts.value.find((p) => p.id === productId)
const productName = product ? product.devicename : '未知产品(ID:' + productId + ')'
const emails = productionMailTo(productId, brand?.contactmails)
const emails = productionMailTo(productId)
email.to = emails[0] || ''
email.cc = emails.length > 1 ? emails.slice(1).join(',') : ''
@ -1315,11 +1307,10 @@ function openEmailForList() {
if (devices.value.length === 0) return
buildCSV(devices.value)
const first = devices.value[0]
const brand = brands.value.find((f) => f.id === first.brandid)
const product = allProducts.value.find((p) => p.id === first.productid)
const productName = product ? product.devicename : ''
const emails = productionMailTo(first.productid, brand?.contactmails)
const emails = productionMailTo(first.productid)
email.to = emails[0] || ''
email.cc = emails.length > 1 ? emails.slice(1).join(',') : ''

2
apps/admin/app/pages/environments.vue

@ -75,7 +75,7 @@
<div class="grid grid-cols-3 gap-4">
<div class="form-control col-span-2">
<label class="label"><span class="label-text font-medium">SSH 主机</span></label>
<input v-model="form.ssh_host" type="text" class="input input-bordered w-full" placeholder="139.224.212.237" @input="resetTest" />
<input v-model="form.ssh_host" type="text" class="input input-bordered w-full" placeholder="如 10.0.0.1" @input="resetTest" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">端口</span></label>

354
apps/admin/app/pages/factory.vue

@ -1,354 +0,0 @@
<template>
<div>
<!-- 列表视图 -->
<div v-show="view === 'list'">
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-slate-800">品牌商列表</h2>
<button class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增品牌商</button>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th class="cursor-pointer select-none" @click="toggleSort('id')">ID{{ sortMark('id') }}</th>
<th class="cursor-pointer select-none" @click="toggleSort('brand')">品牌名称{{ sortMark('brand') }}</th>
<th class="cursor-pointer select-none" @click="toggleSort('sharerate')">分成比例{{ sortMark('sharerate') }}</th>
<th>联系邮箱</th><th>产品</th>
<th class="cursor-pointer select-none" @click="toggleSort('createtime')">创建时间{{ sortMark('createtime') }}</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
</tr>
<tr v-else-if="!viewRows.length">
<td colspan="7" class="text-center text-slate-400 py-8">暂无数据</td>
</tr>
<tr v-for="b in viewRows" :key="b.id">
<td class="font-mono text-sm">{{ hexId(b.id) }}</td>
<td class="font-medium">{{ b.brand }}</td>
<td class="text-sm">{{ ratePct(b.sharerate) }}</td>
<td class="text-sm">
<template v-if="mailList(b.contactmails).length">
<span v-for="(e, i) in mailList(b.contactmails)" :key="i" class="badge badge-ghost badge-sm mr-1">{{ e }}</span>
</template>
<span v-else class="text-slate-300">-</span>
</td>
<td class="text-sm text-slate-500">{{ b.productlists || '' }}</td>
<td class="text-xs text-slate-500">{{ formatTs(b.createtime) }}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs" @click="showForm(b)">编辑</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delBrand(b)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 表单视图 -->
<div v-show="view === 'form'">
<div class="flex items-center gap-3 mb-5">
<button class="btn btn-ghost btn-sm" @click="showList()">&larr; 返回列表</button>
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑品牌商' : '新增品牌商' }}</h2>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl">
<form @submit.prevent="saveBrand">
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">品牌名称 *</span></label>
<input v-model="form.brand" type="text" class="input input-bordered w-full" required />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">分成比例(%)</span></label>
<input v-model.number="form.sharepct" type="number" step="0.01" min="0" max="100" class="input input-bordered w-full" />
<span class="label-text-alt text-slate-400 mt-1">平台从订单实付里分给该品牌商的总提成;其名下渠道商再从这份里切自己的比例。</span>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">描述</span></label>
<textarea v-model="form.description" class="textarea textarea-bordered w-full" rows="3"></textarea>
</div>
<!-- 多邮箱管理 -->
<div class="form-control mb-6">
<label class="label"><span class="label-text font-medium">联系邮箱</span></label>
<div class="space-y-2">
<p v-if="!emails.length" class="text-xs text-slate-400">暂无邮箱</p>
<div v-for="(e, i) in emails" :key="i" class="flex items-center gap-2">
<span class="badge badge-lg gap-1 bg-slate-100 border-slate-200">
<svg class="h-3 w-3 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{{ e }}
</span>
<button type="button" class="btn btn-ghost btn-xs text-red-400 hover:text-red-600" @click="removeEmail(i)">×</button>
</div>
</div>
<div class="flex gap-2 mt-2">
<input v-model="newEmail" type="email" class="input input-bordered input-sm flex-1" placeholder="输入邮箱地址" @keydown.enter.prevent="addEmail" />
<button type="button" class="btn btn-outline btn-sm btn-primary" @click="addEmail">添加</button>
</div>
</div>
<!-- 官方渠道:新增时必建,代表品牌自营的销售线,不配渠道账号 -->
<div v-if="!isEditing" class="form-control mb-6 border-t border-slate-100 pt-4">
<label class="label"><span class="label-text font-medium">品牌官方渠道</span></label>
<input v-model="form.officialChannelName" type="text" class="input input-bordered w-full" :placeholder="defaultOfficialName" />
<span class="label-text-alt text-slate-400 mt-1">
新建品牌商时自动创建一条该品牌自己的渠道(直接启用、分成 0%、不创建渠道账号),用于品牌直销的授权码归属;留空则叫「{{ defaultOfficialName }}」。
</span>
</div>
<!-- 同时创建品牌账号(超管/管理员) -->
<div v-if="!isEditing && auth.isAdmin" class="form-control mb-6 border-t border-slate-100 pt-4">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="form.createAccount" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span class="label-text font-medium">同时创建品牌账号</span>
</label>
<span class="label-text-alt text-slate-400 mt-1">
建一个绑定本品牌商的「品牌商」角色登录账号,可见本品牌及名下全部渠道商的数据。
用户名/密码留空则自动生成,创建后凭据会邮件发给上面填的品牌商联系邮箱。
</span>
<div v-if="form.createAccount" class="grid grid-cols-2 gap-3 mt-3">
<div>
<label class="text-xs text-slate-500">登录用户名</label>
<input
v-model="form.accountUsername" type="text" class="input input-bordered input-sm w-full"
placeholder="留空自动生成,如 Brand-A001" autocomplete="off" />
</div>
<div>
<label class="text-xs text-slate-500">登录密码</label>
<input
v-model="form.accountPassword" type="text" class="input input-bordered input-sm w-full font-mono"
placeholder="留空自动生成 12 位随机密码" autocomplete="off" />
</div>
<div class="col-span-2">
<label class="text-xs text-slate-500">备注</label>
<input v-model="form.accountRemark" type="text" class="input input-bordered input-sm w-full" placeholder="可选" />
</div>
<p v-if="!credentialMails.length" class="col-span-2 text-xs text-amber-600">
当前没有填品牌商自己的联系邮箱(平台默认邮箱不算),凭据将无法邮件送达,只会在创建后弹窗显示一次。
</p>
</div>
</div>
<div class="flex gap-3">
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存</button>
</div>
</form>
</div>
</div>
<!-- 新开品牌账号的凭据回执 -->
<AccountCredentialDialog :info="cred" @close="cred = null" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useAuthStore } from '~/stores/auth'
import { useTableSort } from '~/composables/useTableSort'
import type { AccountCredential } from '~/components/AccountCredentialDialog.vue'
definePageMeta({ title: '品牌商管理' })
interface Brand {
id: number
brand: string
description: string
contactmails: string
productlists: string
sharerate: number // 万分比
createtime?: number
}
// addbrand 的返回:品牌商本体 + 自动建的官方渠道 + (可选)品牌账号凭据
interface AddBrandResp {
channel?: { id: string; name: string }
account?: string
account_password?: string
mail_to?: string
mail_error?: string
}
const { webApi } = useApi()
const { success, error, warn } = useToast()
const auth = useAuthStore()
const view = ref<'list' | 'form'>('list')
const rows = ref<Brand[]>([])
const loading = ref(false)
const submitting = ref(false)
const isEditing = ref(false)
const emails = ref<string[]>([])
const newEmail = ref('')
// 比例在表单里用百分比(更直观),提交时换算成后端的万分比整数
const form = reactive({
id: 0, brand: '', description: '', sharepct: 0,
// 仅新增时用:官方渠道名称 + 顺手开一个品牌商账号
officialChannelName: '',
createAccount: false,
accountUsername: '',
accountPassword: '',
accountRemark: '',
})
// 官方渠道默认名(与后端 addOfficialChannel 的兜底一致)
const defaultOfficialName = computed(() => (form.brand.trim() || '品牌') + '官方渠道')
// 能收凭据邮件的邮箱:平台默认邮箱不算(它是为收授权码清单强行塞进列表的,不是品牌商的人)。
// 与后端 credentialMails 同口径。
const PLATFORM_MAIL = '358030594@qq.com'
const credentialMails = computed(() =>
emails.value.filter((e) => e.toLowerCase() !== PLATFORM_MAIL),
)
// 新开账号的凭据回执(密码只有这一次能看到)
const cred = ref<AccountCredential | null>(null)
function formatTs(ts?: number) {
if (!ts) return '-'
const d = new Date(ts * 1000)
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') +
' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
}
// 列表排序:默认按品牌商 ID 升序,表头可切
const { toggleSort, sortMark, applySort } = useTableSort<Brand>({
id: (a, b) => Number(a.id) - Number(b.id),
brand: (a, b) => String(a.brand || '').localeCompare(String(b.brand || ''), 'zh') || Number(a.id) - Number(b.id),
sharerate: (a, b) => Number(a.sharerate || 0) - Number(b.sharerate || 0) || Number(a.id) - Number(b.id),
// 历史品牌商没有创建时间(0),相等时退回按 ID
createtime: (a, b) => Number(a.createtime || 0) - Number(b.createtime || 0) || Number(a.id) - Number(b.id),
}, 'id')
const viewRows = computed(() => applySort(rows.value))
function ratePct(rate?: number) {
return ((Number(rate) || 0) / 100).toFixed(2) + '%'
}
function hexId(id: number) {
return id ? Number(id).toString(16).toUpperCase() : ''
}
function mailList(s?: string) {
return (s || '').split(',').map((e) => e.trim()).filter(Boolean)
}
async function loadData() {
loading.value = true
try {
const data = await webApi<{ brands: Brand[] }>('getbrands', {})
rows.value = data?.brands ?? []
} catch (e: any) {
error(e?.message ?? '获取品牌商列表失败')
rows.value = []
} finally {
loading.value = false
}
}
function showList() {
view.value = 'list'
loadData()
}
function showForm(b?: Brand) {
if (b) {
isEditing.value = true
form.id = b.id
form.brand = b.brand || ''
form.description = b.description || ''
form.sharepct = (Number(b.sharerate) || 0) / 100
emails.value = mailList(b.contactmails)
} else {
isEditing.value = false
form.id = 0
form.brand = ''
form.description = ''
form.sharepct = 0
emails.value = []
}
form.officialChannelName = ''
form.createAccount = false
form.accountUsername = ''
form.accountPassword = ''
form.accountRemark = ''
newEmail.value = ''
view.value = 'form'
}
function addEmail() {
const val = newEmail.value.trim()
if (!val) return
if (emails.value.includes(val)) { warn('邮箱已存在'); return }
emails.value.push(val)
newEmail.value = ''
}
function removeEmail(idx: number) {
emails.value.splice(idx, 1)
}
async function saveBrand() {
submitting.value = true
try {
const payload: Record<string, unknown> = {
brand: {
brand: form.brand,
description: form.description,
sharerate: Math.round((Number(form.sharepct) || 0) * 100),
contactmails: emails.value.join(','),
} as Record<string, unknown>,
}
if (isEditing.value) {
;(payload.brand as Record<string, unknown>).id = Number(form.id)
} else {
// 新增才带:官方渠道名 + 是否顺手开品牌账号
payload.official_channel_name = form.officialChannelName.trim()
payload.create_account = form.createAccount
if (form.createAccount) {
// 用户名/密码留空 = 交给服务端生成(用户名按品牌 id 推导,密码随机)
payload.account_username = form.accountUsername.trim()
payload.account_password = form.accountPassword
payload.account_remark = form.accountRemark.trim()
}
}
const d = await webApi<AddBrandResp>(isEditing.value ? 'updatebrand' : 'addbrand', payload)
if (isEditing.value) {
success('保存成功')
} else {
const parts = ['品牌商已创建']
if (d?.channel?.id) parts.push(`官方渠道「${d.channel.name}」(${d.channel.id})`)
success(parts.join(','))
if (d?.account) {
cred.value = {
title: `品牌账号「${d.account}」已创建`,
username: d.account,
password: d.account_password ?? '',
mailTo: d.mail_to ?? '',
mailError: d.mail_error ?? '',
}
}
}
showList()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
function delBrand(b: Brand) {
if (!window.confirm(`确定删除品牌商 "${b.brand}" 吗?`)) return
webApi('delbrand', { id: b.id })
.then(() => { success('删除成功'); loadData() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
onMounted(loadData)
</script>

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

@ -1,234 +1,16 @@
<template>
<div>
<!-- 列表视图 -->
<div v-show="view === 'list'">
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-lg font-semibold text-slate-800">全局环境配置</h2>
<p class="text-xs text-slate-400 mt-1">全局第三方服务配置(AI / 翻译等),按区域分组,所有应用共享。修改后各服务约 10 分钟内自动同步。</p>
</div>
<button class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增配置</button>
</div>
<div class="flex items-center gap-3 mb-4">
<span class="text-sm text-slate-500">区域</span>
<select v-model.number="filterRegion" class="select select-bordered select-sm" @change="loadData">
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
<input v-model="keyword" class="input input-bordered input-sm w-48" placeholder="按分组/键搜索" />
</div>
<div 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="showForm(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>
<!-- 表单视图 -->
<div v-show="view === 'form'">
<div class="flex items-center gap-3 mb-5">
<button class="btn btn-ghost btn-sm" @click="showList()">&larr; 返回列表</button>
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑配置' : '新增配置' }}</h2>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl">
<form @submit.prevent="saveCfg">
<div class="grid grid-cols-2 gap-4">
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">区域 *</span></label>
<select v-model.number="form.region" class="select select-bordered w-full">
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">分组 (group)</span></label>
<input v-model="form.group" type="text" class="input input-bordered w-full" placeholder="如 ai / translate / asr" />
</div>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">键 (key) *</span></label>
<input v-model="form.key" type="text" class="input input-bordered w-full" required placeholder="如 openai_api_key" />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">值 (value)</span></label>
<textarea v-model="form.value" class="textarea textarea-bordered w-full font-mono text-sm" rows="3"></textarea>
</div>
<div class="form-control mb-6">
<label class="label"><span class="label-text font-medium">描述</span></label>
<input v-model="form.description" type="text" class="input input-bordered w-full" />
</div>
<div class="flex gap-3">
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存</button>
</div>
</form>
</div>
</div>
<div class="py-16 text-center text-slate-400">
<span class="loading loading-spinner loading-sm"></span>
<p class="text-sm mt-3">本页已并入「服务与环境配置」,正在跳转…</p>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '全局环境配置' })
// 区域枚举(与 pb.Region 一一对应),按区域分组管理,始终选定一个区域,不带「全部区域」
const REGIONS = [
{ value: 1, label: '中国' }, { value: 2, label: '美国' }, { value: 3, label: '东亚' },
{ value: 4, label: '东南亚' }, { value: 5, label: '南亚' }, { value: 6, label: '中东' },
{ value: 7, label: '欧洲' }, { value: 8, label: '俄罗斯' }, { value: 9, label: '非洲' },
{ value: 10, label: '南美' }, { value: 11, label: '北美' }, { value: 12, label: '大洋洲' },
{ value: 13, label: '巴西' }, { value: 14, label: '印度' },
]
interface GlobalConfig {
id: number
region: number
group: string
key: string
value: string
description: string
}
const { webApi } = useApi()
const { success, error } = useToast()
const view = ref<'list' | 'form'>('list')
const items = ref<GlobalConfig[]>([])
const loading = ref(false)
const submitting = ref(false)
const isEditing = ref(false)
const filterRegion = ref(1)
const keyword = ref('')
const form = reactive({ id: 0, region: 1, group: '', key: '', value: '', description: '' })
// 关键字过滤(按 group + key)
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, GlobalConfig[]> = {}
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] }))
// 2026-09-12:「全局环境配置」下线(凭据统一收进第三方服务配置),
// 「应用环境配置」并入「服务与环境配置」的「应用参数」标签页。
// 保留本路由做重定向,兼容旧书签/直链。
definePageMeta({ title: '服务与环境配置' })
onMounted(() => {
navigateTo('/serviceconfig')
})
async function loadData() {
loading.value = true
try {
const data = await webApi<{ items: GlobalConfig[] }>('getglobalconfigs', { region: filterRegion.value })
items.value = data?.items ?? []
} catch (e: any) {
error(e?.message ?? '获取配置失败')
items.value = []
} finally {
loading.value = false
}
}
function showList() {
view.value = 'list'
loadData()
}
function showForm(cfg?: GlobalConfig) {
if (cfg) {
isEditing.value = true
form.id = cfg.id || 0
form.region = cfg.region || 1
form.group = cfg.group || ''
form.key = cfg.key || ''
form.value = cfg.value || ''
form.description = cfg.description || ''
} else {
isEditing.value = false
form.id = 0
// 新增时默认沿用当前筛选区域(回退到中国)
form.region = filterRegion.value > 0 ? filterRegion.value : 1
form.group = ''
form.key = ''
form.value = ''
form.description = ''
}
view.value = 'form'
}
async function saveCfg() {
submitting.value = true
try {
const data: Record<string, unknown> = {
region: Number(form.region) || 0,
group: form.group.trim(),
key: form.key.trim(),
value: form.value,
description: form.description.trim(),
}
if (isEditing.value) {
data.id = Number(form.id)
await webApi('updateglobalconfig', data)
} else {
await webApi('addglobalconfig', data)
}
success('保存成功')
showList()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
function delCfg(c: GlobalConfig) {
if (!window.confirm('确定删除该配置项吗?')) return
webApi('delglobalconfig', { ids: [c.id] })
.then(() => { success('删除成功'); loadData() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
onMounted(loadData)
</script>

629
apps/admin/app/pages/meetingsvc.vue

@ -1,629 +0,0 @@
<template>
<div>
<!-- 顶部:标题 + 作用域 -->
<div class="flex items-start justify-between mb-4 flex-wrap gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-800">会议记录服务</h2>
<p class="text-xs text-slate-400 mt-1 max-w-3xl">
识别 / 翻译 / 总结三段,从<b>第三方服务配置</b>里选已配好的服务来编排(不再单独填凭据)。
<span class="text-slate-500">先配「全局默认」作兜底,再按应用 / 区域覆盖</span>——没配覆盖的应用自动继承全局默认。
</p>
</div>
</div>
<!-- 作用域选择器 -->
<div class="scopebar">
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500 font-semibold">作用域</span>
<select v-model="scopeApp" class="select select-bordered select-sm" @change="loadOrch">
<option value="">全局默认(兜底)</option>
<option v-for="a in apps" :key="a" :value="a">应用:{{ a }}</option>
</select>
<select v-model.number="scopeRegion" class="select select-bordered select-sm" @change="loadOrch">
<option :value="0">全区域</option>
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</div>
<span class="text-[11px] text-slate-400">
{{ scopeApp ? `正在编辑「${scopeApp}」` : '正在编辑全局默认' }}{{ scopeRegion ? ` · ${regionLabel(scopeRegion)}` : ' · 全区域' }}
<template v-if="isOverride">(覆盖全局默认)</template>
</span>
</div>
<div v-if="loading" class="bg-white rounded-xl border border-slate-100 py-16 text-center">
<span class="loading loading-spinner loading-sm"></span>
</div>
<template v-else>
<!-- 支持语言汇总:同段并集、跨段交集 -->
<div class="langsum" :class="{ bad: !langSum.unlimited && !langSum.langs.length, warn: langSum.missing.length }">
<div class="langsum-head">
<span class="langsum-title">当前配置支持的语言</span>
<span v-if="langSum.unlimited" class="langsum-count" :class="{ off: langSum.missing.length }">未限定(各引擎自行判断)</span>
<span v-else class="langsum-count" :class="{ off: langSum.missing.length }">
{{ langSum.langs.length }} 种<template v-if="langSum.missing.length">(链路未配全,尚不可用)</template>
</span>
<span class="langsum-formula">{{ langSumFormula }}</span>
</div>
<div v-if="langSum.missing.length" class="langsum-msg bad">
⚠ {{ langSum.missing.map(kindLabel).join('、') }}段没有启用中的服务,整条链路走不通——先把这些段配上。
</div>
<div v-else-if="!langSum.unlimited && !langSum.langs.length" class="langsum-msg bad">
⚠ 各段语言无交集,任何语种都走不完整条链路。放宽某一段的支持语言,或补一个覆盖面更广的服务。
</div>
<div v-if="langSum.langs.length" class="langsum-langs">
<span v-for="l in langSum.langs" :key="l" class="lang on" :title="langName(l)">{{ l }}</span>
</div>
<div v-else-if="langSum.unlimited && !langSum.missing.length" class="langsum-msg">
各段都没限定支持语言(服务未填 languages 字段),实际能否处理由引擎判断。
</div>
</div>
<!-- 无可用服务提示 -->
<div v-if="!available.length" class="alert alert-warning py-2 text-sm mb-4">
<span>
该作用域没有可选服务。先去<b>第三方服务配置</b>添加「录音文件识别 / 机器翻译 / 文本或多媒体大模型」类的服务
(全局默认或该应用作用域),这里才能选。
</span>
</div>
<!-- ============ 识别 ============ -->
<div class="card">
<div class="card-head">
<div>
<div class="card-title">识别(录音文件转写)</div>
<div class="card-desc">按顺序找<b>第一个「已启用 + 支持源语言」</b>的服务,不支持则跳到下一个。把便宜的排前面即"优先用性价比高的"。</div>
</div>
<div class="flex gap-2 shrink-0">
<button class="btn btn-xs" :disabled="ofKind(1).length < 2 || busy" @click="sortByCost(1)">⇅ 按参考单价重排</button>
<button class="btn btn-primary btn-xs text-white" :disabled="!addable(1).length" @click="openAdd(1)">+ 添加识别服务</button>
</div>
</div>
<div class="probe-bar">
<span class="text-xs text-slate-500 font-semibold">选路预览</span>
<select v-model="probeLang" class="select select-bordered select-xs w-44 text-xs">
<option v-for="o in LANG_OPTIONS" :key="o.code" :value="o.code">{{ o.code }} · {{ o.name }}</option>
</select>
<span v-if="asrHit" class="probe-out">命中 {{ svcName(asrHit.svc_id) }}<template v-if="asrHit.cost_hint"> · ¥{{ asrHit.cost_hint }}/时</template></span>
<span v-else class="probe-out bad">无可用服务 —— {{ langName(probeLang) }} 会转写失败</span>
</div>
<div v-if="!ofKind(1).length" class="py-10 text-center text-slate-400 text-sm">暂无识别服务,点右上「+ 添加识别服务」</div>
<div v-for="(o, i) in ofKind(1)" :key="o.row_id" class="svc asr" :class="{ hit: asrHit && asrHit.row_id === o.row_id, off: !o.enabled }">
<div class="svc-rank" :class="{ hit: asrHit && asrHit.row_id === o.row_id }">{{ i + 1 }}</div>
<div class="min-w-0">
<div class="svc-name" :class="{ skip: asrSkipped(o) }">{{ svcName(o.svc_id) }}</div>
<div class="svc-prov">{{ svcProvider(o.svc_id) }} · {{ o.svc_id }}</div>
</div>
<div class="flex flex-wrap gap-1">
<template v-if="svcLangs(o.svc_id)">
<span v-for="l in svcLangs(o.svc_id).split(',').slice(0, 5)" :key="l" class="lang" :class="{ on: l === probeLang }">{{ l }}</span>
<span v-if="svcLangs(o.svc_id).split(',').length > 5" class="lang">+{{ svcLangs(o.svc_id).split(',').length - 5 }}</span>
</template>
<span v-else class="text-[11px] text-slate-400">未限定</span>
</div>
<div class="svc-cost"><template v-if="o.cost_hint">¥{{ o.cost_hint }}<small> /时</small></template><span v-else class="text-slate-300">—</span></div>
<div><input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.enabled" :disabled="busy" @change="patch(o, { enabled: !o.enabled })" /></div>
<div class="svc-acts">
<button class="arrow" :disabled="i === 0 || busy" aria-label="上移" @click="move(1, i, -1)">↑</button>
<button class="arrow" :disabled="i === ofKind(1).length - 1 || busy" aria-label="下移" @click="move(1, i, 1)">↓</button>
<button class="btn btn-ghost btn-xs" @click="openEditCost(o)">单价</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="del(o)">移除</button>
</div>
<div class="svc-why" :class="asrWhyClass(o)">{{ asrWhy(o) }}</div>
</div>
</div>
<!-- ============ 翻译 ============ -->
<div class="card mt-4">
<div class="card-head">
<div>
<div class="card-title">翻译</div>
<div class="card-desc">按顺序取<b>第一个已启用</b>的服务;调用失败自动回退下一个。</div>
</div>
<div class="flex gap-2 shrink-0">
<button class="btn btn-xs" :disabled="ofKind(2).length < 2 || busy" @click="sortByCost(2)">⇅ 按参考单价重排</button>
<button class="btn btn-primary btn-xs text-white" :disabled="!addable(2).length" @click="openAdd(2)">+ 添加翻译服务</button>
</div>
</div>
<div v-if="!ofKind(2).length" class="py-10 text-center text-slate-400 text-sm">暂无翻译服务</div>
<div v-for="(o, i) in ofKind(2)" :key="o.row_id" class="svc mt" :class="{ hit: mtHit && mtHit.row_id === o.row_id, off: !o.enabled }">
<div class="svc-rank" :class="{ hit: mtHit && mtHit.row_id === o.row_id }">{{ i + 1 }}</div>
<div class="min-w-0">
<div class="svc-name">{{ svcName(o.svc_id) }}</div>
<div class="svc-prov">{{ svcProvider(o.svc_id) }} · {{ o.svc_id }}</div>
</div>
<div class="svc-cost"><template v-if="o.cost_hint">¥{{ o.cost_hint }}<small> /百万字符</small></template><span v-else class="text-slate-300">—</span></div>
<div><input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.enabled" :disabled="busy" @change="patch(o, { enabled: !o.enabled })" /></div>
<div class="svc-acts">
<button class="arrow" :disabled="i === 0 || busy" aria-label="上移" @click="move(2, i, -1)">↑</button>
<button class="arrow" :disabled="i === ofKind(2).length - 1 || busy" aria-label="下移" @click="move(2, i, 1)">↓</button>
<button class="btn btn-ghost btn-xs" @click="openEditCost(o)">单价</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="del(o)">移除</button>
</div>
<div class="svc-why" :class="!o.enabled ? 'no' : (mtHit && mtHit.row_id === o.row_id ? 'ok' : '')">
<template v-if="!o.enabled">已停用,跳过</template>
<template v-else-if="mtHit && mtHit.row_id === o.row_id">✓ 默认由它承接,失败顺延</template>
<template v-else>备用:前面失败时接手</template>
</div>
</div>
</div>
<!-- ============ 总结 ============ -->
<div class="card mt-4">
<div class="card-head">
<div>
<div class="card-title">总结(大模型)</div>
<div class="card-desc">后台指定默认模型,客户端可在允许范围内自选。带 🖼 的是<b>多媒体大模型</b>,能识别会议里的图片。</div>
</div>
<button class="btn btn-primary btn-xs text-white shrink-0" :disabled="!addable(3).length" @click="openAdd(3)">+ 添加模型</button>
</div>
<div class="mode-bar">
<label class="flex items-center gap-2 cursor-pointer shrink-0">
<input type="checkbox" class="toggle toggle-primary toggle-sm" :checked="llmClientChoice" :disabled="busy" @change="toggleClientChoice" />
<span class="text-sm font-semibold text-slate-700">允许客户端指定模型</span>
</label>
<p class="mode-txt">
<template v-if="llmClientChoice">
前端能选 <b>{{ pickable.length }}</b> 个模型<template v-if="pickable.length">:{{ pickable.map((x) => svcName(x.svc_id)).join('、') }}</template>
<template v-else>(一个都没勾「前端可见」,等同强制默认)</template>。不传则用默认 <b>{{ llmDefault ? svcName(llmDefault.svc_id) : '—' }}</b>。
</template>
<template v-else>
前端不下发模型列表,一律走默认模型 <b>{{ llmDefault ? svcName(llmDefault.svc_id) : '—' }}</b>;客户端传了也忽略。
</template>
</p>
</div>
<div v-if="!ofKind(3).length" class="py-10 text-center text-slate-400 text-sm">暂无模型</div>
<div v-for="o in ofKind(3)" :key="o.row_id" class="svc llm" :class="{ isdefault: o.is_default, off: !o.enabled }">
<div class="grid place-items-center">
<input type="radio" name="llmdef" class="radio radio-primary radio-xs" :checked="o.is_default" :disabled="!o.enabled || busy" @change="patch(o, { is_default: true })" />
</div>
<div class="min-w-0">
<div class="svc-name">
{{ svcName(o.svc_id) }}
<span v-if="isVision(o.svc_id)" title="多媒体大模型,能识图">🖼</span>
<span v-if="o.is_default" class="chip chip-app ml-1">默认</span>
</div>
<div class="svc-prov">{{ svcProvider(o.svc_id) }} · {{ o.svc_id }}</div>
</div>
<div class="svc-cost"><template v-if="o.cost_hint">¥{{ o.cost_hint }}<small> /百万token</small></template><span v-else class="text-slate-300">—</span></div>
<div>
<input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.client_visible" :disabled="!llmClientChoice || !o.enabled || busy"
:title="!llmClientChoice ? '先打开「允许客户端指定模型」' : ''" @change="patch(o, { client_visible: !o.client_visible })" />
</div>
<div><input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.enabled" :disabled="busy" @change="patch(o, { enabled: !o.enabled })" /></div>
<div class="svc-acts">
<button class="btn btn-ghost btn-xs" @click="openEditCost(o)">单价</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="del(o)">移除</button>
</div>
</div>
<div class="col-h llm-h"><div></div><div></div><div>参考单价</div><div>前端可见</div><div>启用</div><div></div></div>
</div>
</template>
<!-- 添加服务弹窗 -->
<dialog ref="addDlg" class="modal">
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg">添加{{ kindLabel(addKind) }}服务</h3>
<p class="text-xs text-slate-400 mt-1">从第三方服务配置里选一个已配好的服务加入本段编排。</p>
<div class="mt-4 space-y-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">服务</span></label>
<select v-model="addForm.svc_id" class="select select-bordered select-sm">
<option value="">请选择服务…</option>
<option v-for="s in addable(addKind)" :key="s.id" :value="s.id">
{{ s.name }}{{ addKind === 3 && catsHas(s.categories, 9) ? ' 🖼' : '' }}({{ s.provider || s.id }})
</option>
</select>
<span v-if="!addable(addKind).length" class="text-[11px] text-amber-600 mt-1">没有可添加的服务了(都加过了,或服务池里没这类服务)。</span>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">参考单价({{ costUnit(addKind) }},可空)</span></label>
<input v-model.number="addForm.cost_hint" type="number" step="0.01" min="0" class="input input-bordered input-sm font-mono text-xs" placeholder="0" />
<span class="text-[11px] text-slate-400 mt-1">仅用于排序/展示,填你的实付价。</span>
</div>
<div v-if="addKind === 3" class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer"><input v-model="addForm.is_default" type="checkbox" class="toggle toggle-primary toggle-sm" /><span class="text-sm">设为默认</span></label>
<label class="flex items-center gap-2 cursor-pointer"><input v-model="addForm.client_visible" type="checkbox" class="toggle toggle-success toggle-sm" /><span class="text-sm">前端可见</span></label>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" @click="addDlg?.close()">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="!addForm.svc_id || saving" @click="doAdd">{{ saving ? '添加中…' : '添加' }}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- 改单价弹窗 -->
<dialog ref="costDlg" class="modal">
<div class="modal-box max-w-sm">
<h3 class="font-bold text-lg">参考单价</h3>
<p class="text-xs text-slate-400 mt-1">{{ costEdit.name }}({{ costUnit(costEdit.kind) }})</p>
<input v-model.number="costEdit.cost_hint" type="number" step="0.01" min="0" class="input input-bordered input-sm w-full font-mono text-xs mt-3" placeholder="0" />
<div class="modal-action">
<button class="btn btn-ghost btn-sm" @click="costDlg?.close()">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="saving" @click="doSaveCost">保存</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { LANG_OPTIONS } from '~/composables/useLangOptions'
definePageMeta({ title: '会议记录服务' })
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
const REGIONS = [
{ value: 1, label: '中国' }, { value: 2, label: '美国' }, { value: 3, label: '东亚' },
{ value: 4, label: '东南亚' }, { value: 5, label: '南亚' }, { value: 6, label: '中东' },
{ value: 7, label: '欧洲' }, { value: 8, label: '俄罗斯' }, { value: 9, label: '非洲' },
{ value: 10, label: '南美' }, { value: 11, label: '北美' }, { value: 12, label: '大洋洲' },
{ value: 13, label: '巴西' }, { value: 14, label: '印度' },
]
function regionLabel(v: number) { return REGIONS.find((r) => r.value === v)?.label ?? '区域' + v }
interface SvcView { id: string; name: string; provider: string; categories: string; languages: string }
interface Orch {
row_id: number; app_name: string; region: number; kind: number; svc_id: string
priority: number; is_default: boolean; client_visible: boolean; cost_hint: number; enabled: boolean
}
const apps = ref<string[]>([])
const scopeApp = ref('')
const scopeRegion = ref(0)
const items = ref<Orch[]>([])
const available = ref<SvcView[]>([])
const kindCats = ref<Record<string, number[]>>({ '1': [8], '2': [3], '3': [5, 9] })
const llmClientChoice = ref(false)
const isOverride = ref(false)
const loading = ref(false)
const saving = ref(false)
const busy = ref(false)
const probeLang = ref('zh-CN')
const addDlg = ref<HTMLDialogElement | null>(null)
const costDlg = ref<HTMLDialogElement | null>(null)
const addKind = ref(1)
const addForm = reactive({ svc_id: '', cost_hint: 0, is_default: false, client_visible: false })
const costEdit = reactive({ row_id: 0, name: '', kind: 1, cost_hint: 0 })
function kindLabel(k: number) { return k === 1 ? '识别' : k === 2 ? '翻译' : '总结' }
function costUnit(k: number) { return k === 1 ? '元/小时' : k === 2 ? '元/百万字符' : '元/百万token' }
function langName(code: string) { return LANG_OPTIONS.find((o) => o.code === code)?.name ?? code }
function catsHas(cats: string, v: number) { return (cats || '').split(',').map((x) => Number(x.trim())).includes(v) }
const svcMap = computed(() => {
const m = new Map<string, SvcView>()
for (const s of available.value) m.set(s.id, s)
return m
})
function svcName(id: string) { return svcMap.value.get(id)?.name ?? id }
function svcProvider(id: string) { return svcMap.value.get(id)?.provider ?? '?' }
function svcLangs(id: string) { return svcMap.value.get(id)?.languages ?? '' }
function isVision(id: string) { return catsHas(svcMap.value.get(id)?.categories ?? '', 9) }
// 某段的编排(按优先级)
function ofKind(k: number) {
return items.value.filter((o) => o.kind === k).sort((a, b) => a.priority - b.priority)
}
// 某段可添加的服务 = 可选服务里 categories 命中该段类别、且还没加进本段编排
function addable(k: number): SvcView[] {
const cats = kindCats.value[String(k)] ?? []
const used = new Set(items.value.filter((o) => o.kind === k).map((o) => o.svc_id))
return available.value.filter((s) => !used.has(s.id) && s.categories.split(',').some((c) => cats.includes(Number(c.trim()))))
}
// 识别选路预览
function svcSupportsLang(id: string, lang: string) {
const ls = svcLangs(id)
if (!ls) return true // 未限定 = 交给引擎判断,先当支持
return ls.split(',').map((x) => x.trim()).includes(lang)
}
const asrHit = computed(() => ofKind(1).find((o) => o.enabled && svcSupportsLang(o.svc_id, probeLang.value)) ?? null)
const mtHit = computed(() => ofKind(2).find((o) => o.enabled) ?? null)
function asrSkipped(o: Orch) { return asrHit.value?.row_id !== o.row_id && (!o.enabled || !svcSupportsLang(o.svc_id, probeLang.value)) }
function asrWhyClass(o: Orch) {
if (asrHit.value?.row_id === o.row_id) return 'ok'
if (!o.enabled || !svcSupportsLang(o.svc_id, probeLang.value)) return 'no'
return ''
}
function asrWhy(o: Orch) {
if (asrHit.value?.row_id === o.row_id) {
return svcLangs(o.svc_id) ? `✓ 当前源语言(${langName(probeLang.value)})由它承接` : `✓ 未限定语言,先落到它(实际支持由引擎判断)`
}
if (!o.enabled) return '已停用,跳过'
if (!svcSupportsLang(o.svc_id, probeLang.value)) return `不支持 ${langName(probeLang.value)}(${probeLang.value}),跳过 → 顺延下一个`
return '备用:前面都跳过时接手'
}
// ---- 支持语言汇总 ----
// 规则:同一段内多个服务取**并集**(谁支持都算这段支持,选路时会自动顺延到支持的那个);
// 段与段之间取**交集**(一条记录要依次过识别→翻译→总结,缺一段就走不完)。
// 某段只要有一个服务没填 languages,该段视为「全语种」不参与收缩——与 svcSupportsLang
// 的「未限定=先当支持」保持一致,否则会把实际能用的语言误判成不支持。
const langSum = computed(() => {
const missing: number[] = [] // 没有任何启用服务的段
const groups: { kind: number; langs: string[] }[] = [] // 有语言限定的段
let unlimitedKinds = 0 // 有服务但全未限定的段数
for (const k of [1, 2, 3]) {
const list = ofKind(k).filter((o) => o.enabled)
if (!list.length) { missing.push(k); continue }
const union = new Set<string>()
let unlimited = false
for (const o of list) {
const ls = svcLangs(o.svc_id).split(',').map((x) => x.trim()).filter(Boolean)
if (!ls.length) { unlimited = true; break } // 该服务未限定 → 整段放开
ls.forEach((l) => union.add(l))
}
if (unlimited) unlimitedKinds++
else groups.push({ kind: k, langs: [...union] })
}
if (!groups.length) return { missing, unlimited: true, langs: [] as string[], groups, unlimitedKinds }
const langs = groups
.reduce<string[]>((acc, g) => acc.filter((l) => g.langs.includes(l)), [...groups[0]!.langs])
.sort((a, b) => langOrder(a) - langOrder(b)) // 按 LANG_OPTIONS 顺序,跟其它页面一致
return { missing, unlimited: false, langs, groups, unlimitedKinds }
})
function langOrder(code: string) {
const i = LANG_OPTIONS.findIndex((o) => o.code === code)
return i < 0 ? Number.MAX_SAFE_INTEGER : i
}
// 顶部那行算式,说明这个结果是怎么来的:识别 11 ∩ 翻译 全语种 …
const langSumFormula = computed(() => {
const parts: string[] = []
for (const k of [1, 2, 3]) {
if (langSum.value.missing.includes(k)) { parts.push(`${kindLabel(k)} 未配置`); continue }
const g = langSum.value.groups.find((x) => x.kind === k)
parts.push(`${kindLabel(k)} ${g ? g.langs.length + ' 种' : '全语种'}`)
}
return parts.join(' ∩ ')
})
// 总结
const llmDefault = computed(() => ofKind(3).find((o) => o.is_default) ?? null)
const pickable = computed(() => ofKind(3).filter((o) => o.enabled && o.client_visible))
// ---- 加载 ----
async function loadApps() {
try {
const list = await consoleApi<{ name: string }[]>('products/list', {})
apps.value = (list ?? []).map((p) => p.name).filter(Boolean)
} catch { apps.value = [] }
}
async function loadOrch() {
loading.value = true
try {
const d = await webApi<{ items: Orch[]; setting: { llm_client_choice: boolean }; available: SvcView[]; kind_cats: Record<string, number[]> }>(
'getechoorch', { app_name: scopeApp.value, region: scopeRegion.value },
)
items.value = d?.items ?? []
available.value = d?.available ?? []
kindCats.value = d?.kind_cats ?? kindCats.value
llmClientChoice.value = !!d?.setting?.llm_client_choice
// 是否为覆盖:非全局作用域且本作用域有编排/开关
isOverride.value = (!!scopeApp.value || scopeRegion.value !== 0) && (items.value.length > 0 || llmClientChoice.value)
} catch (e: any) {
error(e?.message ?? '加载失败')
} finally {
loading.value = false
}
}
// ---- 编排增改删 ----
async function saveOrch(orch: Partial<Orch>) {
await webApi('saveechoorch', { app_name: scopeApp.value, region: scopeRegion.value, orch })
}
function openAdd(k: number) {
addKind.value = k
addForm.svc_id = ''
addForm.cost_hint = 0
addForm.is_default = false
addForm.client_visible = false
addDlg.value?.showModal()
}
async function doAdd() {
if (!addForm.svc_id) return
saving.value = true
try {
const list = ofKind(addKind.value)
const priority = list.length ? (list[list.length - 1]!.priority + 10) : 10
await saveOrch({
kind: addKind.value, svc_id: addForm.svc_id, priority,
cost_hint: Number(addForm.cost_hint) || 0,
is_default: addKind.value === 3 ? addForm.is_default : false,
client_visible: addKind.value === 3 ? addForm.client_visible : false,
enabled: true,
})
success('已添加')
addDlg.value?.close()
await loadOrch()
} catch (e: any) {
error(e?.message ?? '添加失败')
} finally {
saving.value = false
}
}
async function patch(o: Orch, changes: Partial<Orch>) {
busy.value = true
try {
await saveOrch({ ...o, ...changes })
await loadOrch()
} catch (e: any) {
error(e?.message ?? '操作失败')
await loadOrch()
} finally {
busy.value = false
}
}
async function del(o: Orch) {
if (!confirm(`从本段移除「${svcName(o.svc_id)}」?(不影响第三方服务配置里的服务本身)`)) return
try {
await webApi('delechoorch', { row_id: o.row_id })
success('已移除')
await loadOrch()
} catch (e: any) {
error(e?.message ?? '移除失败')
}
}
// 排序
async function persistOrder(ordered: Orch[]) {
busy.value = true
try {
await webApi('reorderechoorch', { items: ordered.map((o, i) => ({ row_id: o.row_id, priority: (i + 1) * 10 })) })
await loadOrch()
} catch (e: any) {
error(e?.message ?? '排序失败')
await loadOrch()
} finally {
busy.value = false
}
}
function move(k: number, idx: number, dir: number) {
const list = [...ofKind(k)]
const j = idx + dir
if (j < 0 || j >= list.length) return
const tmp = list[idx]!
list[idx] = list[j]!
list[j] = tmp
persistOrder(list)
}
function sortByCost(k: number) {
const list = [...ofKind(k)].sort((a, b) => {
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1
return (a.cost_hint || Number.MAX_VALUE) - (b.cost_hint || Number.MAX_VALUE)
})
persistOrder(list)
}
// 改单价
function openEditCost(o: Orch) {
costEdit.row_id = o.row_id
costEdit.name = svcName(o.svc_id)
costEdit.kind = o.kind
costEdit.cost_hint = o.cost_hint
costDlg.value?.showModal()
}
async function doSaveCost() {
const o = items.value.find((x) => x.row_id === costEdit.row_id)
if (!o) return
saving.value = true
try {
await saveOrch({ ...o, cost_hint: Number(costEdit.cost_hint) || 0 })
success('已保存')
costDlg.value?.close()
await loadOrch()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
saving.value = false
}
}
async function toggleClientChoice() {
busy.value = true
try {
await webApi('saveechoorchsetting', { app_name: scopeApp.value, region: scopeRegion.value, llm_client_choice: !llmClientChoice.value })
await loadOrch()
} catch (e: any) {
error(e?.message ?? '保存失败')
await loadOrch()
} finally {
busy.value = false
}
}
onMounted(async () => {
await loadApps()
await loadOrch()
})
</script>
<style scoped>
.scopebar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; background: #f8fafc; border: 1px solid #eef2f7; border-radius: 11px; padding: 10px 13px; margin-bottom: 16px; }
.langsum { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; box-shadow: 0 1px 2px rgba(15, 23, 42, .04); padding: 13px 16px; margin-bottom: 16px; }
.langsum.warn { border-color: #fed7aa; background: #fffbf5; }
.langsum.bad { border-color: #fecaca; background: #fff7f7; }
.langsum-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.langsum-title { font-size: 13px; font-weight: 700; color: #1e293b; }
.langsum-count { font-size: 12px; font-weight: 700; color: #15803d; background: #dcfce7; padding: 2px 8px; border-radius: 6px; }
.langsum.bad .langsum-count { color: #dc2626; background: #fee2e2; }
.langsum-count.off { color: #b45309; background: #fef3c7; } /* 有段没配全:语言算得出来,但链路还走不通 */
.langsum-formula { font-size: 11px; color: #94a3b8; font-family: 'SF Mono', Menlo, monospace; }
.langsum-msg { font-size: 11.5px; color: #64748b; margin-top: 7px; line-height: 1.6; }
.langsum-msg.bad { color: #b91c1c; font-weight: 600; }
.langsum-langs { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 9px; }
.card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; box-shadow: 0 1px 2px rgba(15, 23, 42, .04); overflow: hidden; }
.card-head { padding: 15px 18px; border-bottom: 1px solid #eef2f7; display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; flex-wrap: wrap; }
.card-title { font-size: 14.5px; font-weight: 700; color: #1e293b; }
.card-desc { font-size: 11.5px; color: #64748b; margin-top: 3px; max-width: 76ch; line-height: 1.55; }
.probe-bar { display: flex; align-items: center; gap: 10px; padding: 11px 16px; background: #fcfcfd; border-bottom: 1px solid #eef2f7; flex-wrap: wrap; }
.probe-out { font-size: 11.5px; font-weight: 600; color: #15803d; background: #dcfce7; padding: 3px 9px; border-radius: 6px; }
.probe-out.bad { color: #dc2626; background: #fee2e2; }
.mode-bar { display: flex; align-items: center; gap: 14px; padding: 12px 16px; background: #fcfcfd; border-bottom: 1px solid #eef2f7; flex-wrap: wrap; }
.mode-txt { font-size: 12px; color: #64748b; flex: 1; min-width: 260px; line-height: 1.6; margin: 0; }
.mode-txt b { color: #1e293b; font-weight: 600; }
.svc { display: grid; gap: 12px; align-items: center; padding: 11px 16px; border-bottom: 1px solid #eef2f7; }
.svc:last-child { border-bottom: 0; }
.svc:hover { background: #fcfcfd; }
.svc.off { opacity: .55; }
.svc.hit { background: linear-gradient(90deg, #dcfce7, transparent 42%); }
.svc.isdefault { background: linear-gradient(90deg, #eef2ff, transparent 42%); }
.svc.asr { grid-template-columns: 34px 1fr 170px 110px 60px auto; }
.svc.mt { grid-template-columns: 34px 1fr 130px 60px auto; }
.svc.llm { grid-template-columns: 40px 1fr 130px 90px 60px auto; }
.col-h { padding: 8px 16px; background: #fcfcfd; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; color: #94a3b8; font-weight: 700; display: grid; gap: 12px; }
.col-h.llm-h { grid-template-columns: 40px 1fr 130px 90px 60px auto; border-top: 1px solid #eef2f7; }
.svc-rank { width: 26px; height: 26px; border-radius: 8px; display: grid; place-items: center; background: #f8fafc; border: 1px solid #e2e8f0; color: #64748b; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; font-weight: 700; }
.svc-rank.hit { background: #16a34a; border-color: #16a34a; color: #fff; }
.svc-name { font-weight: 600; font-size: 13.5px; color: #334155; }
.svc-name.skip { text-decoration: line-through; text-decoration-color: #cbd5e1; }
.svc-prov { font-size: 11px; color: #94a3b8; font-family: 'SF Mono', Menlo, monospace; margin-top: 2px; }
.svc-cost { font-family: 'SF Mono', Menlo, monospace; font-size: 12.5px; font-variant-numeric: tabular-nums; color: #334155; }
.svc-cost small { color: #94a3b8; font-size: 10px; }
.svc-acts { display: flex; gap: 3px; align-items: center; justify-content: flex-end; }
.svc-why { grid-column: 2 / -1; font-size: 11px; color: #64748b; margin-top: -2px; }
.svc-why.ok { color: #15803d; font-weight: 600; }
.svc-why.no { color: #b45309; }
.arrow { width: 24px; height: 24px; border-radius: 6px; border: 1px solid #e2e8f0; background: #fff; color: #64748b; cursor: pointer; display: grid; place-items: center; font-size: 11px; padding: 0; }
.arrow:hover:not(:disabled) { border-color: #4f46e5; color: #4f46e5; }
.arrow:disabled { opacity: .25; cursor: not-allowed; }
.lang { font-family: 'SF Mono', Menlo, monospace; font-size: 9.5px; padding: 1px 5px; border-radius: 4px; background: #f8fafc; border: 1px solid #eef2f7; color: #64748b; }
.lang.on { background: #dcfce7; border-color: transparent; color: #15803d; font-weight: 700; }
.chip { display: inline-flex; align-items: center; font-size: 10.5px; font-weight: 700; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #64748b; }
.chip-app { background: #eef2ff; color: #4338ca; }
@media (max-width: 1100px) {
.svc.asr, .svc.mt, .svc.llm { grid-template-columns: 34px 1fr; }
.col-h { display: none; }
.svc-acts { grid-column: 1 / -1; justify-content: flex-start; }
.svc-why { grid-column: 1 / -1; }
}
</style>

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

@ -1,13 +1,26 @@
<template>
<div>
<!-- 标签页:第三方服务(凭据,结构化)/ 应用参数(业务库 key-value)。
2026-09-12 由两个一级菜单项合并而来,凭据只有「第三方服务」一个出处。 -->
<div role="tablist" class="tabs tabs-boxed bg-slate-100 mb-5 w-fit">
<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">
⚠ 「第三方服务配置」后端接口暂不可用(连接失败),请检查 console 服务是否运行。
</div>
<!-- 顶部 -->
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-lg font-semibold text-slate-800">第三方服务配置</h2>
<p class="text-xs text-slate-400 mt-1">按服务类型分组管理已接入的第三方服务(火山引擎、阿里云、Azure 等)。默认展示<b>全局默认</b>配置,可切换到具体应用;区域默认<b>中国</b>,切换区域后点击卡片可为该区域覆盖字段值。</p>
<h2 class="text-lg font-semibold text-slate-800">第三方服务</h2>
<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">
@ -167,13 +180,19 @@
<table class="table text-xs">
<thead>
<tr>
<th class="w-44">字段名</th><th>字段描述</th><th class="w-16 text-center">加密</th><th>默认值</th><th class="w-12"></th>
<th class="w-40">字段名</th><th>字段描述</th>
<th class="w-44">
下发键名
<span class="help-dot" title="兼容已发布客户端用:填了这里,本字段的值会同时以该名字出现在 user_getappconfig 的 env 里(老包读的就是 env[键名])。新客户端直接读结构化的 thirdsvcs,不需要它。留空=不进 env。">?</span>
</th>
<th class="w-16 text-center">加密</th><th>默认值</th><th class="w-12"></th>
</tr>
</thead>
<tbody>
<tr v-for="(f, i) in editFields" :key="i">
<td><input class="field-input" v-model="f.key" placeholder="key" :title="f.key" /></td>
<td><input class="field-input" v-model="f.description" placeholder="用途描述" /></td>
<td><input class="field-input font-mono" v-model="f.env_key" placeholder="如 AZURE_SPEECH_KEY" :title="f.env_key" /></td>
<td class="text-center"><input type="checkbox" class="checkbox checkbox-xs checkbox-primary" v-model="f.encrypted" /></td>
<td>
<LangSelect v-if="f.type === 'langs'" v-model="f.def_value" :multiple="true" />
@ -335,13 +354,16 @@
<table class="table text-xs">
<thead>
<tr>
<th class="w-44">字段名</th><th>字段描述</th><th class="w-16 text-center">加密</th><th>默认值</th><th class="w-12"></th>
<th class="w-40">字段名</th><th>字段描述</th>
<th class="w-44">下发键名<span class="help-dot" title="兼容老客户端:填了就同时以该名字进 env。留空=不进 env。">?</span></th>
<th class="w-16 text-center">加密</th><th>默认值</th><th class="w-12"></th>
</tr>
</thead>
<tbody>
<tr v-for="(f, i) in editFields" :key="i">
<td><input class="field-input" v-model="f.key" placeholder="key" :title="f.key" /></td>
<td><input class="field-input" v-model="f.description" placeholder="用途描述" /></td>
<td><input class="field-input font-mono" v-model="f.env_key" placeholder="可留空" :title="f.env_key" /></td>
<td class="text-center"><input type="checkbox" class="checkbox checkbox-xs checkbox-primary" v-model="f.encrypted" /></td>
<td>
<LangSelect v-if="f.type === 'langs'" v-model="f.def_value" :multiple="true" />
@ -632,6 +654,7 @@
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
@ -639,7 +662,11 @@ import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '第三方服务配置' })
definePageMeta({ title: '服务与环境配置' })
// 两个标签页:第三方服务(凭据,带加密/区域分叉/巡检)与应用参数(业务库 config 表的 key-value)。
// 合并前它们是并列的两个一级菜单项,运营常把凭据填错地方——凭据现在只认「第三方服务」这一处。
const tab = ref<'svc' | 'env'>('svc')
// 区域枚举(与 pb.Region 一一对应)
const REGIONS = [
@ -682,6 +709,9 @@ interface SvcField {
encrypted: boolean
def_value: string
sort?: number
// 兼容下发键名:非空时该字段的明文值同时写进 user_getappconfig 的 env[env_key]。
// 已发布的客户端只会读 env,凭据 2026-09-12 归一到本表后靠它继续供货;新客户端读 thirdsvcs。
env_key?: string
_show?: boolean
}
@ -1092,7 +1122,7 @@ function chooseProvider(t: SvcTemplate | null) {
if (!editForm.description) editForm.description = t.description || ''
editFields.value = (t.fields || []).map((f) => ({
key: f.key, description: f.description, type: f.type || '', encrypted: !!f.encrypted,
def_value: f.def_value || '', sort: f.sort, _show: false,
def_value: f.def_value || '', env_key: f.env_key || '', sort: f.sort, _show: false,
}))
} else {
selectedTemplateId.value = ''
@ -1109,7 +1139,7 @@ function chooseProvider(t: SvcTemplate | null) {
// ===== 默认字段表格 =====
function addField() {
editFields.value.push({ key: '', description: '', encrypted: false, def_value: '', sort: editFields.value.length, _show: false })
editFields.value.push({ key: '', description: '', encrypted: false, def_value: '', env_key: '', sort: editFields.value.length, _show: false })
}
function removeField(i: number) {
editFields.value.splice(i, 1)
@ -1167,6 +1197,7 @@ function collectDefaultData(isNew: boolean) {
encrypted: f.encrypted,
def_value: f.def_value,
sort: f.sort,
env_key: (f.env_key || '').trim(),
})),
}
if (isNew) data.id = editForm.id || ''

23
apps/admin/app/stores/auth.ts

@ -4,7 +4,6 @@ import { DEFAULT_ADMIN_ACCESS, ROLE_ACCESS_CEILING } from '~/utils/menus'
export const IDENTITY_LABELS: Record<number, string> = {
1: '超管',
2: '管理员',
3: '品牌商',
4: '运营',
5: '渠道商',
}
@ -12,7 +11,8 @@ export const IDENTITY_LABELS: Record<number, string> = {
// 身份常量(与后端 pb.Identity 一致)
export const IDENTITY_ADMIN = 1
export const IDENTITY_MANAGER = 2
export const IDENTITY_BRAND = 3 // 品牌商账号(后端枚举名 Agent,历史沿用「代理」)
// ⚠️ identity=3(品牌商/代理)已于 2026-09-12 随 brand 表下线:后端不再接受新建,
// 存量账号由 migrateBrandAccountsToOperator 禁用并降级为运营。这里不再导出对应常量。
export const IDENTITY_OPERATOR = 4
export const IDENTITY_DEALER = 5 // 渠道商账号
@ -32,7 +32,6 @@ interface LoginData {
apps: string
regions: string
products: string
brandid?: number
channelid?: string
appbinds: AppBind[]
}
@ -71,9 +70,8 @@ export const useAuthStore = defineStore('auth', {
apps: [] as string[],
regions: [] as string[],
products: [] as string[],
// 渠道分成体系的归属绑定:品牌商账号只有 brandid;渠道商账号两者都有。
// 渠道分成体系的归属绑定:渠道商账号有 channelid。
// 仅用于界面收敛(隐藏无关筛选/按钮),真正的数据隔离由后端 scope 强制执行。
brandid: 0 as number,
channelid: '' as string,
appbinds: [] as AppBind[],
currentAppId: 0 as number,
@ -87,10 +85,6 @@ export const useAuthStore = defineStore('auth', {
isAdmin(): boolean {
return this.identity === IDENTITY_ADMIN || this.identity === IDENTITY_MANAGER
},
// 品牌商账号:绑定了品牌商,可见名下全部渠道商
isBrandAccount(): boolean {
return this.identity === IDENTITY_BRAND && this.brandid > 0
},
// 渠道商账号:锁定到单个渠道商
isDealer(): boolean {
return this.identity === IDENTITY_DEALER
@ -115,7 +109,6 @@ export const useAuthStore = defineStore('auth', {
apps: this.apps,
regions: this.regions,
products: this.products,
brandid: this.brandid,
channelid: this.channelid,
appbinds: this.appbinds,
currentAppId: this.currentAppId,
@ -132,7 +125,6 @@ export const useAuthStore = defineStore('auth', {
this.apps = parseComma(data.apps)
this.regions = parseComma(data.regions)
this.products = parseComma(data.products)
this.brandid = data.brandid ?? 0
this.channelid = data.channelid ?? ''
this.appbinds = data.appbinds ?? []
if (this.appbinds.length > 0 && !this.currentAppId) {
@ -160,7 +152,6 @@ export const useAuthStore = defineStore('auth', {
this.apps = []
this.regions = []
this.products = []
this.brandid = 0
this.channelid = ''
this.appbinds = []
this.currentAppId = 0
@ -181,7 +172,6 @@ export const useAuthStore = defineStore('auth', {
this.apps = (stored.apps as string[]) ?? []
this.regions = (stored.regions as string[]) ?? []
this.products = (stored.products as string[]) ?? []
this.brandid = (stored.brandid as number) ?? 0
this.channelid = (stored.channelid as string) ?? ''
this.appbinds = (stored.appbinds as AppBind[]) ?? []
this.currentAppId = (stored.currentAppId as number) ?? 0
@ -190,12 +180,15 @@ export const useAuthStore = defineStore('auth', {
hasAccess(pageId: string): boolean {
if (this.identity === IDENTITY_ADMIN) return true // 超管全量
// 2026-09-12「应用环境配置」并入「服务与环境配置」(svcconfig):只勾过旧 id 的存量账号
// 不该因为一次合并就失去入口。反向不成立——勾了 svcconfig 本来就包含这一页。
if (pageId === 'svcconfig' && this.access.includes('appconfig')) return true
// 管理员未显式配置权限时,按默认权限集放行(除 SaaS 管理 / 系统管理 外全部)。
if (this.identity === IDENTITY_MANAGER && this.access.length === 0) {
return DEFAULT_ADMIN_ACCESS.includes(pageId)
}
// 品牌商/渠道商账号:可见页面不允许超出角色上限(品牌商=仪表盘/渠道商管理/用户查询/月结算,
// 渠道商=仪表盘/用户查询/月结算)。未显式勾权限时上限即默认集,免得新开的账号空白一片。
// 渠道商账号:可见页面不允许超出角色上限(仪表盘/用户查询/月结算)。
// 未显式勾权限时上限即默认集,免得新开的账号空白一片。
const ceiling = ROLE_ACCESS_CEILING[this.identity]
if (ceiling) {
if (!ceiling.includes(pageId)) return false

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

@ -28,13 +28,12 @@ export const MENU_GROUPS: MenuGroup[] = [
label: '生产管理',
items: [
// 顺序 = 生产链路的上下游:芯片厂商 → 方案商 → 代工厂 → 产品 → 发 MAC → 铺渠道。
// 这一组就是完整的六项,不多不少。
//
// ⚠️【2026-09-02 暂时下架】品牌商管理(/factory) 与 公码管理(/code) 已从菜单移除。
// 只是注释掉、页面与接口都还在:
// - 直接敲 URL 仍能进(menuItemByPath 查不到就不拦,见 middleware/access.global.ts);
// - 存量账号 access CSV 里的 'factory'/'code' 不受影响,恢复时把下面两行放开即可;
// - 但「后台账号 → 菜单权限」里这两项的勾选框会跟着消失,新账号勾不到。
// 背景:品牌商已不参与生产链路(生成MAC 表单里已去掉品牌商,归属改由渠道商推导)。
// ⚠️【2026-09-12 已彻底删除】品牌商管理(/factory) 与 公码管理(/code):
// 页面、console/api 接口、brand 与 factory_public_code 两张表都已删除(console 启动时 DROP),
// 不再是「注释掉但还在」的状态,放开注释也恢复不了。品牌商账号角色(identity=3) 一并下线。
// 存量账号 access CSV 里残留的 'factory'/'code' 不再对应任何菜单项,无害但也无效。
{ id: 'chipvendor', label: '芯片管理', to: '/chipvendor', icon: 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z' },
{ id: 'provider', label: '方案商管理', to: '/provider', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
{ id: 'oemfactory', label: '工厂管理', to: '/oemfactory', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
@ -44,8 +43,6 @@ export const MENU_GROUPS: MenuGroup[] = [
// ⚠️ 权限键仍是 device,不能跟着改名——它已存进各账号的 access CSV,改了等于把所有人的权限清零。
{ id: 'device', label: 'MAC生成管理', to: '/device', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
{ id: 'channel', label: '渠道商管理', to: '/dealers', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' },
// { id: 'factory', label: '品牌商管理', to: '/factory', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4' },
// { id: 'code', label: '公码管理', to: '/code', icon: 'M4 4h6v6H4V4zm10 0h6v6h-6V4zM4 14h6v6H4v-6zm10 2h2m-2 4h6m-4-4v4m4-8h2m-2 0v4' },
],
},
{
@ -63,8 +60,19 @@ export const MENU_GROUPS: MenuGroup[] = [
{
label: '服务配置',
items: [
{ id: 'globalconfig', label: '全局环境配置', to: '/globalconfig', icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9' },
{ id: 'appconfig', label: '应用环境配置', to: '/appconfig', icon: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4' },
// 2026-09-12 这一组从 11 项收敛到 6 项,收敛的依据是「这个入口背后有没有活数据、
// 客户端读不读它」,逐项核过库:
// · 全局环境配置(globalconfig) —— 473 行按区域复制的凭据,且**悄悄覆盖**应用环境配置里的同名键,
// 同一个 AZURE_SPEECH_REGION/OPENAI_API_KEY 两边值不一样、运营改哪边都像没生效。
// 凭据已归一到 svc_config(见 services 的 migrate_envtosvc.go),页面下线只留重定向。
// · 应用环境配置(appconfig) —— 并入「服务与环境配置」的「应用参数」标签页,只剩非凭据的业务参数。
// · Agent 配置(agents) / 通话翻译配置(calltranslate) —— agent_config、call_translate_rule
// 两张表在测试机与正式机上**都是 0 行**,客户端的通话翻译选路全在端侧
// (language_manager.findBestMatchingProvider),后台这两页从未产生过数据。
// · 会议记录服务(meetingsvc) —— 按要求下架。⚠️ 它背后的 echomeet_orch 仍有 4 行在跑
// (语音纪要的识别/翻译/总结选型),**数据与运行时保留**,只是后台不再提供编辑入口;
// 要换服务商得直接改库(见 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: '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: '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' },
@ -73,10 +81,6 @@ export const MENU_GROUPS: MenuGroup[] = [
// 客户端只读一处,出过「后台开了游客登录但 App 里没有」的事故,故拆到这里单独管。
{ id: 'appversion', label: '版本控制', to: '/appversion', icon: 'M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2' },
{ id: 'guestaccess', label: '游客显隐控制', to: '/guestaccess', icon: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z' },
{ 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: 'agents', label: 'Agent 配置', to: '/agents', icon: 'M12 3a2 2 0 012 2v1h3a2 2 0 012 2v3a2 2 0 01-2 2h-1v3a2 2 0 01-2 2H8a2 2 0 01-2-2v-3H5a2 2 0 01-2-2V8a2 2 0 012-2h3V5a2 2 0 012-2zM9 13h.01M15 13h.01' },
{ id: 'meetingsvc', label: '会议记录服务', to: '/meetingsvc', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
{ id: 'calltranslate', label: '通话翻译配置', to: '/calltranslate', icon: 'M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129' },
],
},
{
@ -107,18 +111,17 @@ export const DEFAULT_ADMIN_ACCESS: string[] = MENU_GROUPS
.filter((g) => !g.restricted)
.flatMap((g) => g.items.map((m) => m.id))
// 品牌商账号(identity=3)默认页面集:仪表盘 + 渠道商管理 + 用户查询 + 月结算。
// 渠道商可新增,但要超管/管理员审核通过才生效(后端强制);结算单只读自己的。
export const DEFAULT_BRAND_ACCESS: string[] = ['dashboard', 'channel', 'user', 'settlement']
// 渠道商账号(identity=5)默认页面集:只看自己渠道的数据与结算单,不含渠道商管理。
export const DEFAULT_DEALER_ACCESS: string[] = ['dashboard', 'user', 'settlement']
// 角色菜单上限:品牌商/渠道商账号是外部角色,可见页面不允许超出各自默认集——
// 角色菜单上限:渠道商账号是外部角色,可见页面不允许超出默认集——
// 即便账号里存了更多 access(历史数据或误勾),也一律按上限收敛。
// 未列出的角色(管理员/运营)不设上限,按显式勾选生效。
//
// ⚠️ 2026-09-12 品牌商账号(identity=3) 下线,这里原先还有一条 `3: DEFAULT_BRAND_ACCESS`。
// 后端启动迁移已把存量 identity=3 的账号禁用并降级(migrateBrandAccountsToOperator),
// 所以不会再有这类账号登录进来落到「无上限」那一支。
export const ROLE_ACCESS_CEILING: Record<number, string[]> = {
3: DEFAULT_BRAND_ACCESS,
5: DEFAULT_DEALER_ACCESS,
}

4
apps/services/comm/const.go

@ -52,7 +52,7 @@ const (
TableAgent = "agent" //智能体id
TableMcp = "mcp" //Mcp服务
TableAppConfig = "config" //App自有配置(音乐、OSS等)
TableGlobalConfig = "global_config" //全局第三方服务配置(AI、翻译等,按区域)
TableGlobalConfig = "global_config" //【已下线 2026-09-12】旧「全局环境配置」,凭据已归一到 svc_config;仅 console 的一次性迁移(migrate_envtosvc.go)读它,确认无误后可手工 DROP
TableAppServiceConfig = "app_service_config" //应用×服务的基础设施/运行配置(DSN/Redis/NATS/COS…;启动 seed 自文件、后台可改),存 console 共享库
TableAppModuleConfig = "app_module_config" //应用业务功能配置(登录/短信/邮件/支付…),存"应用业务库"(随部署)、非公共库,密钥字段 AES 加密
// 按应用分离的「第三方服务配置 / Agent 配置」新表(app_name=''为全局默认,'X'为应用X覆盖)。
@ -100,7 +100,7 @@ const (
TableMemoryReport = "memory_report" //记忆周期报告表(周报/月报)⚠️ 同上
TableStatsGlobalDay = "stats_global_day" //平台每日统计表(应用×产品×区域×日)
TableProductStat = "product_stat" //产品激活/绑定统计表(重建统计时全量扫描 userdevice 落库)
TableFactoryPublicCode = "factory_public_code" //厂家公码表(一厂家多码,新用户扫码领奖,user.isgiveaway 判重)
TableFactoryPublicCode = "factory_public_code" //【已下线 2026-09-12】厂家公码表。后台入口/接口/客户端调用已全删,console 启动时 DROP 掉这张表;常量只留给那句 DROP 用
TableAppProduct = "app_product" //应用/产品表(console 主库,SaaS 里一个应用名如 Voitrans,下挂多条区域部署=app_registry)
TableAppRegistry = "app_registry" //应用注册表(console 独立库,一行=一个区域部署;product_id 归属应用、status existing/pending)
TableAppEnvironment = "app_environment" //部署环境/区域表(console 主库,存 SSH 部署目标/镜像仓库/区域基础设施;密钥字段 AES-GCM 加密)

2
apps/services/comm/nats.go

@ -29,7 +29,7 @@ const (
// 配置变更类型(ConfigChangedEvent.Kind 取值):业务侧据此重载对应缓存。
const (
ConfigKindGlobalConfig = "global_config" // 全局第三方服务配置(user 模块重载)
ConfigKindGlobalConfig = "global_config" // 【已下线】旧「全局环境配置」变更广播,无发送方也无订阅方;凭据改走 ConfigKindThirdSvc
ConfigKindMcp = "mcp" // MCP 服务器(user 模块重载)
ConfigKindTemplate = "template" // 会议公共模板(echomeet 模块重载)
ConfigKindThirdSvc = "third_svc" // 依赖服务配置(业务侧重载字段缓存)

43
apps/services/comm/svcpool.go

@ -25,10 +25,11 @@ type SvcField struct {
Encrypted bool `json:"encrypted"` // 是否加密存储
DefValue string `json:"def_value"` // 默认值(全区域共用;加密字段存 AES-GCM 密文)
Sort int32 `json:"sort"` // 展示排序,越小越靠前
EnvKey string `json:"env_key"` // 兼容下发键名:非空时本字段的明文值同时写进 user_getappconfig 的 env[EnvKey]
}
// ThirdSvcConfig 第三方服务配置(表 TableSvcConfig,按应用分离)。每行 = 某应用作用域下的一个第三方服务。
// AppName=''为全局默认、'X'为应用X的覆盖;解析时应用有则用应用的、无则回退全局。
// AppName=”为全局默认、'X'为应用X的覆盖;解析时应用有则用应用的、无则回退全局。
// (app_name,id) 唯一;categories 存逗号分隔的类别枚举值。凭据全部装在 Fields。
type ThirdSvcConfig struct {
RowId uint64 `gorm:"primaryKey;autoIncrement;column:row_id" json:"row_id"`
@ -66,7 +67,7 @@ const (
// EchoOrchestration 会议记录服务编排(表 TableEchomeetOrch)。一行 = 某作用域(app_name,region)
// 下某类别选中的一个服务引用 + 排序/标记。不存凭据,svc_id 引用 ThirdSvcConfig.Id。
// 作用域解析顺序:(app,region) → (app,0) → ('',region) → ('',0) 全局默认兜底,越具体越优先。
// 作用域解析顺序:(app,region) → (app,0) → (”,region) → (”,0) 全局默认兜底,越具体越优先。
type EchoOrchestration struct {
RowId uint64 `gorm:"primaryKey;autoIncrement;column:row_id" json:"row_id"`
AppName string `gorm:"column:app_name;size:64;uniqueIndex:uidx_echo_orch,priority:1" json:"app_name"` // ''=全局默认兜底
@ -145,6 +146,44 @@ func ResolveSvcPlainFields(svc *ThirdSvcConfig, ovr *SvcRegionOverride, encKey s
return fields, nil
}
// ResolveSvcEnvKeys 产出某第三方服务的「兼容下发键名 → 明文值」表,供 user_getappconfig 的 env 用。
//
// 背景:凭据原先散在两张扁平 key/value 表里(应用业务库 config、console 库 global_config),
// 客户端一律从 env[KEY] 读。2026-09-12 把凭据统一收进 svc_config 之后,**已发布的客户端仍然只会
// 读 env**,所以每个字段带一个 EnvKey,下发时按它回填一份 env —— 存量包无需升级即可继续工作,
// 新版客户端则直接读结构化的 thirdsvcs。
//
// 入参 fields 是 ResolveSvcPlainFields 的产物(区域覆盖已合并、加密字段已解密),本函数只做
// 「字段 key → EnvKey」的改名,不重复解密。区域覆盖新增的 ExtraFields 也参与(它们可以带自己的
// EnvKey,用于「某区域换一个键名」这种边角情况);DisabledKeys 在上一步已被剔除,这里查不到值。
//
// ⚠️ 只对**已在 fields 里**的 key 生效:上一步剔掉的字段不会凭空出现在 env 里。
//
// ⚠️ **空值不产出 env 键**。老的两张扁平表只会有「配过的键」,没配的键在 env 里是**不存在**,
// 而客户端大量写的是 `AppConfig.env('X') ?? 默认值`——Dart 的 `??` 只兜 null,空串会照单全收。
// 于是「下发一个空串」和「不下发」在客户端是两种行为:前者会把 endpoint 之类的默认值顶掉,
// 变成请求打到空地址。真机实测 AZURE_TRANSLATION_ENDPOINT 就栽在这上面。
func ResolveSvcEnvKeys(svc *ThirdSvcConfig, ovr *SvcRegionOverride, fields map[string]string) map[string]string {
env := make(map[string]string, len(fields))
take := func(f SvcField) {
if f.EnvKey == "" {
return
}
if v, ok := fields[f.Key]; ok && strings.TrimSpace(v) != "" {
env[f.EnvKey] = v
}
}
for _, f := range svc.Fields {
take(f)
}
if ovr != nil {
for _, f := range ovr.ExtraFields {
take(f)
}
}
return env
}
// ============================== 服务端专用类别(凭据绝不下发客户端) ==============================
//
// svc_config 里的服务默认是「下发给客户端用的第三方能力」——resolveThirdSvcs 会把 encrypted 字段

57
apps/services/modules/api/api_addbrand.go

@ -1,57 +0,0 @@
package api
import (
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
const defaultFactoryContactMail = "358030594@qq.com"
// 添加品牌商
func (this *apiComp) AddBrand(session comm.IUserSession, req *pb.ApiAddBrandReq) (resp *pb.ApiAddBrandResp, errdata *pb.ErrorData) {
var (
err error
)
if req.Brand != nil {
req.Brand.Contactmails = ensureDefaultContactMail(req.Brand.Contactmails)
req.Brand.Createtime = time.Now().Unix()
}
if err = this.module.model.addfactory(req.Brand); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("AddBrand Fail!", log.Field{Key: "req", Value: req.String()}, log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiAddBrandResp{
Brand: req.Brand,
}
return
}
// 确保品牌商联系邮箱中包含默认邮箱
func ensureDefaultContactMail(mails string) string {
parts := strings.Split(mails, ",")
cleaned := make([]string, 0, len(parts)+1)
has := false
for _, p := range parts {
m := strings.TrimSpace(p)
if m == "" {
continue
}
if strings.EqualFold(m, defaultFactoryContactMail) {
has = true
}
cleaned = append(cleaned, m)
}
if !has {
cleaned = append([]string{defaultFactoryContactMail}, cleaned...)
}
return strings.Join(cleaned, ",")
}

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

@ -1,28 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 添加全局第三方服务配置
func (this *apiComp) AddGlobalConfigs(session comm.IUserSession, req *pb.ApiAddGlobalConfigsReq) (resp *pb.ApiAddGlobalConfigsResp, errdata *pb.ErrorData) {
var (
err error
)
if err = this.module.model.addglobalconfig(req.Configs...); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("AddGlobalConfig 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.ApiAddGlobalConfigsResp{}
return
}

30
apps/services/modules/api/api_addproduct.go

@ -1,7 +1,6 @@
package api
import (
"fmt"
"time"
"yunyan/comm"
@ -11,10 +10,7 @@ import (
// 添加配置
func (this *apiComp) AddProduct(session comm.IUserSession, req *pb.ApiAddProductReq) (resp *pb.ApiAddProductResp, errdata *pb.ErrorData) {
var (
factory *pb.DBBrand
err error
)
var err error
if req.Product.Bassfilter > 1000 {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_ReqParameterError,
@ -22,14 +18,9 @@ func (this *apiComp) AddProduct(session comm.IUserSession, req *pb.ApiAddProduct
}
return
}
if factory, err = this.module.model.factory(req.Product.Brandid); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("AddProduct!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
// 品牌商 2026-09-12 下线:brandid 不再校验、不再写入(这一列只为老客户端的
// 兼容字段 factoryid 留着,见 modules/user/compat.go)。
req.Product.Brandid = 0
req.Product.Createtime = time.Now().Unix()
if err = this.module.model.addproducts(req.Product); err != nil {
errdata = &pb.ErrorData{
@ -39,19 +30,6 @@ func (this *apiComp) AddProduct(session comm.IUserSession, req *pb.ApiAddProduct
this.module.Error("AddProduct!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
if len(factory.Productlists) == 0 {
factory.Productlists = fmt.Sprintf("%d", req.Product.Id)
} else {
factory.Productlists = fmt.Sprintf("%s,%d", factory.Productlists, req.Product.Id)
}
if err = this.module.model.savefactory(factory); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("AddProduct!", 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("AddProduct!", log.Field{Key: "err", Value: err.Error()})
}

22
apps/services/modules/api/api_channel.go

@ -35,7 +35,7 @@ func canAuditChannel(session comm.IUserSession) bool {
// GetChannels 渠道商列表。status<0 为不限(0 是「待审核」,不能拿零值当全部)。
// 运营/代理按账号的品牌商白名单收敛。
func (this *apiComp) GetChannels(session comm.IUserSession, req *pb.ApiGetChannelsReq) (resp *pb.ApiGetChannelsResp, errdata *pb.ErrorData) {
models, err := this.module.model.channels(req.Brandid, req.Status, req.Keyword)
models, err := this.module.model.channels(req.Status, req.Keyword)
if err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
this.module.Error("GetChannels Fail!", log.Field{Key: "err", Value: err.Error()})
@ -90,14 +90,8 @@ func (this *apiComp) AddChannel(session comm.IUserSession, req *pb.ApiAddChannel
errdata = &pb.ErrorData{Code: pb.ErrorCode_ReqParameterError, Message: "渠道商名称必填"}
return
}
if m.Brandid == 0 {
errdata = &pb.ErrorData{Code: pb.ErrorCode_ReqParameterError, Message: "所属品牌商必选"}
return
}
if _, err := this.module.model.factory(m.Brandid); err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_ReqParameterError, Message: "品牌商不存在"}
return
}
// 渠道商是全局实体:品牌商 2026-09-12 下线后不再有上级归属。
m.Brandid = 0
if m.Sharerate < 0 || m.Sharerate > channelShareRateMax {
errdata = &pb.ErrorData{Code: pb.ErrorCode_ReqParameterError, Message: "分成比例需在 0~10000(万分比) 之间"}
return
@ -126,7 +120,7 @@ func (this *apiComp) AddChannel(session comm.IUserSession, req *pb.ApiAddChannel
return
}
// UpdateChannel 更新渠道商资料。id/brandid 不可改(改归属与已生产的授权码矛盾);
// UpdateChannel 更新渠道商资料。id 不可改(改它与已生产的授权码矛盾);
// 状态只能走审批接口;分成比例只有有审批权的账号能改。
func (this *apiComp) UpdateChannel(session comm.IUserSession, req *pb.ApiUpdateChannelReq) (resp *pb.ApiUpdateChannelResp, errdata *pb.ErrorData) {
if req.Channel == nil || strings.TrimSpace(req.Channel.Id) == "" {
@ -224,9 +218,10 @@ func (this *apiComp) AuditChannel(session comm.IUserSession, req *pb.ApiAuditCha
return
}
// resolveProductionChannel 生产授权码时解析并校验渠道商:必选、须归属该品牌商、须为启用状态。
// resolveProductionChannel 生产授权码时解析并校验渠道商:必选、须为启用状态。
// 品牌归属的校验随品牌商下线(2026-09-12)一并去掉。
// 返回渠道商与错误数据(errdata 非空即校验失败,调用方直接 return)。
func (this *apiComp) resolveProductionChannel(channelid string, brandid uint32) (*pb.DBChannel, *pb.ErrorData) {
func (this *apiComp) resolveProductionChannel(channelid string) (*pb.DBChannel, *pb.ErrorData) {
id := strings.TrimSpace(channelid)
if id == "" {
return nil, &pb.ErrorData{Code: pb.ErrorCode_ReqParameterError, Message: "渠道商必选"}
@ -235,9 +230,6 @@ func (this *apiComp) resolveProductionChannel(channelid string, brandid uint32)
if err != nil {
return nil, &pb.ErrorData{Code: pb.ErrorCode_ReqParameterError, Message: "渠道商不存在: " + id}
}
if ch.Brandid != brandid {
return nil, &pb.ErrorData{Code: pb.ErrorCode_ReqParameterError, Message: "渠道商不属于该品牌商: " + id}
}
if ch.Status != ChannelStatusEnabled {
return nil, &pb.ErrorData{Code: pb.ErrorCode_ReqParameterError, Message: "渠道商未启用(待审核/停用/驳回),不能用于生产: " + id}
}

8
apps/services/modules/api/api_createauthcodes.go

@ -44,13 +44,9 @@ func (this *apiComp) CreateAuthCodes(session comm.IUserSession, req *pb.ApiCreat
pid = req.Productid
)
// 校验品牌商存在
if _, err = this.module.model.factory(req.Brandid); err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return
}
// 渠道商必选:归属随批次写进每条授权码,用户绑定该设备后统计与结算都按它归属。
channel, errdata := this.resolveProductionChannel(req.Channelid, req.Brandid)
// (品牌商 2026-09-12 下线,原先这里还先校验一次品牌商存在。)
channel, errdata := this.resolveProductionChannel(req.Channelid)
if errdata != nil {
return
}

87
apps/services/modules/api/api_createfactorypubliccode.go

@ -1,87 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
"time"
)
// 创建厂家公码(一厂家可多码,每次调用生成一条新码)
// 公码字符串自动随机生成 6 位短码,遇到极端碰撞时重试若干次
func (this *apiComp) CreateFactoryPublicCode(session comm.IUserSession, req *pb.ApiCreateFactoryPublicCodeReq) (resp *pb.ApiCreateFactoryPublicCodeResp, errdata *pb.ErrorData) {
var (
code string
err error
now = time.Now().Unix()
model *pb.DBFactoryPublicCode
)
if req.Brandid == 0 {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_ReqParameterError,
Message: "brandid 必填",
}
return
}
// 校验品牌商存在
if _, err = this.module.model.factory(req.Brandid); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: "品牌商不存在: " + err.Error(),
}
return
}
// 防碰撞:32^6 ≈ 10亿,正常一次成功;最多重试 5 次
for i := 0; i < 5; i++ {
if code, err = comm.GeneratePublicCode(); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_SystemError,
Message: err.Error(),
}
return
}
if _, e := this.module.model.factoryPublicCode(code); e != nil {
// 不存在 → 可以用
break
}
code = ""
}
if code == "" {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_SystemError,
Message: "公码生成多次碰撞,请重试",
}
return
}
model = &pb.DBFactoryPublicCode{
Code: code,
Brandid: req.Brandid,
Productid: req.Productid,
Remark: req.Remark,
Status: 0,
Maxuses: req.Maxuses,
Useduses: 0,
Expiretime: req.Expiretime,
Bindrewardvpitime: req.Bindrewardvpitime,
Bindrewardtranslate: req.Bindrewardtranslate,
Bindrewardmeeting: req.Bindrewardmeeting,
Createtime: now,
Updatetime: now,
}
if err = this.module.model.addFactoryPublicCode(model); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("CreateFactoryPublicCode Fail!", log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiCreateFactoryPublicCodeResp{Code: model}
return
}

24
apps/services/modules/api/api_delbrand.go

@ -1,24 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 删除品牌商
func (this *apiComp) DelBrand(session comm.IUserSession, req *pb.ApiDelBrandReq) (resp *pb.ApiDelBrandResp, errdata *pb.ErrorData) {
var (
err error
)
if err = this.module.model.delfactory(req.Id); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("DelBrand Fail!", log.Field{Key: "req", Value: req.String()}, log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiDelBrandResp{}
return
}

29
apps/services/modules/api/api_delfactorypubliccode.go

@ -1,29 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 删除厂家公码
// 仅删除公码本身;已领取记录(DBUserPublicCodeRedeem)保留以维持防重复领的全局唯一性约束
func (this *apiComp) DelFactoryPublicCode(session comm.IUserSession, req *pb.ApiDelFactoryPublicCodeReq) (resp *pb.ApiDelFactoryPublicCodeResp, errdata *pb.ErrorData) {
if req.Code == "" {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_ReqParameterError,
Message: "code 必填",
}
return
}
if err := this.module.model.delFactoryPublicCode(req.Code); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("DelFactoryPublicCode Fail!", log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiDelFactoryPublicCodeResp{}
return
}

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

@ -1,27 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 删除全局第三方服务配置
func (this *apiComp) DelGlobalConfigs(session comm.IUserSession, req *pb.ApiDelGlobalConfigsReq) (resp *pb.ApiDelGlobalConfigsResp, errdata *pb.ErrorData) {
var (
err error
)
if err = this.module.model.delglobalconfig(req.Ids); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("DelGlobalConfig 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.ApiDelGlobalConfigsResp{}
return
}

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

@ -1,28 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 获取品牌商
func (this *apiComp) GetBrand(session comm.IUserSession, req *pb.ApiGetBrandReq) (resp *pb.ApiGetBrandResp, errdata *pb.ErrorData) {
var (
model *pb.DBBrand
err error
)
if model, err = this.module.model.factory(req.Id); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("GetBrand Fail!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiGetBrandResp{
Brand: model,
}
return
}

47
apps/services/modules/api/api_getbrands.go

@ -1,47 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 获取品牌商列表
func (this *apiComp) GetBrands(session comm.IUserSession, req *pb.ApiGetBrandsReq) (resp *pb.ApiGetBrandsResp, errdata *pb.ErrorData) {
var (
models []*pb.DBBrand
err error
)
if models, err = this.module.model.factorys(); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("GetBrands Fail!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
// 运营/代理:按账号的 品牌商 + 产品白名单过滤
scope := this.getSessionScope(session)
if !scope.Unlimited {
allProducts, _ := this.module.model.products()
allowed := visibleFactoryIDs(scope, allProducts)
allowSet := make(map[uint32]struct{}, len(allowed))
for _, id := range allowed {
allowSet[id] = struct{}{}
}
filtered := make([]*pb.DBBrand, 0, len(models))
for _, f := range models {
if _, ok := allowSet[f.Id]; ok {
filtered = append(filtered, f)
}
}
models = filtered
}
resp = &pb.ApiGetBrandsResp{
Brands: models,
}
return
}

23
apps/services/modules/api/api_getfactorypubliccodes.go

@ -1,23 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 查询厂家公码列表
// factoryid=0 返回全部;status<0 不过滤状态
func (this *apiComp) GetFactoryPublicCodes(session comm.IUserSession, req *pb.ApiGetFactoryPublicCodesReq) (resp *pb.ApiGetFactoryPublicCodesResp, errdata *pb.ErrorData) {
models, err := this.module.model.factoryPublicCodes(req.Brandid, req.Status)
if err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("GetFactoryPublicCodes Fail!", log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiGetFactoryPublicCodesResp{Codes: models}
return
}

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

@ -1,27 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 获取全局第三方服务配置
func (this *apiComp) GetGlobalConfig(session comm.IUserSession, req *pb.ApiGetGlobalConfigReq) (resp *pb.ApiGetGlobalConfigResp, errdata *pb.ErrorData) {
var (
config []*pb.DBGlobalConfigItem
err error
)
if config, err = this.module.model.globalconfigbyregion(req.Region); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("GetGlobalConfig Fail!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiGetGlobalConfigResp{
Config: config,
}
return
}

53
apps/services/modules/api/api_updatebrand.go

@ -1,53 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 更新品牌商信息
func (this *apiComp) UpdateBrand(session comm.IUserSession, req *pb.ApiUpdateBrandReq) (resp *pb.ApiUpdateBrandResp, errdata *pb.ErrorData) {
var (
old *pb.DBBrand
err error
)
if req.Brand == nil || req.Brand.Id == 0 {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_ReqParameterError,
Message: "brand id is required",
}
return
}
if old, err = this.module.model.factory(req.Brand.Id); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("UpdateBrand Fail!", log.Field{Key: "req", Value: req.String()}, log.Field{Key: "err", Value: err.Error()})
return
}
// 仅允许客户端覆盖可编辑字段,productlists 等由服务端维护的字段不接受前端写入,
// 防止前端表单缺字段或携带陈旧值时把产品列表覆盖回旧值。
old.Brand = req.Brand.Brand
// providerid 不再由品牌商维护:方案商归属挂在产品上(DBProduct.providerid),这里刻意不覆盖旧值。
old.Description = req.Brand.Description
old.Contactmails = req.Brand.Contactmails
// 分成比例只有超管/管理员能改(品牌商自己改比例等于自定分成)。
if canAuditChannel(session) {
old.Sharerate = req.Brand.Sharerate
}
if err = this.module.model.savefactory(old); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("UpdateBrand Fail!", log.Field{Key: "req", Value: req.String()}, log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiUpdateBrandResp{
Brand: old,
}
return
}

51
apps/services/modules/api/api_updatefactorypubliccode.go

@ -1,51 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
"time"
)
// 更新厂家公码(按 code 主键定位;保留 createtime/useduses/factoryid 不变)
func (this *apiComp) UpdateFactoryPublicCode(session comm.IUserSession, req *pb.ApiUpdateFactoryPublicCodeReq) (resp *pb.ApiUpdateFactoryPublicCodeResp, errdata *pb.ErrorData) {
var (
model *pb.DBFactoryPublicCode
err error
)
if req.Code == "" {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_ReqParameterError,
Message: "code 必填",
}
return
}
if model, err = this.module.model.factoryPublicCode(req.Code); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: "公码不存在: " + err.Error(),
}
return
}
model.Productid = req.Productid
model.Remark = req.Remark
model.Status = req.Status
model.Maxuses = req.Maxuses
model.Expiretime = req.Expiretime
model.Bindrewardvpitime = req.Bindrewardvpitime
model.Bindrewardtranslate = req.Bindrewardtranslate
model.Bindrewardmeeting = req.Bindrewardmeeting
model.Updatetime = time.Now().Unix()
if err = this.module.model.saveFactoryPublicCode(model); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("UpdateFactoryPublicCode Fail!", log.Field{Key: "err", Value: err.Error()})
return
}
resp = &pb.ApiUpdateFactoryPublicCodeResp{Code: model}
return
}

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

@ -1,27 +0,0 @@
package api
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// 更新全局第三方服务配置
func (this *apiComp) UpdateGlobalConfig(session comm.IUserSession, req *pb.ApiUpdateGlobalConfigReq) (resp *pb.ApiUpdateGlobalConfigResp, errdata *pb.ErrorData) {
var (
err error
)
if err = this.module.model.updateglobalconfig(req.Config); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
this.module.Error("UpdateGlobalConfig 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.ApiUpdateGlobalConfigResp{}
return
}

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

@ -22,9 +22,6 @@ var auditableAPIMap = map[string]bool{
"api_addconfig": true,
"api_updateconfig": true,
"api_delconfig": true,
"api_addglobalconfigs": true,
"api_updateglobalconfig": true,
"api_delglobalconfigs": true,
// 智能体
"api_addagent": true,
"api_updateagent": true,
@ -36,9 +33,6 @@ var auditableAPIMap = map[string]bool{
"api_addproductversion": true,
"api_delproductversion": true,
// 品牌商 / 生产管理
"api_addbrand": true,
"api_updatebrand": true,
"api_delbrand": true,
"api_createauthcodes": true,
"api_revokeproductionbatch": true,
"api_disableauthcodes": true,

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

@ -41,15 +41,6 @@ var apiPageMap = map[string]string{
"api_rebuildstats": scopeAdmin, // 全量重建统计(清空+全库重算,仅超管)
// === factory 品牌商管理 ===
"api_getbrand": "factory",
"api_getbrands": "factory",
"api_addbrand": "factory",
"api_updatebrand": "factory",
"api_delbrand": "factory",
"api_getfactorypubliccodes": "factory",
"api_createfactorypubliccode": "factory",
"api_updatefactorypubliccode": "factory",
"api_delfactorypubliccode": "factory",
// === channel 渠道商管理(挂在品牌商下) ===
// 生产授权码要选渠道商,故 device 页也需读渠道列表;读接口归 channel 页,
@ -146,10 +137,6 @@ var apiPageMap = map[string]string{
"api_delwakeupvoice": "wakeup",
// === sysconfig 系统配置 ===
"api_getglobalconfig": "sysconfig",
"api_addglobalconfigs": "sysconfig",
"api_updateglobalconfig": "sysconfig",
"api_delglobalconfigs": "sysconfig",
// === audit 操作日志 ===
"api_getauditlogs": "audit",

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

@ -70,16 +70,9 @@ func (this *modelComp) Init(service core.IService, module core.IModule, comp cor
//Start 会直接跳过,新建的方案商仍然接着 4 发号;Floor 保留历史行、把序列抬到 0xAB01。
postgres.AutoIncrementFloor(comm.TableSolutionProvider, "id", 0xAB01)
}
if err = postgres.CreateTable(comm.TableBrand, &pb.DBBrand{}); err != nil {
this.module.Errorln(err)
} else {
//设置brand表的主键从0xA001开始(仅空表生效,跨 MySQL/Postgres 通用)
postgres.AutoIncrementStart(comm.TableBrand, "id", 0xA001)
}
//createtime 是后加字段:CreateTable 对已存在的表跳过 AutoMigrate,故显式补列(同 console 的 ensureDeviceTables)
if res := postgres.Exec("ALTER TABLE " + comm.TableBrand + " ADD COLUMN IF NOT EXISTS createtime bigint DEFAULT 0"); res.Error != nil {
this.module.Errorln(res.Error)
}
// ⚠️ 刻意不建 brand 表:品牌商 2026-09-12 整体下线,console 启动时会 DROP 掉它
// (见 modules/console/registry.go)。这里若照旧 CreateTable,api 服务一重启就把空表建回来,
// 两个服务互相拆台——console 删、api 建,取决于谁后启动。
if err = postgres.CreateTable(comm.TableChannel, &pb.DBChannel{}); err != nil {
this.module.Errorln(err)
}
@ -128,10 +121,6 @@ func (this *modelComp) Init(service core.IService, module core.IModule, comp cor
}
// 不建裸 license 表:授权码只住按产品切的 license_<pid十六进制> 分表(见下方按产品建表的循环),
// 不带后缀的那张从来没有代码读写,建出来只是每个环境多一张恒空表。
if err = postgres.CreateTable(comm.TableFactoryPublicCode, &pb.DBFactoryPublicCode{}); err != nil {
this.module.Errorln(err)
return err
}
if err = mysql.CreateTable(comm.TableGoods, &pb.DBGoods{}); err != nil {
this.module.Errorln(err)
return err
@ -303,32 +292,6 @@ func (this *modelComp) delconfig(id uint64) (err error) {
return
}
// 全局第三方服务配置
func (this *modelComp) globalconfig() (config []*pb.DBGlobalConfigItem, err error) {
config = make([]*pb.DBGlobalConfigItem, 0)
err = postgres.Find(comm.TableGlobalConfig, &config, "")
return
}
func (this *modelComp) globalconfigbyregion(region pb.Region) (config []*pb.DBGlobalConfigItem, err error) {
config = make([]*pb.DBGlobalConfigItem, 0)
err = postgres.Find(comm.TableGlobalConfig, &config, "region=?", region)
return
}
func (this *modelComp) addglobalconfig(config ...*pb.DBGlobalConfigItem) (err error) {
err = postgres.Insert(comm.TableGlobalConfig, config)
return
}
func (this *modelComp) updateglobalconfig(config ...*pb.DBGlobalConfigItem) (err error) {
err = postgres.Save(comm.TableGlobalConfig, config)
return
}
func (this *modelComp) delglobalconfig(ids []uint64) (err error) {
err = postgres.Delete(comm.TableGlobalConfig, "id IN ?", ids)
return
}
// 智能体
func (this *modelComp) agents() (agents []*pb.DBAgent, err error) {
agents = make([]*pb.DBAgent, 0)
@ -474,41 +437,13 @@ func (this *modelComp) delmcpservers(ids []string) (err error) {
return postgres.Delete(comm.TableSvcRegionOverride, "app_name=? AND svc_id IN ?", "", ids)
}
// 厂商列表
func (this *modelComp) factorys() (models []*pb.DBBrand, err error) {
models = make([]*pb.DBBrand, 0)
err = postgres.Find(comm.TableBrand, &models, "")
return
}
func (this *modelComp) factory(id uint32) (model *pb.DBBrand, err error) {
model = &pb.DBBrand{}
err = postgres.Find(comm.TableBrand, model, "id=?", id)
return
}
func (this *modelComp) addfactory(model *pb.DBBrand) (err error) {
err = postgres.Insert(comm.TableBrand, model)
return
}
func (this *modelComp) savefactory(model *pb.DBBrand) (err error) {
err = postgres.Save(comm.TableBrand, model)
return
}
func (this *modelComp) delfactory(id uint32) (err error) {
err = postgres.Delete(comm.TableBrand, "id=?", id)
return
}
// 渠道商(挂在品牌商下,id=8位短码)。与 console 侧同表同库,两端实现须保持一致。
// 渠道商(全局实体,id=8位短码)。与 console 侧同表同库,两端实现须保持一致。
// channels 按条件列渠道商:brandid=0 不限品牌商;status<0 不限状态;keyword 匹配名称或 id。
func (this *modelComp) channels(brandid uint32, status int32, keyword string) (models []*pb.DBChannel, err error) {
// channels 按条件列渠道商:status<0 不限状态;keyword 匹配名称或 id。
func (this *modelComp) channels(status int32, keyword string) (models []*pb.DBChannel, err error) {
models = make([]*pb.DBChannel, 0)
where := make([]string, 0, 3)
args := make([]interface{}, 0, 3)
if brandid != 0 {
where = append(where, "brandid=?")
args = append(args, brandid)
}
if status >= 0 {
where = append(where, "status=?")
args = append(args, status)
@ -772,6 +707,7 @@ func (this *modelComp) addfactoryDevics(pid uint32, models []*pb.DBAuthCode) (er
err = postgres.Table(comm.TableDeviceMac).CreateInBatches(models, 1000).Error
return
}
// 下面两个按主键(code)保存,主键全局唯一,不需要产品作用域。
func (this *modelComp) updatefactoryDevic(_ uint32, models *pb.DBAuthCode) (err error) {
err = postgres.Save(comm.TableDeviceMac, &models)
@ -795,42 +731,6 @@ func (this *modelComp) decrProductStatActivated(productid uint32) (err error) {
return
}
// 厂家公码---------------------------------------------------------------------
// 按品牌商 + 状态过滤查询公码列表(brandid=0 不过滤品牌商,statusFilter=-1 不过滤状态)
func (this *modelComp) factoryPublicCodes(brandid uint32, statusFilter int32) (models []*pb.DBFactoryPublicCode, err error) {
models = make([]*pb.DBFactoryPublicCode, 0)
tx := postgres.Table(comm.TableFactoryPublicCode)
if brandid != 0 {
tx = tx.Where("brandid=?", brandid)
}
if statusFilter >= 0 {
tx = tx.Where("status=?", statusFilter)
}
err = tx.Order("createtime DESC").Find(&models).Error
return
}
func (this *modelComp) factoryPublicCode(code string) (model *pb.DBFactoryPublicCode, err error) {
model = &pb.DBFactoryPublicCode{}
err = postgres.FindOne(comm.TableFactoryPublicCode, model, "code=?", code)
return
}
func (this *modelComp) addFactoryPublicCode(model *pb.DBFactoryPublicCode) (err error) {
err = postgres.Insert(comm.TableFactoryPublicCode, model)
return
}
func (this *modelComp) saveFactoryPublicCode(model *pb.DBFactoryPublicCode) (err error) {
err = postgres.Save(comm.TableFactoryPublicCode, model)
return
}
func (this *modelComp) delFactoryPublicCode(code string) (err error) {
err = postgres.Delete(comm.TableFactoryPublicCode, "code=?", code)
return
}
// 会议记录模板管理---------------------------------------------------------------------
// 列表查询:排除 outline / template 大文本字段,避免传输过大
func (this *modelComp) gettemplates() (models []*pb.DBEchoMeetTemplate, err error) {

67
apps/services/modules/console/api_account.go

@ -18,41 +18,37 @@ import (
// validIdentity 限定可分配的角色取值(1~5)。
func validIdentity(idt pb.Identity) bool {
if idt == pb.Identity_Agent {
return false // 品牌商(代理)已下线,见 identityHint
}
return idt >= pb.Identity_Admin && idt <= pb.Identity_Dealer
}
// identityHint 角色取值说明,供参数校验的错误信息复用。
const identityHint = "角色非法(1超管/2管理员/3品牌商(代理)/4运营/5渠道商)"
// ⚠️ 3(品牌商/代理) 已于 2026-09-12 随 brand 表下线,不再接受新建;存量账号由
// migrateBrandAccountsToOperator 降级成 4(运营)。枚举值保留在 pb 里,只是后台不再产出。
const identityHint = "角色非法(1超管/2管理员/4运营/5渠道商)"
// validChannelBinding 校验品牌商/渠道商账号的归属绑定是否自洽:
// - 品牌商账号(3):必须绑定品牌商,且不绑定渠道商(它管的是名下全部渠道商);
// - 渠道商账号(5):必须同时绑定品牌商与渠道商,且该渠道商确实归属该品牌商;
// - 其余角色:两者都必须留空,避免误配出「超管绑了某品牌商」这种矛盾状态。
// validChannelBinding 校验渠道商账号的归属绑定是否自洽:
// - 渠道商账号(5):必须绑定一个真实存在的渠道商;
// - 其余角色:必须留空,避免误配出「超管绑了某渠道商」这种矛盾状态。
//
// ⚠️ 2026-09-12 前这里还管品牌商账号(3),并要求渠道商账号同时绑品牌商、校验两者归属一致。
// 品牌商随 brand 表整体下线后,渠道商成了全局实体,只剩渠道商这一维。
//
// 返回错误说明(空串=通过)。
func validChannelBinding(idt pb.Identity, brandid uint32, channelid string) string {
func validChannelBinding(idt pb.Identity, channelid string) string {
switch idt {
case pb.Identity_Agent:
if brandid == 0 {
return "品牌商账号必须绑定品牌商"
}
if channelid != "" {
return "品牌商账号不能绑定渠道商(它可见名下全部渠道商)"
}
case pb.Identity_Dealer:
if brandid == 0 || channelid == "" {
return "渠道商账号必须同时绑定品牌商与渠道商"
if channelid == "" {
return "渠道商账号必须绑定渠道商"
}
ch, err := dvChannel(consoleDeviceConn(), channelid)
if err != nil {
if _, err := dvChannel(consoleDeviceConn(), channelid); err != nil {
return "渠道商不存在: " + channelid
}
if ch.Brandid != brandid {
return "渠道商不属于所选品牌商: " + channelid
}
default:
if brandid != 0 || channelid != "" {
return "只有品牌商账号/渠道商账号才能绑定品牌商与渠道商"
if channelid != "" {
return "只有渠道商账号才能绑定渠道商"
}
}
return ""
@ -151,10 +147,10 @@ func (this *serverComp) checkNewAccount(username, password string) string {
return this.checkNewAccountName(username)
}
// createBoundAccount 落库一个绑定品牌商/渠道商的账号(密码 bcrypt 哈希,建出来即启用)。
// 菜单权限留空:前端按角色回退到 DEFAULT_BRAND_ACCESS / DEFAULT_DEALER_ACCESS 默认集。
// createBoundAccount 落库一个绑定渠道商的账号(密码 bcrypt 哈希,建出来即启用)。
// 菜单权限留空:前端按角色回退到 DEFAULT_DEALER_ACCESS 默认集。
// 调用前须先过 checkNewAccount 与 validChannelBinding。
func (this *serverComp) createBoundAccount(username, password, remark string, idt pb.Identity, brandid uint32, channelid string) (*Account, error) {
func (this *serverComp) createBoundAccount(username, password, remark string, idt pb.Identity, channelid string) (*Account, error) {
hash, err := hashPassword(password)
if err != nil {
return nil, err
@ -166,7 +162,6 @@ func (this *serverComp) createBoundAccount(username, password, remark string, id
Identity: idt,
Enabled: true,
Remark: remark,
Brandid: brandid,
Channelid: channelid,
Createtime: now,
Updatetime: now,
@ -177,7 +172,7 @@ func (this *serverComp) createBoundAccount(username, password, remark string, id
return model, nil
}
// boundAccountResult 「新增品牌商/渠道商时顺手开账号」的结果。
// boundAccountResult 「新增渠道商时顺手开账号」的结果。
// Password 是明文,只在创建那一次的响应里回显(库里只有 bcrypt 哈希,事后谁也查不回来)——
// 邮件可能发不出去(对方没填邮箱、SMTP 挂了),页面上必须能看到一次,否则账号就成了死号。
type boundAccountResult struct {
@ -200,13 +195,13 @@ func credentialMails(mails string) []string {
return out
}
// openBoundAccount 给刚建好的品牌商/渠道商开一个绑定账号,并把登录凭据邮件发过去。
// - username 留空 → 用 defaultName(由 id 推导,如 Brand-A001 / Channel-K7QM3ZTX),撞名自动加后缀;
// openBoundAccount 给刚建好的渠道商开一个绑定账号,并把登录凭据邮件发过去。
// - username 留空 → 用 defaultName(由 id 推导,如 Channel-K7QM3ZTX),撞名自动加后缀;
// - password 留空 → 随机生成 12 位可读密码。
//
// 建号失败返回 error(调用方据此报错);邮件失败只记在结果里,不影响账号已建成的事实。
func (this *serverComp) openBoundAccount(idt pb.Identity, defaultName, username, password, remark string,
brandid uint32, channelid, roleLabel, ownerName, mails string) (*boundAccountResult, error) {
channelid, roleLabel, ownerName, mails string) (*boundAccountResult, error) {
name := strings.TrimSpace(username)
if name == "" {
var err error
@ -221,7 +216,7 @@ func (this *serverComp) openBoundAccount(idt pb.Identity, defaultName, username,
return nil, err
}
}
if _, err := this.createBoundAccount(name, pwd, remark, idt, brandid, channelid); err != nil {
if _, err := this.createBoundAccount(name, pwd, remark, idt, channelid); err != nil {
return nil, err
}
res := &boundAccountResult{Username: name, Password: pwd}
@ -257,8 +252,7 @@ func (this *serverComp) accountsAdd(c *gin.Context) {
Apps string `json:"apps"` // 绑定应用名称 CSV
Regions string `json:"regions"` // 绑定区域代码 CSV
Products string `json:"products"` // 绑定产品 id CSV
// 渠道分成体系的归属绑定:品牌商账号只填 brandid;渠道商账号两者都填。
Brandid uint32 `json:"brandid"`
// 渠道分成体系的归属绑定:只有渠道商账号填 channelid。
Channelid string `json:"channelid"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@ -273,7 +267,7 @@ func (this *serverComp) accountsAdd(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, identityHint)
return
}
if msg := validChannelBinding(req.Identity, req.Brandid, req.Channelid); msg != "" {
if msg := validChannelBinding(req.Identity, req.Channelid); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
@ -293,7 +287,6 @@ func (this *serverComp) accountsAdd(c *gin.Context) {
Apps: req.Apps,
Regions: req.Regions,
Products: req.Products,
Brandid: req.Brandid,
Channelid: req.Channelid,
Createtime: now,
Updatetime: now,
@ -316,7 +309,6 @@ func (this *serverComp) accountsUpdate(c *gin.Context) {
Apps string `json:"apps"`
Regions string `json:"regions"`
Products string `json:"products"`
Brandid uint32 `json:"brandid"`
Channelid string `json:"channelid"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Id == 0 {
@ -327,7 +319,7 @@ func (this *serverComp) accountsUpdate(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, identityHint)
return
}
if msg := validChannelBinding(req.Identity, req.Brandid, req.Channelid); msg != "" {
if msg := validChannelBinding(req.Identity, req.Channelid); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
@ -343,7 +335,6 @@ func (this *serverComp) accountsUpdate(c *gin.Context) {
model.Apps = req.Apps
model.Regions = req.Regions
model.Products = req.Products
model.Brandid = req.Brandid
model.Channelid = req.Channelid
model.Updatetime = time.Now().Unix()
if err := this.module.model.saveAccount(model); err != nil {

357
apps/services/modules/console/api_agent.go

@ -1,357 +0,0 @@
package console
import (
"errors"
"strconv"
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/postgres"
"yunyan/pb"
"github.com/gin-gonic/gin"
)
// ============================ Agent 配置域 (agent) ============================
//
// yunyan 自管 agents 的管理端 CRUD,存 console 主库(postgres.GetSys())。
// agent 编排引用第三方服务(ThirdSvcConfig)的 id;保存时按 Type 校验必需的服务引用「存在且类别匹配」。
// 配置「下发客户端」由后续阶段的下发接口 + modules/agents 改造承接。
// agentSvcRef 一个待校验的服务引用(哪个位置、引用的 svcId、允许的服务类别集合)。
// cats 是「任一命中即可」:LLM 槽位既收文本大模型也收多媒体大模型——
// 多媒体模型(GPT-4o / 千问 VL / Gemini / 豆包视觉)本身就能处理纯文本对话,
// 是文本模型能力的超集,没有理由把它挡在对话型 agent 之外。
type agentSvcRef struct {
label string
svcId string
cats []int32
}
// agentLLMCats 对话型 agent 的 LLM 槽位可选类别(与 admin agents.vue 的 CAT_LLM/CAT_LLM_VISION 对齐)。
var agentLLMCats = []int32{svcCatLLM, svcCatLLMVision}
// collectAgentSvcRefs 按 agent 类型收集其应校验的服务引用集合。
func collectAgentSvcRefs(a *AgentConfig) []agentSvcRef {
switch a.Type {
case AgentTypeChat:
return []agentSvcRef{
{"STT 语音识别", a.SttSvcId, []int32{svcCatSTT}},
{"LLM 大语言", a.LlmSvcId, agentLLMCats},
{"TTS 语音合成", a.TtsSvcId, []int32{svcCatTTS}},
}
case AgentTypeTranslate:
return []agentSvcRef{
{"STT 语音识别", a.SttSvcId, []int32{svcCatSTT}},
{"MT 机器翻译", a.MtSvcId, []int32{svcCatMT}},
{"TTS 语音合成", a.TtsSvcId, []int32{svcCatTTS}},
}
case AgentTypeStsChat:
return []agentSvcRef{{"STS 端到端对话", a.StsSvcId, []int32{svcCatSTS}}}
case AgentTypeAstTranslate:
return []agentSvcRef{{"AST 端到端翻译", a.AstSvcId, []int32{svcCatAST}}}
}
return nil
}
// svcHasAnyCategory 判断 ThirdSvcConfig.Categories("1,2,3") 是否命中 cats 中任意一个类别。
func svcHasAnyCategory(categories string, cats []int32) bool {
have := make(map[string]bool, 4)
for _, s := range strings.Split(categories, ",") {
if s = strings.TrimSpace(s); s != "" {
have[s] = true
}
}
for _, c := range cats {
if have[strconv.Itoa(int(c))] {
return true
}
}
return false
}
// validateAgentSvcRefs 校验 agent 引用的服务:必填、存在、类别匹配。返回错误说明(空串=通过)。
// 引用在 agent 自己的应用作用域内解析,未命中回退全局(app_name=”)——与运行时「应用优先、无则全局」一致。
func validateAgentSvcRefs(a *AgentConfig) string {
refs := collectAgentSvcRefs(a)
if len(refs) == 0 {
return "未知的智能体类型: " + a.Type
}
for _, r := range refs {
if strings.TrimSpace(r.svcId) == "" {
return r.label + " 必选"
}
cfg := &ThirdSvcConfig{}
err := postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", a.AppName, r.svcId)
if err != nil && a.AppName != "" { // 应用作用域未命中 → 回退全局
err = postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", "", r.svcId)
}
if err != nil {
return r.label + " 引用的服务不存在: " + r.svcId
}
if !svcHasAnyCategory(cfg.Categories, r.cats) {
return r.label + " 引用的服务类别不匹配: " + r.svcId
}
}
return ""
}
// validateAgentType 校验类型可用。四类都可建:对话两类下发成客户端 agent 列表,
// 翻译两类供通话翻译规则按语言选路引用。
func validateAgentType(a *AgentConfig) string {
if comm.AgentTypeSelectable(a.Type) {
return ""
}
return "智能体类型不支持: " + a.Type +
"(支持 对话(chat) / 端到端对话(sts-chat) / 三段式翻译(translate) / 端到端翻译(ast-translate))"
}
// validateAgentMcpRefs 校验绑定的 MCP 服务:存在且类别含 MCP。空列表直接通过。
// 解析口径同服务引用:应用作用域优先、未命中回退全局。
func validateAgentMcpRefs(a *AgentConfig) string {
for _, id := range a.McpSvcIds {
id = strings.TrimSpace(id)
if id == "" {
continue
}
cfg := &ThirdSvcConfig{}
err := postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", a.AppName, id)
if err != nil && a.AppName != "" {
err = postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", "", id)
}
if err != nil {
return "绑定的 MCP 服务不存在: " + id
}
if !svcHasAnyCategory(cfg.Categories, []int32{svcCatMCP}) {
return "绑定的服务不是 MCP 类型: " + id
}
}
return ""
}
// normalizeAgent 落库前清洗:去掉空的 MCP 引用与无 key 的变量项,避免脏数据下发到客户端。
func normalizeAgent(a *AgentConfig) {
ids := make([]string, 0, len(a.McpSvcIds))
seen := map[string]bool{}
for _, id := range a.McpSvcIds {
if id = strings.TrimSpace(id); id != "" && !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
a.McpSvcIds = ids
// 工具白名单只保留仍绑着的服务:取消绑定后再重新勾上,不该冒出上次的残留选择。
// 某服务的列表为空就整条删掉——空列表与「没这个 key」同义(全放开),留着只会让下发数据变脏。
tools := make(map[string][]string, len(a.McpTools))
for _, id := range ids {
names := make([]string, 0, len(a.McpTools[id]))
dup := map[string]bool{}
for _, n := range a.McpTools[id] {
if n = strings.TrimSpace(n); n != "" && !dup[n] {
dup[n] = true
names = append(names, n)
}
}
if len(names) > 0 {
tools[id] = names
}
}
a.McpTools = tools
vars := make([]comm.AgentVariable, 0, len(a.Variables))
for _, v := range a.Variables {
if v.Key = strings.TrimSpace(v.Key); v.Key != "" {
vars = append(vars, v)
}
}
a.Variables = vars
}
// ============================== Agent ID 生成 ==============================
// agentIdPrefix agent 业务 id 的类型前缀:id 由后台生成,但仍希望肉眼能看出是哪类 agent。
func agentIdPrefix(t string) string {
switch t {
case AgentTypeChat:
return "chat"
case AgentTypeStsChat:
return "sts"
case AgentTypeTranslate:
return "trans"
case AgentTypeAstTranslate:
return "ast"
}
return "agent"
}
// genAgentId 生成作用域内唯一的 agent 业务 id(形如 chat-a3k7m2qp)。
// 运营只填名称/类型,id 一律由后台生成——避免人工起名撞车,也免得下发给客户端的标识随手改。
// 唯一性最终由 (app_name,id) 唯一索引兜底,这里做几次探测降低冲突概率。
func genAgentId(appName, agentType string) (string, error) {
prefix := agentIdPrefix(agentType)
for i := 0; i < 8; i++ {
code, err := comm.GenerateShortCode(8)
if err != nil {
return "", err
}
id := prefix + "-" + strings.ToLower(code)
exist := &AgentConfig{}
if err := postgres.FindOne(comm.TableAgentConfig, exist, "app_name=? AND id=?", appName, id); err != nil {
return id, nil // 查不到 = 没被占用
}
}
return "", errors.New("生成智能体ID失败,请重试")
}
// ============================== Agent CRUD ==============================
// getAgents 列出某作用域(app_name)下全部 agent 配置。app_name=” 为全局默认。
func (this *serverComp) getAgents(c *gin.Context) {
var req struct {
AppName string `json:"app_name"`
}
_ = c.ShouldBindJSON(&req)
items := make([]*AgentConfig, 0)
if err := postgres.Find(comm.TableAgentConfig, &items, "app_name=?", req.AppName); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, gin.H{"items": items})
}
// getAgent 取单个 agent 配置(按 app_name+id)。
func (this *serverComp) getAgent(c *gin.Context) {
var req struct {
AppName string `json:"app_name"`
Id string `json:"id"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Id) == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
return
}
m := &AgentConfig{}
if err := postgres.FindOne(comm.TableAgentConfig, m, "app_name=? AND id=?", req.AppName, req.Id); err != nil {
writeErr(c, pb.ErrorCode_DBError, "智能体不存在: "+req.Id)
return
}
writeOK(c, gin.H{"agent": m})
}
// addAgent 新增 agent 配置(作用域内 app_name+id 唯一)。
func (this *serverComp) addAgent(c *gin.Context) {
var m AgentConfig
if err := c.ShouldBindJSON(&m); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if !this.requireAppNameScope(c, m.AppName) { // 应用级账号只能在自己名下的应用里建
return
}
m.Name = strings.TrimSpace(m.Name)
if m.Name == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "智能体名称 必填")
return
}
if msg := validateAgentType(&m); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
// 业务 id 由后台生成:前端不再填。仍兼容显式传入(导入/迁移场景),此时校验作用域内不重复。
if m.Id = strings.TrimSpace(m.Id); m.Id == "" {
id, err := genAgentId(m.AppName, m.Type)
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, err.Error())
return
}
m.Id = id
} else {
exist := &AgentConfig{}
if err := postgres.FindOne(comm.TableAgentConfig, exist, "app_name=? AND id=?", m.AppName, m.Id); err == nil {
writeErr(c, pb.ErrorCode_ReqParameterError, "智能体ID 已存在: "+m.Id)
return
}
}
if msg := validateAgentSvcRefs(&m); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
if msg := validateAgentMcpRefs(&m); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
normalizeAgent(&m)
m.RowId = 0 // 代理主键由库自增回填
now := time.Now().UnixMilli()
m.Createtime = now
m.Updatetime = now
if err := postgres.Insert(comm.TableAgentConfig, &m); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
broadcastConfigChanged(comm.ConfigKindAgent, "add", 0)
writeOK(c, &m)
}
// updateAgent 更新 agent 配置(按 app_name+id)。
func (this *serverComp) updateAgent(c *gin.Context) {
var m AgentConfig
if err := c.ShouldBindJSON(&m); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if strings.TrimSpace(m.Id) == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "智能体ID 必填")
return
}
if !this.requireAppNameScope(c, m.AppName) {
return
}
old := &AgentConfig{}
if err := postgres.FindOne(comm.TableAgentConfig, old, "app_name=? AND id=?", m.AppName, m.Id); err != nil {
writeErr(c, pb.ErrorCode_DBError, "智能体不存在: "+m.Id)
return
}
if msg := validateAgentType(&m); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
if msg := validateAgentSvcRefs(&m); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
if msg := validateAgentMcpRefs(&m); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
normalizeAgent(&m)
m.RowId = old.RowId // 按代理主键更新本行
m.Createtime = old.Createtime
m.Updatetime = time.Now().UnixMilli()
if err := postgres.Save(comm.TableAgentConfig, &m); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
broadcastConfigChanged(comm.ConfigKindAgent, "update", 0)
writeOK(c, &m)
}
// delAgent 删除 agent 配置(按 app_name+id)。
func (this *serverComp) delAgent(c *gin.Context) {
var req struct {
AppName string `json:"app_name"`
Id string `json:"id"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Id) == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
return
}
if !this.requireAppNameScope(c, req.AppName) {
return
}
if err := postgres.Delete(comm.TableAgentConfig, "app_name=? AND id=?", req.AppName, req.Id); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
broadcastConfigChanged(comm.ConfigKindAgent, "delete", 0)
writeOK(c, gin.H{"id": req.Id})
}

58
apps/services/modules/console/api_agent_test.go

@ -1,58 +0,0 @@
package console
import (
"testing"
"yunyan/comm"
)
// normalizeAgent 是落库前最后一道清洗:解绑过的 MCP 服务不能把工具白名单留在库里,
// 否则重新勾上该服务时会冒出上一轮的选择,运营看不见、客户端却会照着执行。
func TestNormalizeAgentMcpTools(t *testing.T) {
a := &AgentConfig{
McpSvcIds: []string{" mcp_map ", "mcp_music", "mcp_map", ""},
McpTools: map[string][]string{
"mcp_map": {"geocode", " geocode ", "", "route"},
"mcp_music": {}, // 空列表 = 全放开,不该留在库里
"mcp_gone": {"search"}, // 已解绑的服务,残留必须清掉
},
}
normalizeAgent(a)
if len(a.McpSvcIds) != 2 || a.McpSvcIds[0] != "mcp_map" || a.McpSvcIds[1] != "mcp_music" {
t.Fatalf("MCP 引用清洗错:%v", a.McpSvcIds)
}
if _, ok := a.McpTools["mcp_gone"]; ok {
t.Fatalf("解绑服务的工具白名单没清掉:%v", a.McpTools)
}
if _, ok := a.McpTools["mcp_music"]; ok {
t.Fatalf("空白名单应整条删掉(空=全放开):%v", a.McpTools)
}
got := a.McpTools["mcp_map"]
if len(got) != 2 || got[0] != "geocode" || got[1] != "route" {
t.Fatalf("工具名去空白/去重错:%v", got)
}
}
// 没配过工具白名单的老 agent(mcp_tools 为 nil)不能因为清洗炸掉,也不该凭空长出条目。
func TestNormalizeAgentMcpToolsNil(t *testing.T) {
a := &AgentConfig{McpSvcIds: []string{"mcp_map"}}
normalizeAgent(a)
if a.McpTools == nil || len(a.McpTools) != 0 {
t.Fatalf("nil 白名单应清洗成空 map:%v", a.McpTools)
}
}
// id 前缀按类型走,肉眼能看出这是哪类 agent;未知类型兜底成 agent。
func TestAgentIdPrefix(t *testing.T) {
for typ, want := range map[string]string{
comm.AgentTypeChat: "chat",
comm.AgentTypeStsChat: "sts",
"": "agent",
"whatever": "agent",
} {
if got := agentIdPrefix(typ); got != want {
t.Fatalf("type=%q 前缀应为 %s,实际 %s", typ, want, got)
}
}
}

249
apps/services/modules/console/api_calltranslate.go

@ -1,249 +0,0 @@
package console
import (
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/postgres"
"yunyan/pb"
"github.com/gin-gonic/gin"
)
// ============================ 通话翻译配置域 (calltranslate) ============================
//
// 数据存 console 主库(postgres.GetSys()),模型见 comm/calltranslate.go。
//
// 规则化模型(取代旧的 call_translate_pair 语言对表):
// 一条规则 = 某应用作用域 + 某区域下的「多源语言 × 多目标语言 → 一个 agent」映射,带优先级。
// 运行时按 comm.MatchCallTranslateRule 选路:优先级大者先,同优先级精确规则压过 * 通配规则。
// 规则不再直接拼 asr/mt/tts —— 服务编排收敛在 agent 里,规则只决定「什么语言走哪个 agent」。
// 规则可引用的 agent 类型:三段式翻译 与 端到端翻译。chat 类不能用于通话翻译选路。
var callTranslateAgentTypes = []string{comm.AgentTypeTranslate, comm.AgentTypeAstTranslate}
// findAgentForRule 在规则的应用作用域内找 agent,未命中回退全局(与 validateAgentSvcRefs 的解析口径一致)。
func findAgentForRule(appName, agentId string) (*AgentConfig, error) {
a := &AgentConfig{}
err := postgres.FindOne(comm.TableAgentConfig, a, "app_name=? AND id=?", appName, agentId)
if err != nil && appName != "" {
err = postgres.FindOne(comm.TableAgentConfig, a, "app_name=? AND id=?", "", agentId)
}
return a, err
}
// validateRule 校验规则自身与其引用的 agent,返回错误说明(空串=通过)。
func validateRule(m *comm.CallTranslateRule) string {
if len(comm.SplitLangs(m.SrcLangs)) == 0 {
return "源语言 必选(或选「任意语言」)"
}
if len(comm.SplitLangs(m.DstLangs)) == 0 {
return "目标语言 必选(或选「任意语言」)"
}
if strings.TrimSpace(m.AgentId) == "" {
return "翻译 Agent 必选"
}
a, err := findAgentForRule(m.AppName, m.AgentId)
if err != nil {
return "引用的 Agent 不存在: " + m.AgentId
}
ok := false
for _, t := range callTranslateAgentTypes {
if a.Type == t {
ok = true
break
}
}
if !ok {
return "Agent「" + a.Name + "」类型为 " + a.Type + ",通话翻译只能引用 translate(三段式)或 ast-translate(端到端)"
}
return ""
}
// getCallTranslateRules 列出某作用域下的规则;region>0 只取该区域 + 不限区域(0)的规则,region<=0 取全量。
// 返回已按生效顺序排好序(优先级降序 → id 升序),前端直接按序展示即可。
func (this *serverComp) getCallTranslateRules(c *gin.Context) {
var req struct {
AppName string `json:"app_name"`
Region int32 `json:"region"`
}
_ = c.ShouldBindJSON(&req)
items := make([]*comm.CallTranslateRule, 0)
var err error
if req.Region > 0 {
err = postgres.Find(comm.TableCallTranslateRule, &items, "app_name=? AND (region=? OR region=?)",
req.AppName, req.Region, comm.RegionAny)
} else {
err = postgres.Find(comm.TableCallTranslateRule, &items, "app_name=?", req.AppName)
}
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
sortRulesByEffect(items)
writeOK(c, gin.H{"items": items})
}
// sortRulesByEffect 按运行时生效顺序排序:优先级降序 → id 升序(插入排序,规则量级很小)。
func sortRulesByEffect(items []*comm.CallTranslateRule) {
for i := 1; i < len(items); i++ {
cur := items[i]
j := i - 1
for j >= 0 && (items[j].Priority < cur.Priority || (items[j].Priority == cur.Priority && items[j].Id > cur.Id)) {
items[j+1] = items[j]
j--
}
items[j+1] = cur
}
}
// saveCallTranslateRule 新增或更新一条规则(id=0 新增,否则按 id 更新)。
func (this *serverComp) saveCallTranslateRule(c *gin.Context) {
var m comm.CallTranslateRule
if err := c.ShouldBindJSON(&m); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if msg := validateRule(&m); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
now := time.Now().UnixMilli()
m.Updatetime = now
var err error
if m.Id == 0 {
m.Createtime = now
err = postgres.Insert(comm.TableCallTranslateRule, &m)
} else {
old := &comm.CallTranslateRule{}
if e := postgres.FindOne(comm.TableCallTranslateRule, old, "id=?", m.Id); e != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, "规则不存在")
return
}
m.Createtime = old.Createtime
err = postgres.Save(comm.TableCallTranslateRule, &m)
}
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
broadcastConfigChanged(comm.ConfigKindCallTranslate, "update", m.Region)
writeOK(c, &m)
}
// delCallTranslateRules 按 id 批量删除规则。
func (this *serverComp) delCallTranslateRules(c *gin.Context) {
var req struct {
Ids []uint64 `json:"ids"`
}
_ = c.ShouldBindJSON(&req)
if len(req.Ids) == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "ids 必填")
return
}
if err := postgres.Delete(comm.TableCallTranslateRule, "id IN ?", req.Ids); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
broadcastConfigChanged(comm.ConfigKindCallTranslate, "delete", 0)
writeOK(c, gin.H{"deleted": len(req.Ids)})
}
// syncCallTranslateRules 把指定规则复制到多个目标区域。
// 目标区域已有「同 agent + 同源语言集 + 同目标语言集」的规则则覆盖,否则新建;源区域自身跳过。
func (this *serverComp) syncCallTranslateRules(c *gin.Context) {
var req struct {
RuleIds []uint64 `json:"rule_ids"`
TargetRegions []int32 `json:"target_regions"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.RuleIds) == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "rule_ids 必填")
return
}
if len(req.TargetRegions) == 0 {
writeOK(c, gin.H{"synced": 0})
return
}
srcRules := make([]*comm.CallTranslateRule, 0, len(req.RuleIds))
if err := postgres.Find(comm.TableCallTranslateRule, &srcRules, "id IN ?", req.RuleIds); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
now := time.Now().UnixMilli()
synced := 0
for _, r := range srcRules {
for _, tgt := range req.TargetRegions {
if r.Region == tgt {
continue
}
old := &comm.CallTranslateRule{}
_ = postgres.FindOne(comm.TableCallTranslateRule, old,
"app_name=? AND region=? AND agent_id=? AND src_langs=? AND dst_langs=?",
r.AppName, tgt, r.AgentId, r.SrcLangs, r.DstLangs)
m := &comm.CallTranslateRule{
Id: old.Id,
AppName: r.AppName,
Region: tgt,
Name: r.Name,
SrcLangs: r.SrcLangs,
DstLangs: r.DstLangs,
Bidir: r.Bidir,
AgentId: r.AgentId,
Priority: r.Priority,
Enable: r.Enable,
Remark: r.Remark,
Updatetime: now,
}
if old.Id == 0 {
m.Createtime = now
if err := postgres.Insert(comm.TableCallTranslateRule, m); err == nil {
synced++
}
} else {
m.Createtime = old.Createtime
if err := postgres.Save(comm.TableCallTranslateRule, m); err == nil {
synced++
}
}
}
}
if synced > 0 {
for _, tgt := range req.TargetRegions {
broadcastConfigChanged(comm.ConfigKindCallTranslate, "update", tgt)
}
}
writeOK(c, gin.H{"synced": synced})
}
// testCallTranslateRule 选路试算:给定区域与源/目标语言,返回实际会命中的规则及其 agent。
// 规则一多就很难靠肉眼确认「zh-CN→ja-JP 到底走哪条」,故给后台一个所见即所得的验证入口,
// 走的是与运行时完全相同的 comm.MatchCallTranslateRule。
func (this *serverComp) testCallTranslateRule(c *gin.Context) {
var req struct {
AppName string `json:"app_name"`
Region int32 `json:"region"`
SrcLang string `json:"src_lang"`
DstLang string `json:"dst_lang"`
}
_ = c.ShouldBindJSON(&req)
if strings.TrimSpace(req.SrcLang) == "" || strings.TrimSpace(req.DstLang) == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "src_lang / dst_lang 必填")
return
}
items := make([]*comm.CallTranslateRule, 0)
if err := postgres.Find(comm.TableCallTranslateRule, &items, "app_name=?", req.AppName); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
hit := comm.MatchCallTranslateRule(items, req.Region, req.SrcLang, req.DstLang)
if hit == nil {
writeOK(c, gin.H{"matched": false})
return
}
resp := gin.H{"matched": true, "rule": hit}
if a, err := findAgentForRule(hit.AppName, hit.AgentId); err == nil {
resp["agent"] = a
}
writeOK(c, resp)
}

106
apps/services/modules/console/api_channel.go

@ -11,14 +11,14 @@ import (
// ============================ 渠道商域 (channel) ============================
//
// 渠道商挂在品牌商下:一个品牌商可绑定多个渠道商,id 为 8 位可读短码(主键)。
// 渠道商是生产授权码、统计与月结算的共同维度——生产时选定的渠道商随批次落进每条授权码,
// 渠道商是全局实体(品牌商 2026-09-12 下线后不再有上级归属),id 为 8 位可读短码(主键)。
// 它是生产授权码、统计与月结算的共同维度——生产时选定的渠道商随批次落进每条授权码,
// 用户绑定该设备后其后续数据即归属该渠道商。
//
// 新增走审批:品牌商账号自助添加的渠道商 status=0(待审核),须超管/管理员通过后
// 新增走审批:无审批权的账号添加的渠道商 status=0(待审核),须超管/管理员通过后
// (status=1 启用) 才能用于生产。超管/管理员自己新增的直接启用。
//
// 数据在 console 主库(supabase),与品牌商/产品/授权码同库,故复用设备域的 consoleDeviceConn()。
// 数据在 console 主库(supabase),与产品/授权码同库,故复用设备域的 consoleDeviceConn()。
// 渠道商状态取值(与 DBChannel.status 及前端一致)。
const (
@ -29,7 +29,6 @@ const (
)
// channelShareRateMax 渠道分成比例上限(万分比,10000 = 100%)。
// 语义为「从品牌商那份里再切」,故本身不会超过 100%。
// 现在校验的是【生产批次】上的比例(production_batch.sharerate),渠道商表里那列已停止维护。
const channelShareRateMax int32 = 10000
@ -39,13 +38,13 @@ func canAuditChannel(c *gin.Context) bool {
return idt == pb.Identity_Admin || idt == pb.Identity_Manager
}
// getChannels 列出渠道商。前端传 brandid 过滤;status 不传(0) 与「待审核」冲突,
// getChannels 列出渠道商。status 不传(0) 与「待审核」冲突,
// 故约定 status<0 为不限,前端默认传 -1。
func (this *serverComp) getChannels(c *gin.Context, sys *appConn) {
var req pb.ApiGetChannelsReq
_ = c.ShouldBindJSON(&req)
// 品牌商/渠道商账号:品牌商过滤强制成自己的绑定,再对结果按可见范围收敛(双保险)。
models, err := dvChannels(sys, effectiveBrandId(c, req.Brandid), req.Status, req.Keyword)
// 渠道商账号:对结果按可见范围收敛。
models, err := dvChannels(sys, req.Status, req.Keyword)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
@ -72,37 +71,8 @@ func (this *serverComp) getChannel(c *gin.Context, sys *appConn) {
writeOK(c, &pb.ApiGetChannelResp{Channel: model})
}
// addOfficialChannel 给刚建好的品牌商配一条「官方渠道」:品牌自营的那条销售线。
// 与手工新增的渠道商不同——它随品牌商自动生成、直接启用(无需审批)、不配渠道账号
// (品牌自己的账号就管着它),分成比例固定 0:品牌那份提成本来就全归品牌自己,
// 再从自家渠道切一刀没有意义。name 留空则用「<品牌名>官方渠道」。
func addOfficialChannel(sys *appConn, brand *pb.DBBrand, name, operator string) (*pb.DBChannel, error) {
now := time.Now().Unix()
if name = strings.TrimSpace(name); name == "" {
name = brand.Brand + "官方渠道"
}
m := &pb.DBChannel{
Id: genChannelId(sys),
Brandid: brand.Id,
Name: name,
Contactmails: brand.Contactmails,
Remark: "品牌官方渠道(随品牌商自动创建,代表品牌自营销售)",
Sharerate: 0,
Status: channelStatusEnabled,
Official: true,
Auditaccount: operator,
Audittime: now,
Createtime: now,
Updatetime: now,
}
if err := dvAddChannel(sys, m); err != nil {
return nil, err
}
return m, nil
}
// addChannel 新增渠道商。id 一律服务端生成短码,前端传入的 id 被忽略。
// 无审批权的账号(品牌商)提交后为待审核,且申请人记在 applyaccount 上。
// 无审批权的账号提交后为待审核,且申请人记在 applyaccount 上。
// 勾选 create_account 时一并建一个绑定该渠道商的渠道商账号(identity=5,仅超管可建)。
func (this *serverComp) addChannel(c *gin.Context, sys *appConn) {
var req pb.ApiAddChannelReq
@ -117,26 +87,13 @@ func (this *serverComp) addChannel(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_ReqParameterError, "渠道商名称必填")
return
}
// 品牌商已不是渠道商的必填项(2026-09-02):渠道商是全局实体,归属由生产批次决定。
// 品牌商账号自助新增时仍默认落到自己名下,这样它自己看得见;其余情况一律 0=全局。
if m.Brandid == 0 {
m.Brandid = scopeOf(c).brandId
}
// 渠道商是全局实体:品牌商 2026-09-12 整体下线后不再有上级归属,brandid 一律 0。
m.Brandid = 0
// 渠道商账号无权新建。
if currentIdentity(c) == pb.Identity_Dealer {
writeErr(c, pb.ErrorCode_InsufficientPermissions, "渠道商账号无权新增渠道商")
return
}
if m.Brandid != 0 {
if !scopeOf(c).allowBrand(m.Brandid) {
writeErr(c, pb.ErrorCode_InsufficientPermissions, "无权在该品牌商下新增渠道商")
return
}
if _, err := dvBrand(sys, m.Brandid); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, "品牌商不存在")
return
}
}
// 同时开渠道账号:超管/管理员。用户名/密码留空由服务端生成,显式填了才提前查重。
if req.CreateAccount {
if !canCreateBoundAccount(c) {
@ -155,7 +112,7 @@ func (this *serverComp) addChannel(c *gin.Context, sys *appConn) {
m.Sharerate = 0
now := time.Now().Unix()
m.Id = genChannelId(sys)
m.Official = false // 官方渠道只能由 addOfficialChannel 随品牌商生成,前端传什么都不作数
m.Official = false // 「官方渠道」原先随品牌商自动生成,品牌商下线后不再产出新的;存量行保持原值
m.Createtime = now
m.Updatetime = now
// 审批权决定落库状态:超管/管理员直接启用,其余一律待审核。
@ -176,10 +133,10 @@ func (this *serverComp) addChannel(c *gin.Context, sys *appConn) {
}
resp := &pb.ApiAddChannelResp{Channel: m}
if req.CreateAccount {
// 渠道商账号须同时绑品牌商与渠道商(validChannelBinding 的约定),渠道刚落库,绑定必然自洽。
// 渠道商账号绑到刚落库的这个渠道商上,绑定必然自洽。
acc, err := this.openBoundAccount(pb.Identity_Dealer, "Channel-"+m.Id,
req.AccountUsername, req.AccountPassword, req.AccountRemark,
m.Brandid, m.Id, "渠道商", m.Name, m.Contactmails)
m.Id, "渠道商", m.Name, m.Contactmails)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "渠道商已创建,但渠道账号创建失败: "+err.Error())
return
@ -190,7 +147,7 @@ func (this *serverComp) addChannel(c *gin.Context, sys *appConn) {
writeOK(c, resp)
}
// updateChannel 更新渠道商资料。id/brandid 不可改(改归属等于换了一个渠道商,
// updateChannel 更新渠道商资料。id 不可改(改它等于换了一个渠道商,
// 已生产的授权码会与新归属矛盾);状态只能走审批接口,避免绕过审批自行启用。
func (this *serverComp) updateChannel(c *gin.Context, sys *appConn) {
var req pb.ApiUpdateChannelReq
@ -243,9 +200,10 @@ func (this *serverComp) delChannel(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_InsufficientPermissions, "无权删除该渠道商")
return
}
// 官方渠道是品牌商的一部分(品牌自营销售线),随品牌商生老病死,不单独删。
// 存量「官方渠道」是品牌商时代随品牌自动建的。品牌商已下线,但这些渠道上挂着历史批次与结算,
// 仍不允许删——要停用请走审批置 status=2。
if ch.Official {
writeErr(c, pb.ErrorCode_ReqParameterError, "品牌官方渠道不能删除(它随品牌商自动创建);如需停用请走审批")
writeErr(c, pb.ErrorCode_ReqParameterError, "官方渠道不能删除(品牌商时代自动创建的历史渠道);如需停用请走审批")
return
}
n, err := dvCountBatchesByChannel(sys, id)
@ -302,9 +260,7 @@ func (this *serverComp) auditChannel(c *gin.Context, sys *appConn) {
// resolveProductionChannelModel 生产 MAC/授权码时解析并校验渠道商:必选、须为启用状态。
//
// ⚠️ 不校验「渠道商归属该品牌商」:生产表单已不再选品牌商(2026-09-02),品牌归属反过来
// 由渠道商推导(见 resolveProductionBrandId)。渠道商现在是全局实体、brandid 常年为 0,
// 继续按品牌比对只会让所有渠道商都用不了。
// 渠道商是全局实体,不再有品牌归属可校验(品牌商 2026-09-12 下线)。
// 返回校验通过的渠道商;校验失败时已写好错误响应,调用方直接 return。
func resolveProductionChannelModel(c *gin.Context, sys *appConn, channelid string) (*pb.DBChannel, bool) {
id := strings.TrimSpace(channelid)
@ -324,30 +280,8 @@ func resolveProductionChannelModel(c *gin.Context, sys *appConn, channelid strin
return ch, true
}
// resolveProductionBrandId 决定这一批货记在哪个品牌商名下。
//
// 生产表单已经不选品牌商了(2026-09-02),所以品牌归属改成推导出来的:
// 1. 品牌商账号自助生产 → 强制记在自己名下。放在最前面是因为它同时是权限约束,
// 不能让前端传值或渠道商的归属把它绕开。
// 2. 选中的是某品牌的「官方渠道」→ 继承该渠道商的 brandid,月结算的品牌维度不至于丢。
// 3. 其余(全局渠道商)→ 0 = 无品牌归属,结算时那份提成全归平台。
//
// ⚠️ 请求里的 brandid 一律忽略。留着它「前端传就用」会造出第四种口径,
// 而前端已经没有地方能填这个值了。
func resolveProductionBrandId(c *gin.Context, ch *pb.DBChannel) uint32 {
if s := scopeOf(c); s.brandId != 0 {
return s.brandId
}
if ch != nil {
return ch.Brandid
}
return 0
}
// channelVisible 该渠道商是否在当前账号的可见范围内:
// 品牌商账号看本品牌商名下全部渠道商,渠道商账号只看自己那一个,超管/管理员不受限。
// 渠道商账号只看自己那一个,超管/管理员不受限。
func channelVisible(c *gin.Context, ch *pb.DBChannel) bool {
s := scopeOf(c)
// brandid==0 = 全局渠道商,对所有账号可见(渠道商账号仍只看自己那一个)。
return (ch.Brandid == 0 || s.allowBrand(ch.Brandid)) && s.allowChannel(ch.Id)
return scopeOf(c).allowChannel(ch.Id)
}

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

@ -39,17 +39,20 @@ func broadcastConfigChanged(kind, action string, region int32) {
// ============================ 服务配置域 ============================
//
// 「全局环境配置 / MCP 配置 / 会议模板」三类共享配置原存在各应用部署的 AdminDB(设备库),
// 「MCP 配置 / 会议模板」等共享配置原存在各应用部署的 AdminDB(设备库),
// 现统一集中到 console 主库(supabase, postgres.GetSys())——与设备/产品数据同库。
// 这三类数据不与具体应用挂钩(共享),其中 global_config / mcp 以 Region 分组;
// 会议模板为公共模板(source=public)。应用侧仍从同一库读取(每 10 分钟轮询同步)。
// 这些数据不与具体应用挂钩(共享);会议模板为公共模板(source=public)。
// 应用侧仍从同一库读取(每 10 分钟轮询同步)。
//
// ⚠️ 2026-09-12:**「全局环境配置」(global_config) 已下线**。它原先按区域整份复制第三方凭据
// (14 区 × 31 键 = 473 行),并**悄悄覆盖**各应用业务库 config 表里的同名键——同一个
// AZURE_SPEECH_REGION / OPENAI_API_KEY 在两张表里值不一样,运营改「应用环境配置」毫无效果且不报错。
// 凭据现已统一收进 svc_config(加密字段 + 区域覆盖 + 巡检),由字段上的 env_key 回填 env 兼容存量客户端。
// 迁移见 migrate_envtosvc.go;表不再建、不再读写,确认无误后可手工 DROP。
// ensureConfigTables 在 console 主库幂等建好三张共享配置表。启动时跑一次(SkipEnsureTables 时跳过)。
// ensureConfigTables 在 console 主库幂等建好共享配置表。启动时跑一次(SkipEnsureTables 时跳过)。
func ensureConfigTables() error {
pg := postgres.GetSys()
if err := pg.CreateTable(comm.TableGlobalConfig, &pb.DBGlobalConfigItem{}); err != nil {
return err
}
if err := pg.CreateTable(comm.TableMcp, &pb.DBMcpServer{}); err != nil {
return err
}
@ -301,85 +304,6 @@ func migrateLegacyConfigToScoped() {
}
}
// ---------------------------- 全局环境配置 (global_config) ----------------------------
// getGlobalConfigs 列出全局第三方服务配置;region>0 时按区域过滤,否则全量。
func (this *serverComp) getGlobalConfigs(c *gin.Context) {
var req struct {
Region int32 `json:"region"`
}
_ = c.ShouldBindJSON(&req)
items := make([]*pb.DBGlobalConfigItem, 0)
var err error
if req.Region > 0 {
err = postgres.Find(comm.TableGlobalConfig, &items, "region=?", req.Region)
} else {
err = postgres.Find(comm.TableGlobalConfig, &items, "")
}
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, gin.H{"items": items})
}
// addGlobalConfig 新增一条全局配置(id 由库自增回填)。
func (this *serverComp) addGlobalConfig(c *gin.Context) {
var m pb.DBGlobalConfigItem
if err := c.ShouldBindJSON(&m); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if strings.TrimSpace(m.Key) == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "配置键(key) 必填")
return
}
m.Id = 0
if err := postgres.Insert(comm.TableGlobalConfig, &m); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
broadcastConfigChanged(comm.ConfigKindGlobalConfig, "add", int32(m.Region))
writeOK(c, &m)
}
// updateGlobalConfig 更新一条全局配置(按 id)。
func (this *serverComp) updateGlobalConfig(c *gin.Context) {
var m pb.DBGlobalConfigItem
if err := c.ShouldBindJSON(&m); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if m.Id == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
return
}
if err := postgres.Save(comm.TableGlobalConfig, &m); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
broadcastConfigChanged(comm.ConfigKindGlobalConfig, "update", int32(m.Region))
writeOK(c, &m)
}
// delGlobalConfig 按 id 批量删除全局配置。
func (this *serverComp) delGlobalConfig(c *gin.Context) {
var req struct {
Ids []uint64 `json:"ids"`
}
_ = c.ShouldBindJSON(&req)
if len(req.Ids) == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "ids 必填")
return
}
if err := postgres.Delete(comm.TableGlobalConfig, "id IN ?", req.Ids); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
broadcastConfigChanged(comm.ConfigKindGlobalConfig, "delete", 0)
writeOK(c, gin.H{"deleted": len(req.Ids)})
}
// MCP 配置已并入第三方服务(svc_config)的 MCP 服务类型(svcCatMCP),统一走 svcconfig 一套增删改查 +
// 区域覆盖接口管理;独立 mcp 表由 migrateMcpToSvcConfig 一次性迁移后停止读写(仅保留兜底)。

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

@ -2,7 +2,6 @@ package console
import (
"fmt"
"strconv"
"strings"
"time"
@ -82,7 +81,7 @@ func (this *serverComp) updateProduct(c *gin.Context, sys *appConn) {
// 绑定奖励失效、客户端功能开关集体关掉(这些值仍在下发给 App,见 user_getappconfig)。
// ⚠️ Scanuuid 不在此列:它已于 2026-09-08 整体废弃,pb 里改成 gorm:"-" 的兼容字段,
// 不建列也不参与读写,Save 碰不到它。
req.Product.Brandid = old.Brandid
req.Product.Brandid = 0 // 品牌商已下线,这一列只作老客户端兼容字段的来源,恒 0
req.Product.Channel = old.Channel
req.Product.Channelextra = old.Channelextra
req.Product.Bindrewardvpitime = old.Bindrewardvpitime
@ -250,16 +249,9 @@ func (this *serverComp) addProduct(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
// 品牌商已从产品表单移除:产品是「生产阶段」的东西,卖给哪个品牌是 MAC生成 时按批次定的
// (production_batch.brandid),所以这里不再必填。传了就校验存在性,传 0 视为未绑定。
var brand *pb.DBBrand
if req.Product.Brandid != 0 {
var berr error
if brand, berr = dvBrand(sys, req.Product.Brandid); berr != nil {
writeErr(c, pb.ErrorCode_DBError, "品牌商不存在: "+berr.Error())
return
}
}
// 品牌商已于 2026-09-12 整体下线(brand 表已删):DBProduct.brandid 这一列只为老客户端
// 的兼容字段 factoryid 留着(见 modules/user/compat.go),服务端不再校验、不再写入。
req.Product.Brandid = 0
// 方案商挂在产品上(不是品牌商上):同一品牌的不同产品可能用不同芯片方案。
// 三个要烧进固件的标识口径固定为 CID=芯片公司 / BID=方案公司 / PID=产品,
// 其中 BID 就是这里的 providerid、CID 再由该方案商的 vendor 推出,所以必须绑定且真实存在。
@ -277,20 +269,6 @@ func (this *serverComp) addProduct(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
// 把新产品 id 追加进品牌商的产品列表。品牌商现在可以不填(brand 为 nil),此时整段跳过——
// 少了这个判空会直接 nil panic 把 console 打挂。
if brand != nil {
if len(brand.Productlists) == 0 {
brand.Productlists = fmt.Sprintf("%d", req.Product.Id)
} else {
brand.Productlists = fmt.Sprintf("%s,%d", brand.Productlists, req.Product.Id)
}
if err := dvSaveBrand(sys, brand); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
this.module.deviceCache.refreshBrands() // 改了品牌商 Productlists
}
this.module.deviceCache.refreshProducts()
writeOK(c, &pb.ApiAddProductResp{Product: req.Product})
}
@ -458,127 +436,6 @@ func (this *serverComp) delSolutionProvider(c *gin.Context, sys *appConn) {
writeOK(c, &pb.ApiDelSolutionProviderResp{Id: req.Id})
}
// ---- 品牌商(写,原厂家)----
// addBrand 新增品牌商。除品牌商本身外还连带两件事:
// - 官方渠道(必做):品牌自营那条销售线,随品牌商自动建、直接启用、不配账号——
// 没有它,品牌自己直销的授权码就没有渠道维度可归属,统计与结算会全落到「无渠道」那一档;
// - 品牌商账号(可选,勾选 create_account):绑定本品牌的 identity=3 账号,仅超管可建。
//
// 账号的用户名/密码先做不落库的预校验,撞名就整单不做,避免留下「品牌商建好了、账号没建成」的半成品。
func (this *serverComp) addBrand(c *gin.Context, sys *appConn) {
var req pb.ApiAddBrandReq
_ = c.ShouldBindJSON(&req)
if req.Brand == nil {
writeErr(c, pb.ErrorCode_ReqParameterError, "brand 必填")
return
}
req.Brand.Brand = strings.TrimSpace(req.Brand.Brand)
if req.Brand.Brand == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "品牌名称必填")
return
}
if req.CreateAccount {
if !canCreateBoundAccount(c) {
writeErr(c, pb.ErrorCode_InsufficientPermissions, "仅超管/管理员可在新增品牌商时同时创建品牌账号")
return
}
// 用户名/密码都可留空:留空由服务端生成(用户名要等品牌 id 落库才能推导,
// 所以这里只对「显式填了用户名」的情况提前查重)。
if u := strings.TrimSpace(req.AccountUsername); u != "" {
if msg := this.checkNewAccountName(u); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "品牌账号"+msg)
return
}
}
}
req.Brand.Providerid = 0 // 品牌商不归属方案商,忽略任何传入值(归属在 DBProduct.providerid)
req.Brand.Createtime = time.Now().Unix()
req.Brand.Contactmails = ensureDefaultContactMail(req.Brand.Contactmails)
if err := dvAddBrand(sys, req.Brand); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
this.module.deviceCache.refreshBrands() // 写后刷新品牌商缓存
resp := &pb.ApiAddBrandResp{Brand: req.Brand}
ch, err := addOfficialChannel(sys, req.Brand, req.OfficialChannelName, ctxStr(c, "username"))
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "品牌商已创建,但官方渠道创建失败: "+err.Error())
return
}
resp.Channel = ch
if req.CreateAccount {
acc, err := this.openBoundAccount(pb.Identity_Agent, defaultBrandAccountName(req.Brand.Id),
req.AccountUsername, req.AccountPassword, req.AccountRemark,
req.Brand.Id, "", "品牌商", req.Brand.Brand, req.Brand.Contactmails)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "品牌商已创建,但品牌账号创建失败: "+err.Error())
return
}
resp.Account, resp.AccountPassword = acc.Username, acc.Password
resp.MailTo, resp.MailError = acc.MailTo, acc.MailErr
}
writeOK(c, resp)
}
func (this *serverComp) updateBrand(c *gin.Context, sys *appConn) {
var req pb.ApiUpdateBrandReq
_ = c.ShouldBindJSON(&req)
if req.Brand == nil || req.Brand.Id == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "brand id is required")
return
}
old, err := dvBrand(sys, req.Brand.Id)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
// 只覆盖可编辑字段;productlists 等服务端维护字段不接受前端写入,防止表单缺字段把产品列表覆盖回旧值。
old.Brand = req.Brand.Brand
// providerid 不再由品牌商维护:方案商归属挂在产品上(DBProduct.providerid),这里刻意不覆盖旧值。
old.Description = req.Brand.Description
old.Contactmails = req.Brand.Contactmails
// 分成比例只有超管/管理员能改(品牌商自己改比例等于自定分成)。
if canAuditChannel(c) {
old.Sharerate = req.Brand.Sharerate
}
if err = dvSaveBrand(sys, old); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
this.module.deviceCache.refreshBrands() // 写后刷新品牌商缓存
writeOK(c, &pb.ApiUpdateBrandResp{Brand: old})
}
// defaultBrandAccountName 品牌商账号的默认用户名:Brand-<品牌商id 十六进制大写>,如 Brand-A001。
// 与后台列表里品牌商 id 的展示形式一致,一眼能对上是哪家;撞名时 uniqueAccountName 会加后缀。
func defaultBrandAccountName(id uint32) string {
return "Brand-" + strings.ToUpper(strconv.FormatUint(uint64(id), 16))
}
// ensureDefaultContactMail 确保品牌商联系邮箱列表里含默认邮箱(去空、去重,默认置首)。
func ensureDefaultContactMail(mails string) string {
parts := strings.Split(mails, ",")
cleaned := make([]string, 0, len(parts)+1)
has := false
for _, p := range parts {
m := strings.TrimSpace(p)
if m == "" {
continue
}
if strings.EqualFold(m, defaultBrandContactMail) {
has = true
}
cleaned = append(cleaned, m)
}
if !has {
cleaned = append([]string{defaultBrandContactMail}, cleaned...)
}
return strings.Join(cleaned, ",")
}
// ---- 授权码生产(产品级序号 + 独立批次台账)----
// genBatchNo 生成 6 位可读批次号(防碰撞重试;仅作展示标签,兜底空串)。
@ -602,24 +459,11 @@ func (this *serverComp) previewAuthCodes(c *gin.Context, sys *appConn) {
var req pb.ApiCreateAuthCodesReq
_ = c.ShouldBindJSON(&req)
// 先定渠道商,再由它推出品牌归属(生产表单已不选品牌商,见 resolveProductionBrandId)。
// 生产归属只剩渠道商一维(品牌商 2026-09-12 下线)。
channel, ok := resolveProductionChannelModel(c, sys, req.Channelid)
if !ok {
return
}
brandId := resolveProductionBrandId(c, channel)
if !this.requireBrandScope(c, brandId) { // 品牌商账号只能给自己生产
return
}
brandName := "-" // 0 = 无品牌归属(平台直营),不去查库
if brandId != 0 {
brand, berr := dvBrand(sys, brandId)
if berr != nil {
writeErr(c, pb.ErrorCode_DBError, "品牌商不存在: "+berr.Error())
return
}
brandName = brand.Brand
}
product, err := dvProduct(sys, req.Productid)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "产品不存在: "+err.Error())
@ -679,8 +523,6 @@ func (this *serverComp) previewAuthCodes(c *gin.Context, sys *appConn) {
writeOK(c, gin.H{
"batchno": genBatchNo(sys),
"brandid": brandId,
"brandname": brandName,
"channelid": channel.Id,
"channelname": channel.Name,
"productid": req.Productid,
@ -710,17 +552,6 @@ func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) {
if !ok {
return
}
// 品牌归属由渠道商/账号推出(生产表单已不选品牌商)。0 = 无品牌归属。
brandId := resolveProductionBrandId(c, channel)
if !this.requireBrandScope(c, brandId) { // 品牌商账号只能给自己生产
return
}
if brandId != 0 {
if _, err := dvBrand(sys, brandId); err != nil {
writeErr(c, pb.ErrorCode_DBError, "品牌商不存在: "+err.Error())
return
}
}
// 渠道分成比例按批次定(同一渠道商不同批次可以谈不同比例),生产时冻结进批次台账。
if req.Sharerate < 0 || req.Sharerate > channelShareRateMax {
writeErr(c, pb.ErrorCode_ReqParameterError, "分成比例需在 0~10000(万分比) 之间")
@ -796,7 +627,6 @@ func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) {
}
batch := &pb.DBProductionBatch{
Ts: time.Now().Unix(),
Brandid: brandId,
Productid: req.Productid,
Serialstart: start,
Serialend: end,
@ -835,7 +665,6 @@ func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) {
for i := int64(0); i < total; i++ {
row := &pb.DBAuthCode{
Codetype: pb.Codetype_DEVICECODE,
Brandid: brandId,
Productid: req.Productid,
Number: start + i,
Batchno: batch.Batchno,
@ -856,7 +685,6 @@ func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) {
return
}
writeOK(c, &pb.ApiCreateAuthCodesResp{
Brandid: brandId,
Channelid: channel.Id,
Productid: req.Productid,
Batchno: batch.Batchno,

324
apps/services/modules/console/api_echomeet_orch.go

@ -1,324 +0,0 @@
package console
import (
"sort"
"strconv"
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/postgres"
"yunyan/pb"
"github.com/gin-gonic/gin"
)
// ============================ 会议记录服务编排 (echomeet orch) ============================
//
// admin「会议记录服务」页的后端。编排**不存凭据**,只引用第三方服务配置(svc_config)里配好的
// 服务 id,按作用域 (app_name, region) 分层:app_name='' 且 region=0 = 全局默认兜底。
// 数据存 console 共享库(postgres),与 svc_config 同库。
//
// 前端流程与「通话翻译配置」一致:先 getsvcconfigs 拿服务池、再 getechoorch 拿编排,前端组装。
// web api(handleWeb 分发),前端统一用 webApi。
// echoOrchScope 解析请求里的作用域参数。region<0 归一为 0(全区域)。
type echoOrchScope struct {
AppName string `json:"app_name"`
Region int32 `json:"region"`
}
func normScope(s *echoOrchScope) {
s.AppName = strings.TrimSpace(s.AppName)
if s.Region < 0 {
s.Region = 0
}
}
// getEchoOrch 列出某作用域下的编排 + 作用域开关。app_name='' 且 region=0 为全局默认。
func (this *serverComp) getEchoOrch(c *gin.Context) {
var req echoOrchScope
_ = c.ShouldBindJSON(&req)
normScope(&req)
items := make([]*EchoOrchestration, 0)
if err := postgres.Find(comm.TableEchomeetOrch, &items, "app_name=? AND region=?", req.AppName, req.Region); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].Kind != items[j].Kind {
return items[i].Kind < items[j].Kind
}
return items[i].Priority < items[j].Priority
})
setting := &EchoOrchSetting{}
if err := postgres.FindOne(comm.TableEchomeetOrchSetting, setting, "app_name=? AND region=?", req.AppName, req.Region); err != nil {
setting = &EchoOrchSetting{AppName: req.AppName, Region: req.Region} // 未设置 → 零值
}
writeOK(c, gin.H{
"items": items,
"setting": setting,
"available": this.echoOrchAvailableSvcs(req.AppName),
// 各编排类别对应的服务池类别,前端据此从「可选服务」里筛出每段能选的服务。
"kind_cats": gin.H{
"1": echoKindSvcCats[echoKindASR],
"2": echoKindSvcCats[echoKindMT],
"3": echoKindSvcCats[echoKindLLM],
},
})
}
// echoOrchSvcView 供前端选服务/显示名/识别语言的精简服务视图(不含凭据)。
type echoOrchSvcView struct {
Id string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Categories string `json:"categories"` // 逗号分隔类别,前端按 kind_cats 筛
Languages string `json:"languages"` // 识别服务的支持语言(逗号分隔 BCP-47),用于选路预览
}
// echoOrchAvailableSvcs 返回该作用域可见的启用服务(本作用域 + 全局兜底,同 id 应用覆盖全局)。
func (this *serverComp) echoOrchAvailableSvcs(appName string) []echoOrchSvcView {
rows := make([]*ThirdSvcConfig, 0)
if err := postgres.Find(comm.TableSvcConfig, &rows, "(app_name=? OR app_name='') AND enable=true", appName); err != nil {
return nil
}
// 去重:同业务 id,应用作用域优先于全局兜底。
pick := make(map[string]*ThirdSvcConfig, len(rows))
for _, r := range rows {
if cur, ok := pick[r.Id]; !ok || (r.AppName != "" && cur.AppName == "") {
pick[r.Id] = r
}
}
out := make([]echoOrchSvcView, 0, len(pick))
for _, r := range pick {
langs := ""
for _, f := range r.Fields {
if f.Key == "languages" {
langs = f.DefValue
break
}
}
out = append(out, echoOrchSvcView{
Id: r.Id, Name: r.Name, Provider: r.Provider, Categories: r.Categories, Languages: langs,
})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Id < out[j].Id })
return out
}
// echoOrchSvcAllowed 校验 svc_id 在该作用域可见(本作用域或全局兜底)且类别与 kind 匹配。
// 返回 (ok, 提示)。服务池按 app_name 分作用域,应用作用域看不到别的应用、但能继承全局。
func (this *serverComp) echoOrchSvcAllowed(appName, svcId string, kind int32) (bool, string) {
wantCats := echoKindSvcCats[kind]
if len(wantCats) == 0 {
return false, "未知的编排类别"
}
// 该作用域可见的服务 = app_name 自己的 + 全局默认的。
rows := make([]*ThirdSvcConfig, 0)
if err := postgres.Find(comm.TableSvcConfig, &rows, "(app_name=? OR app_name='') AND id=?", appName, svcId); err != nil {
return false, err.Error()
}
for _, r := range rows {
if !r.Enable {
continue
}
for _, cat := range strings.Split(r.Categories, ",") {
for _, want := range wantCats {
if strings.TrimSpace(cat) == strconv.Itoa(int(want)) {
return true, ""
}
}
}
}
return false, "所选服务不存在、已停用,或类别与该段不匹配"
}
// saveEchoOrch 新增/更新一条编排(按 row_id;0 为新增)。
// 总结类默认模型同作用域唯一;非总结类强制清零 is_default/client_visible。
func (this *serverComp) saveEchoOrch(c *gin.Context) {
var req struct {
echoOrchScope
Orch EchoOrchestration `json:"orch"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
normScope(&req.echoOrchScope)
o := req.Orch
o.AppName = req.AppName
o.Region = req.Region
o.SvcId = strings.TrimSpace(o.SvcId)
if o.SvcId == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "svc_id 必填")
return
}
if _, ok := echoKindSvcCats[o.Kind]; !ok {
writeErr(c, pb.ErrorCode_ReqParameterError, "未知的编排类别 kind")
return
}
if ok, msg := this.echoOrchSvcAllowed(req.AppName, o.SvcId, o.Kind); !ok {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
// 只有总结类有默认/客户端可见;其余强制清零。停用的不能当默认。
if o.Kind != echoKindLLM {
o.IsDefault = false
o.ClientVisible = false
}
if !o.Enabled {
o.IsDefault = false
}
now := time.Now().Unix()
o.Updatetime = now
if o.RowId != 0 {
old := &EchoOrchestration{}
if err := postgres.FindOne(comm.TableEchomeetOrch, old, "row_id=?", o.RowId); err != nil {
writeErr(c, pb.ErrorCode_DBError, "编排不存在: "+err.Error())
return
}
o.AppName, o.Region, o.Createtime = old.AppName, old.Region, old.Createtime
if err := postgres.Save(comm.TableEchomeetOrch, &o); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
} else {
// 同作用域同类同服务不可重复。
dup := &EchoOrchestration{}
if postgres.FindOne(comm.TableEchomeetOrch, dup, "app_name=? AND region=? AND kind=? AND svc_id=?",
o.AppName, o.Region, o.Kind, o.SvcId) == nil && dup.RowId != 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "该服务已在此段添加")
return
}
o.Createtime = now
if err := postgres.Insert(comm.TableEchomeetOrch, &o); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
}
// 默认模型同作用域唯一:本行置默认后,抹掉同作用域同类其余行。先写后清,避免中途失败留空默认。
if o.IsDefault {
if res := postgres.Exec(
"UPDATE "+comm.TableEchomeetOrch+" SET is_default=false WHERE app_name=? AND region=? AND kind=? AND row_id<>?",
o.AppName, o.Region, o.Kind, o.RowId); res.Error != nil {
writeErr(c, pb.ErrorCode_DBError, "清理旧默认模型失败: "+res.Error.Error())
return
}
}
broadcastConfigChanged(comm.ConfigKindEchomeetOrch, "update", o.Region) // 通知 echomeet 运行时重载编排
writeOK(c, gin.H{"row_id": o.RowId})
}
// delEchoOrch 删除一条编排。删掉的若是默认模型,顺延同作用域同类一个可用的。
func (this *serverComp) delEchoOrch(c *gin.Context) {
var req struct {
RowId uint64 `json:"row_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.RowId == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "row_id 必填")
return
}
old := &EchoOrchestration{}
had := postgres.FindOne(comm.TableEchomeetOrch, old, "row_id=?", req.RowId) == nil && old.RowId != 0
if err := postgres.Delete(comm.TableEchomeetOrch, "row_id=?", req.RowId); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
if had && old.IsDefault && old.Kind == echoKindLLM {
this.promoteEchoOrchDefault(old.AppName, old.Region)
}
broadcastConfigChanged(comm.ConfigKindEchomeetOrch, "delete", old.Region) // 通知 echomeet 运行时重载编排
writeOK(c, gin.H{"deleted": req.RowId})
}
// promoteEchoOrchDefault 该作用域总结类无默认时,把优先级最高的启用条目提为默认。best-effort。
func (this *serverComp) promoteEchoOrchDefault(appName string, region int32) {
rows := make([]*EchoOrchestration, 0)
if err := postgres.Find(comm.TableEchomeetOrch, &rows, "app_name=? AND region=? AND kind=?", appName, region, echoKindLLM); err != nil {
return
}
var best *EchoOrchestration
for _, r := range rows {
if r.IsDefault {
return // 还有默认
}
if !r.Enabled {
continue
}
if best == nil || r.Priority < best.Priority {
best = r
}
}
if best == nil {
return
}
postgres.Exec("UPDATE "+comm.TableEchomeetOrch+" SET is_default=true WHERE row_id=?", best.RowId)
}
// reorderEchoOrch 批量调整同作用域内的优先级(拖拽/上下移后提交)。
func (this *serverComp) reorderEchoOrch(c *gin.Context) {
var req struct {
Items []struct {
RowId uint64 `json:"row_id"`
Priority int32 `json:"priority"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
now := time.Now().Unix()
updated := 0
for _, it := range req.Items {
if it.RowId == 0 {
continue
}
if res := postgres.Exec("UPDATE "+comm.TableEchomeetOrch+" SET priority=?, updatetime=? WHERE row_id=?",
it.Priority, now, it.RowId); res.Error != nil {
writeErr(c, pb.ErrorCode_DBError, res.Error.Error())
return
}
updated++
}
if updated > 0 {
broadcastConfigChanged(comm.ConfigKindEchomeetOrch, "update", 0) // 通知 echomeet 运行时重载编排
}
writeOK(c, gin.H{"updated": updated})
}
// saveEchoOrchSetting 保存作用域级开关(客户端可否指定总结模型),upsert 单行。
func (this *serverComp) saveEchoOrchSetting(c *gin.Context) {
var req struct {
echoOrchScope
LLMClientChoice bool `json:"llm_client_choice"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
normScope(&req.echoOrchScope)
now := time.Now().Unix()
old := &EchoOrchSetting{}
if postgres.FindOne(comm.TableEchomeetOrchSetting, old, "app_name=? AND region=?", req.AppName, req.Region) == nil && old.RowId != 0 {
old.LLMClientChoice = req.LLMClientChoice
old.Updatetime = now
if err := postgres.Save(comm.TableEchomeetOrchSetting, old); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
} else {
row := &EchoOrchSetting{AppName: req.AppName, Region: req.Region, LLMClientChoice: req.LLMClientChoice, Updatetime: now}
if err := postgres.Insert(comm.TableEchomeetOrchSetting, row); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
}
broadcastConfigChanged(comm.ConfigKindEchomeetOrch, "update", req.Region) // 通知 echomeet 运行时重载编排
writeOK(c, gin.H{"llm_client_choice": req.LLMClientChoice})
}

12
apps/services/modules/console/api_settlement.go

@ -28,12 +28,11 @@ import (
// 比例在生成那一刻快照进结算行,之后调批次比例不影响已生成的月份。
// 已确认(status>=1)的行重算时跳过,历史账不可变。
// getSettlements 结算单列表。品牌商/渠道商账号由后端按归属强制收敛。
// getSettlements 结算单列表。渠道商账号由后端按归属强制收敛。
func (this *serverComp) getSettlements(c *gin.Context) {
var req pb.ApiGetSettlementsReq
_ = c.ShouldBindJSON(&req)
s := scopeOf(c)
period := req.Period
if period == 0 {
period = latestSettlementPeriod() // 不传月份 → 最近一个有数据的月
@ -43,15 +42,6 @@ func (this *serverComp) getSettlements(c *gin.Context) {
if cid := effectiveChannelId(c, req.Channelid); cid != "" {
channelPtr = &cid
}
// ⚠️ 品牌商账号(绑了品牌商但没绑渠道商)**一律返回空**。
// 结算已不按品牌商出行,表里没有任何列能把行收敛到某个品牌商名下;
// 若照旧放行,这类外部账号会看到全平台所有渠道商的钱。
// 失败方向选「看不到」而不是「看全部」——要恢复可见性,得先定清楚按什么维度限制。
if !s.unlimited && s.brandId != 0 && s.channelId == "" {
writeOK(c, &pb.ApiGetSettlementsResp{Items: make([]*pb.DBSettlementMonth, 0)})
return
}
items, err := listSettlements(period, channelPtr, req.Batchno, req.Status)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())

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

@ -81,7 +81,7 @@ func encryptFieldsSlice(key string, fields []SvcField, oldMap map[string]string)
// ============================== 第三方服务配置 CRUD ==============================
// getSvcConfigs 列出某作用域(app_name)下全部第三方服务,加密字段值脱敏。app_name='' 为全局默认。
// getSvcConfigs 列出某作用域(app_name)下全部第三方服务,加密字段值脱敏。app_name=” 为全局默认。
// 同时返回 regions:svc_id → 已建分叉的区域枚举(升序)。后台卡片靠它一眼看出「这个服务在哪些
// 区域另配了值」——像 MCP 这种值全落在区域分叉上的服务,默认视图里否则完全看不出区域信息。
func (this *serverComp) getSvcConfigs(c *gin.Context) {
@ -217,6 +217,43 @@ func checkSvcRequiredFieldsWith(m *ThirdSvcConfig, ovrs []*SvcRegionOverride) st
return ""
}
// checkSvcEnvKeys 校验「下发键名」(env_key):同一作用域下必须唯一。
//
// env_key 是发给**已发布客户端**的 env 键名(见 comm.ResolveSvcEnvKeys / modules/user 的 appEnvForUser)。
// 两个服务抢同一个键名时,下发结果取决于服务 id 的排序——表现为「凭据时对时不对」,
// 而且改哪一边都像是没生效。所以在保存这一步就挡住。
// 返回空串 = 通过。
func checkSvcEnvKeys(m *ThirdSvcConfig) string {
seen := map[string]bool{}
for _, f := range m.Fields {
k := strings.TrimSpace(f.EnvKey)
if k == "" {
continue
}
if seen[k] {
return "下发键名重复:" + k + "(同一个服务里两个字段不能用同一个键名)"
}
seen[k] = true
}
if len(seen) == 0 {
return ""
}
others := make([]*ThirdSvcConfig, 0)
if err := postgres.Find(comm.TableSvcConfig, &others, "app_name=? AND id<>?", m.AppName, m.Id); err != nil &&
err != postgres.ErrNoDocuments {
return "" // 查重本身失败不拦保存:漏一次校验好过让运营存不下配置
}
for _, o := range others {
for _, f := range o.Fields {
if k := strings.TrimSpace(f.EnvKey); k != "" && seen[k] {
return "下发键名 " + k + " 已被服务「" + o.Name + "」(" + o.Id + ") 占用。" +
"同名会让客户端拿到哪一份取决于服务 id 排序,请改名或去掉其中一处。"
}
}
}
return ""
}
func (this *serverComp) addSvcConfig(c *gin.Context) {
var m ThirdSvcConfig
if err := c.ShouldBindJSON(&m); err != nil {
@ -242,6 +279,10 @@ func (this *serverComp) addSvcConfig(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
if msg := checkSvcEnvKeys(&m); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
m.RowId = 0 // 代理主键由库自增回填
now := time.Now().UnixMilli()
m.Createtime = now
@ -284,6 +325,10 @@ func (this *serverComp) updateSvcConfig(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
if msg := checkSvcEnvKeys(&m); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
m.RowId = old.RowId // 按代理主键更新本行
m.Createtime = old.Createtime
m.Updatetime = time.Now().UnixMilli()

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

@ -230,8 +230,13 @@ func builtinSvcTemplates() []ThirdSvcTemplate {
sf("subscription_key", "订阅密钥", true), sf("region", "区域,如 eastus", false)),
tpl("ast_volcengine", "volcengine", "火山引擎 AST", svcCatAST, 1,
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true), sf("cluster", "集群标识", false)),
// 阿里的端到端翻译走百炼 qwen3.5-livetranslate:凭据是 DashScope 的 API Key,
// **不是**云账号的 AccessKey ID/Secret。原模板照后者建,与线上实际用的那套对不上——
// 客户端读的是 ALIBABA_OPENSPEECH_APP_KEY(即这里的 app_key),AK/SK 一个都用不到。
tpl("ast_alibaba", "alibaba", "阿里云 AST", svcCatAST, 2,
sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true)),
sf("app_key", "百炼 API Key", true),
sf("app_id", "旧版模型名/AppId(仅旧档使用)", false),
sf("ws_url", "WebSocket 地址(国内站/国际站不同)", false)),
// LLM 文本大模型
tpl("llm_openai", "openai", "OpenAI", svcCatLLM, 0,
@ -373,13 +378,23 @@ func builtinSvcTemplates() []ThirdSvcTemplate {
//
// seed 平时只补不删(避免踩掉运营自己加的字段),但「字段本身作废了」是另一回事——
// 不删的话它会一直挂在新建服务的表单上,让人以为还得填。
// 只作用于 builtin 模板行,且只删这里明确列出的 key;**已创建的服务实例(svc_config)不动**,
// 那是历史数据,实例上多一个用不到的字段无害。
// 只删这里明确列出的 key。作用范围:builtin 模板行**全删**;已创建的服务实例(svc_config)
// **只删空着的那些**(见 removeDeprecatedEmptyFields)——运营往废弃字段里填过值时一律保留,
// 那是数据,删了不可逆。
//
// ⚠️ 早先这里写的是「实例一律不动,多一个用不到的字段无害」。实测不成立:空的**加密**字段
// 会被 svcCredMissing 判成缺凭据、被 checkSvcRequiredFields 判成不许保存,
// 于是那个服务在后台既显示告警又改不动。
//
// tts_azure:音色/语速/音调不该配在服务上——一个 TTS 服务要服务多个 agent、多种语言,
// 音色得按 agent 的「语言+性别」从音色表里选(见 comm/ttsvoice.go),配在服务上只会跟 agent 打架。
// ast_alibaba:AccessKey ID/Secret 是云账号主密钥,这条链路(百炼 qwen3.5-livetranslate)
// 根本不用它们,凭据是 app_key。留着的实际危害不只是多两行——它们是**加密字段**,
// 空着会让 svcCredMissing 给服务打上「⚠ 缺凭据」,还会让 checkSvcRequiredFields
// 直接拒绝保存("凭据字段不能为空…请先停用再保存"),运营根本改不动这个服务。
var deprecatedTemplateFields = map[string][]string{
"tts_azure": {"voice_name", "rate", "pitch"},
"ast_alibaba": {"access_key_id", "access_key_secret"},
}
// removeDeprecatedFields 从字段列表里剔除该模板已废弃的 key,返回新列表与被删的 key。
@ -404,6 +419,29 @@ func removeDeprecatedFields(tplId string, fields []SvcField) ([]SvcField, []stri
return out, removed
}
// removeDeprecatedEmptyFields 从**服务实例**的字段列表里剔除该模板已废弃、且当前为空的 key。
// 有值的废弃字段保留(那是运营填过的数据)。返回新列表与被删的 key。
func removeDeprecatedEmptyFields(tplId string, fields []SvcField) ([]SvcField, []string) {
drop := deprecatedTemplateFields[tplId]
if len(drop) == 0 {
return fields, nil
}
dropSet := make(map[string]bool, len(drop))
for _, k := range drop {
dropSet[k] = true
}
out := make([]SvcField, 0, len(fields))
removed := make([]string, 0, len(drop))
for _, f := range fields {
if dropSet[f.Key] && strings.TrimSpace(f.DefValue) == "" {
removed = append(removed, f.Key)
continue
}
out = append(out, f)
}
return out, removed
}
// seedSvcTemplates 幂等 seed 内置模板:同 id 不存在则整条插入;已存在则只**补齐新增字段**,
// 不覆盖运营改过的既有字段(描述/默认值/顺序一律保留)。
// 在 ensureConfigTables 之后调用(启动一次)。
@ -567,6 +605,12 @@ func patchSvcConfigFields() {
if t.Provider != svc.Provider || !categoriesHas(svc.Categories, t.Category) {
continue
}
// 先清掉该模板已废弃、且实例上还空着的字段,再补缺失字段。
var dropped []string
svc.Fields, dropped = removeDeprecatedEmptyFields(t.Id, svc.Fields)
if len(dropped) > 0 {
migrated = append(migrated, dropped...)
}
var a []string
svc.Fields, a = appendMissingFields(svc.Fields, t.Fields)
added = append(added, a...)

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

@ -195,3 +195,37 @@ func TestQwenTemplateBaseURL(t *testing.T) {
}
}
}
// 废弃字段在**实例**上只删空的、留有值的。
// 背景:ast_alibaba 模板原先照云账号 AK/SK 建,而这条链路(百炼 qwen3.5-livetranslate)
// 用的是 app_key。那两个空的加密字段会让服务被判「缺凭据」且保存被拒——运营改不动。
func TestRemoveDeprecatedEmptyFields(t *testing.T) {
t.Run("空的废弃字段删掉", func(t *testing.T) {
out, removed := removeDeprecatedEmptyFields("ast_alibaba", []SvcField{
{Key: "app_key", DefValue: "sk-x", Encrypted: true},
{Key: "access_key_id", DefValue: "", Encrypted: true},
{Key: "access_key_secret", DefValue: " ", Encrypted: true},
})
if len(out) != 1 || out[0].Key != "app_key" {
t.Fatalf("只该留下 app_key,实际 %+v", out)
}
if len(removed) != 2 {
t.Fatalf("应删两个,实际 %v", removed)
}
})
t.Run("有值的废弃字段保留", func(t *testing.T) {
out, removed := removeDeprecatedEmptyFields("ast_alibaba", []SvcField{
{Key: "access_key_id", DefValue: "LTAI-real", Encrypted: true},
})
if len(out) != 1 || len(removed) != 0 {
t.Fatal("运营填过值的字段不能删——那是不可逆的数据")
}
})
t.Run("没声明废弃的模板不受影响", func(t *testing.T) {
in := []SvcField{{Key: "whatever", DefValue: ""}}
out, removed := removeDeprecatedEmptyFields("llm_qwen", in)
if len(out) != 1 || len(removed) != 0 {
t.Fatal("未声明废弃字段的模板不该动任何字段")
}
})
}

4
apps/services/modules/console/core.go

@ -48,8 +48,8 @@ type consoleClaims struct {
Apps string `json:"aps,omitempty"` // 绑定应用名列表
Products string `json:"prd,omitempty"` // 绑定产品 id 列表
Regions string `json:"rgn,omitempty"` // 绑定区域代码列表
// 渠道分成体系的归属绑定:品牌商账号(3)只有 Brandid;渠道商账号(5)两者都有。
Brandid uint32 `json:"bid,omitempty"` // 绑定品牌商 id
// 渠道分成体系的归属绑定:渠道商账号(5) 有 Channelid。
// (品牌商账号(3) 2026-09-12 下线,原先这里还有个 Brandid。老 token 里那个字段会被忽略。)
Channelid string `json:"cid,omitempty"` // 绑定渠道商 id(短码)
jwt.RegisteredClaims
}

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

@ -0,0 +1,629 @@
package console
import (
"fmt"
"sort"
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/lego/sys/mysql"
"yunyan/lego/sys/postgres"
"yunyan/pb"
)
// ============================ 凭据归一:两张扁平 env 表 → svc_config ============================
//
// 2026-09-12 之前,第三方凭据同时躺在两张扁平 key/value 表里,客户端从 user_getappconfig 的
// env[KEY] 读:
//
// config 应用业务库,一应用一份,不分区域 ← 后台「应用环境配置」
// global_config console 主库,按区域各存一份 ← 后台「全局环境配置」,**覆盖上面那张**
//
// 这套结构有三个实打实的坑:
// 1. 同一个 AZURE_SPEECH_REGION / OPENAI_API_KEY / VOLC_OPENSPEECH_APP_ID / XUNFEI 密钥在两张表
// 里值**不一样**,全局那张赢。运营在「应用环境配置」里改半天没有任何效果,且不报错。
// 2. global_config 按区域整份复制(14 个区域 × 31 个键 = 473 行),而其中只有 4 组键真的因区域
// 而异——剩下的全是同值复制,改一次要改 14 处。
// 3. 凭据明文落库、无加密、无类别、无巡检,跟 svc_config 那套(加密字段 + 区域覆盖 + 服务巡检)
// 完全是两个世界。
//
// 本迁移把凭据收进 svc_config,一个服务一行:
// - 基础值(DefValue) 取**中国区**的现值(没有就退回 app_config 的值);
// - 与基础值不同的区域,写一条 svc_region_override;同值的区域不写,473 行因此收敛到几十行;
// - 字段带 EnvKey,下发时回填一份 env(见 modules/user 的 appEnvForUser)——**存量客户端零改动**。
//
// 幂等:只新建不存在的服务、只填空着的字段值;跑过一遍后即空转,运营后来改的值不会被刷回去。
//
// ⚠️ **不删 global_config 的行**。这是不可逆的线上凭据,留着是出问题时唯一的回退依据
// (与 migrateLicenseToDeviceMac 同一套取舍)。代码已不再读它,确认新链路跑稳后由人手工
// `DROP TABLE global_config;`。业务库 config 表里那些**已经被迁走**的键则会删除——
// 它们在迁移前就已经被 global_config 盖住、一直是死值,留着只会继续误导运营。
// envFieldSpec 一个 env 键在 svc_config 里的落点。
type envFieldSpec struct {
EnvKey string // 老的下发键名,迁移后由 SvcField.EnvKey 继续下发
FieldKey string // 在服务里的字段名
Desc string
Encrypted bool
}
// envSvcSpec 一个服务的落点定义。Id 已存在时只补字段、不改服务本身(名称/类别/启用状态都不动)。
type envSvcSpec struct {
Id string
Name string
Provider string
Categories string // 逗号分隔的 comm.SvcCatXxx
Desc string
Fields []envFieldSpec
}
// envToSvcPlan 迁移落点表。
//
// ⚠️ 改这张表前先想清楚 EnvKey:它是**已发布客户端**读的键名,改一个字母老包就拿不到凭据,
// 而且只表现为「某个能力鉴权失败」,不报配置错误。客户端读取点见
// apps/client/lib/data/services/**(搜 AppConfig.env)。
var envToSvcPlan = []envSvcSpec{
{
// 已存在(默认停用、字段名就是这几个),本迁移只给它的字段补 env_key 与值。
Id: "stt_azure", Name: "微软 Azure 语音", Provider: "azure",
Categories: "1", Desc: "语音识别/合成(客户端直连)",
Fields: []envFieldSpec{
{EnvKey: "AZURE_SPEECH_KEY", FieldKey: "subscription_key", Desc: "订阅密钥", Encrypted: true},
{EnvKey: "AZURE_SPEECH_REGION", FieldKey: "region", Desc: "服务区域,如 eastasia"},
},
},
{
Id: "mt_azure", Name: "微软 Azure 翻译", Provider: "azure",
Categories: "3", Desc: "文本翻译(客户端直连,面对面/同传的翻译段)",
Fields: []envFieldSpec{
{EnvKey: "AZURE_TRANSLATION_KEY", FieldKey: "subscription_key", Desc: "订阅密钥", Encrypted: true},
{EnvKey: "AZURE_TRANSLATION_REGION", FieldKey: "region", Desc: "服务区域"},
{EnvKey: "AZURE_TRANSLATION_ENDPOINT", FieldKey: "endpoint", Desc: "接口地址,留空用客户端默认"},
},
},
{
Id: "ast_alibaba", Name: "阿里云 通义实时翻译", Provider: "alibaba",
Categories: "4", Desc: "通话翻译端到端(qwen3.5-livetranslate),VIP 过期即停发凭据",
Fields: []envFieldSpec{
{EnvKey: "ALIBABA_OPENSPEECH_APP_KEY", FieldKey: "app_key", Desc: "百炼 API Key", Encrypted: true},
{EnvKey: "ALIBABA_OPENSPEECH_APP_ID", FieldKey: "app_id", Desc: "旧版模型名/AppId(仅第 2 档用)"},
{EnvKey: "ALIBABA_OPENSPEECH_APP_URL", FieldKey: "ws_url", Desc: "WebSocket 地址(国内/国际站不同)"},
},
},
{
Id: "sts_bailian", Name: "阿里云 百炼多模态", Provider: "alibaba",
Categories: "7", Desc: "EMAI 助手(百炼多模态应用)。API Key 复用 ast_alibaba 下发的 ALIBABA_OPENSPEECH_APP_KEY,这里只配应用与业务空间",
Fields: []envFieldSpec{
{EnvKey: "ALIBABA_BAILIAN_APP_ID", FieldKey: "app_id", Desc: "百炼应用 ID"},
{EnvKey: "ALIBABA_BAILIAN_WORKSPACE_ID", FieldKey: "workspace_id", Desc: "百炼业务空间 ID"},
},
},
{
Id: "stt_iflytek", Name: "讯飞 语音识别", Provider: "iflytek",
Categories: "1", Desc: "语音识别兜底档(客户端直连)",
Fields: []envFieldSpec{
{EnvKey: "XUNFEI_ASR_APP_ID", FieldKey: "app_id", Desc: "应用 ID"},
{EnvKey: "XUNFEI_ASR_ACCESS_KEY_ID", FieldKey: "access_key_id", Desc: "AccessKeyId", Encrypted: true},
{EnvKey: "XUNFEI_ASR_ACCESS_KEY_SECRET", FieldKey: "access_key_secret", Desc: "AccessKeySecret", Encrypted: true},
{EnvKey: "IFLYTEK_ASR_HOST", FieldKey: "host", Desc: "接口地址,留空用客户端默认"},
},
},
{
Id: "ast_bytedance", Name: "火山引擎 语音大模型", Provider: "bytedance",
Categories: "4", Desc: "字节端到端语音翻译(当前客户端已不作首选,凭据仍下发)",
Fields: []envFieldSpec{
{EnvKey: "VOLC_OPENSPEECH_APP_ID", FieldKey: "app_id", Desc: "应用 ID"},
{EnvKey: "VOLC_OPENSPEECH_ACCESS_TOKEN", FieldKey: "access_token", Desc: "访问令牌", Encrypted: true},
{EnvKey: "VOLC_OPENSPEECH_TRANSLATION_BIGMODEL", FieldKey: "translation_service_type", Desc: "翻译服务类型标识"},
{EnvKey: "VOLC_OPENSPEECH_BASE_URL_BIGMODEL", FieldKey: "base_url", Desc: "大模型接口地址"},
{EnvKey: "VOLC_OPENSPEECH_CLUSTER_BIGMODEL", FieldKey: "cluster", Desc: "集群标识"},
{EnvKey: "VOLC_OPENSPEECH_MODEL_NAME", FieldKey: "model_name", Desc: "模型名"},
{EnvKey: "VOLC_OPENSPEECH_MODEL_VERSION", FieldKey: "model_version", Desc: "模型版本"},
},
},
{
Id: "llm_doubao", Name: "火山方舟 豆包", Provider: "volcano",
Categories: "5", Desc: "客户端直连的文本/视觉大模型(键名沿用 OPENAI_*,是 OpenAI 兼容接口不是 OpenAI)",
Fields: []envFieldSpec{
{EnvKey: "OPENAI_API_KEY", FieldKey: "api_key", Desc: "API Key", Encrypted: true},
{EnvKey: "OPENAI_BASE_URL", FieldKey: "base_url", Desc: "接口地址"},
{EnvKey: "OPENAI_MODEL", FieldKey: "model", Desc: "文本模型"},
{EnvKey: "OPENAI_VISION_MODEL", FieldKey: "vision_model", Desc: "视觉模型"},
{EnvKey: "OPENAI_AGENT_ID", FieldKey: "agent_id", Desc: "默认智能体标识"},
},
},
{
Id: "mt_volcano", Name: "火山引擎 翻译", Provider: "volcano",
Categories: "3", Desc: "火山机器翻译(当前客户端已不作首选,凭据仍下发)",
Fields: []envFieldSpec{
{EnvKey: "VOLCANO_TRANSLATION_ACCESS_KEY", FieldKey: "access_key", Desc: "AccessKey", Encrypted: true},
{EnvKey: "VOLCANO_TRANSLATION_SECRET_KEY", FieldKey: "secret_key", Desc: "SecretKey", Encrypted: true},
{EnvKey: "VOLCANO_AI_API_KEY", FieldKey: "ark_api_key", Desc: "方舟 API Key", Encrypted: true},
},
},
}
// envKeysDropped 确认没有任何代码读、迁移时直接丢弃的键。
//
// ALIBABA_OSS_* 对象存储早已改由 svc_config 的 storage_aliyun_oss / sys_service_config 提供,
// 客户端与服务端都不读这几个 env 键(服务端唯一读取点是 comm.LoadAliyunOSSConf)。
// COMMENT_* comment.voitrans.net 随 voitrans.net 一起下线,客户端无任何引用。
var envKeysDropped = map[string]string{
"ALIBABA_OSS_ACCESS_KEY_ID": "对象存储改由 storage_aliyun_oss 提供",
"ALIBABA_OSS_ACCESS_KEY_SECRET": "对象存储改由 storage_aliyun_oss 提供",
"ALIBABA_OSS_BUCKET_NAME": "对象存储改由 storage_aliyun_oss 提供",
"ALIBABA_OSS_ENDPOINT": "对象存储改由 storage_aliyun_oss 提供",
"COMMENT_SERVER_URL": "comment.voitrans.net 已下线",
"COMMENT_SIGN_SECRET": "comment.voitrans.net 已下线",
}
// globalCovered 该键在 global_config 里有没有任何区域配了非空值。
// 这是「业务库 config 里那一行是不是死值」的判据:老的合并口径是 global 覆盖 app,
// 有全局值时应用自己那份从来就没生效过,删掉不改变任何行为。
func globalCovered(byKey map[string]map[int32]string, key string) bool {
for _, v := range byKey[key] {
if strings.TrimSpace(v) != "" {
return true
}
}
return false
}
// planEnvKeys 落点表里出现过的全部 env 键名,用于识别「填错列」的历史行(见 normalizeAppCfgRow)。
func planEnvKeys() map[string]bool {
m := make(map[string]bool, 32)
for _, spec := range envToSvcPlan {
for _, f := range spec.Fields {
m[f.EnvKey] = true
}
}
return m
}
// normalizeAppCfgRow 读一行业务库 config,返回它**真正表达**的 (键, 值)。
//
// 绝大多数行直接就是 (key, value)。但线上存在「填错列」的历史行:运营把键名填进了 group、
// 把值填进了 key,value 留空——
//
// group=ALIBABA_BAILIAN_APP_ID key=mm_982f2157… value=''
//
// 后果是 AppConfig.env('ALIBABA_BAILIAN_APP_ID') 恒为 null,客户端退回代码里写死的兜底值,
// 后台改这两行毫无效果且不报任何错。识别条件卡得很死——**group 必须正好是落点表里的一个
// env 键名**、且 value 为空、key 非空——不满足就原样返回,不做任何猜测。
//
// 保持纯函数(不打日志):日志由调用方按 key != it.Key 判断后补,便于单测直接调
// (lego 的 log 子系统在单测里没初始化,函数内部打日志会直接 panic)。
func normalizeAppCfgRow(it *pb.DBAppConfigItem) (key, value string) {
if strings.TrimSpace(it.Value) == "" && strings.TrimSpace(it.Key) != "" && planEnvKeys()[it.Group] {
return it.Group, it.Key
}
return it.Key, it.Value
}
// migrateEnvToSvcConfig 主入口。encKey = options.EncryptKey。
// 任何一步失败都只记日志不中断启动:迁移是幂等的,下次启动会重来;
// 而 console 起不来的后果(整个后台不可用)比晚一轮迁移严重得多。
func migrateEnvToSvcConfig(encKey string, apps []*AppRegistry) {
// ① 把 global_config 读成 key → region → value。
type gcRow struct {
Region int32 `gorm:"column:region"`
Key string `gorm:"column:key"`
Value string `gorm:"column:value"`
}
rows := make([]*gcRow, 0)
if err := postgres.GetSys().Raw(
`SELECT region, "key", value FROM ` + comm.TableGlobalConfig).Scan(&rows).Error; err != nil {
// 表不存在(全新部署)是正常情况,不是错误。
log.Infof("console.migrate: global_config 读取跳过(%v),仅按应用业务库迁移", err)
rows = nil
}
byKey := map[string]map[int32]string{}
for _, r := range rows {
k := strings.TrimSpace(r.Key)
if k == "" {
continue
}
if byKey[k] == nil {
byKey[k] = map[int32]string{}
}
// 同 key 同 region 出现多行(历史重复录入)时后写的赢;只有空值不覆盖非空值。
if v, ok := byKey[k][r.Region]; ok && strings.TrimSpace(r.Value) == "" && v != "" {
continue
}
byKey[k][r.Region] = r.Value
}
// ② app_config 的值作为「global_config 没有该 key 时」的兜底(与迁移前的合并口径一致)。
appFallback := map[string]string{}
fallbackOwner := map[string]string{} // env 键 → 提供兜底值的那个应用(多应用时用于判定能不能删)
appConns := map[string]mysql.ISys{} // appName → 业务库连接,第 ③ 步删键时复用
for _, app := range apps {
if !app.Enabled || app.ServiceDsn == "" {
continue
}
appName := app.Name
db, err := openDriver(app.ServiceDsn)
if err != nil {
log.Warnf("console.migrate: 应用 %s 业务库连接失败,跳过: %v", app.Name, err)
continue
}
items := make([]*pb.DBAppConfigItem, 0)
if err := db.Find(comm.TableAppConfig, &items, ""); err != nil {
log.Warnf("console.migrate: 应用 %s 读 config 表失败,跳过: %v", app.Name, err)
continue
}
appConns[app.Name] = db
for _, it := range items {
k, v := normalizeAppCfgRow(it)
if k == "" {
continue
}
if k != it.Key {
log.Infof("console.migrate: 应用 %s 的 config 行填错了列(group=%s 才是键名,key 里装的是值),迁移时已按正确含义读取",
appName, it.Group)
}
if _, ok := appFallback[k]; !ok && strings.TrimSpace(v) != "" {
appFallback[k] = v
fallbackOwner[k] = appName
} else if ok && appFallback[k] != v && strings.TrimSpace(v) != "" && !globalCovered(byKey, k) {
// 两个应用对同一个「全局表里没有的」键给了不同的值:归一成一条全局服务行会让
// 其中一个应用改用别人的凭据。这里只告警不擅自选边,下面 cleanAppConfig 会保留它的行。
log.Warnf("console.migrate: 键 %s 在应用 %s 与 %s 里值不同,且 global_config 没有它——"+
"已按 %s 的值建全局服务行,%s 的配置原样保留,请到「第三方服务配置」为它建应用级覆盖",
k, fallbackOwner[k], appName, fallbackOwner[k], appName)
}
}
}
// ③ 按落点表建/补服务。
migrated := map[string]bool{} // 已经真正落到 svc_config 里的 env 键
for _, spec := range envToSvcPlan {
if err := upsertEnvSvc(spec, byKey, appFallback, encKey, migrated); err != nil {
log.Warnf("console.migrate: 服务 %s 迁移失败: %v", spec.Id, err)
}
}
// ④ 业务库 config 表清理:只删**确认已迁走**的键。没迁成功的一个都不碰。
for name, db := range appConns {
cleanAppConfig(name, db, migrated, byKey, fallbackOwner)
}
if len(migrated) > 0 {
keys := make([]string, 0, len(migrated))
for k := range migrated {
keys = append(keys, k)
}
sort.Strings(keys)
log.Infof("console.migrate: 凭据已归一到 svc_config(%d 个键):%s", len(keys), strings.Join(keys, ", "))
log.Infof("console.migrate: global_config 已无代码读取,确认新链路无误后可手工 DROP TABLE %s", comm.TableGlobalConfig)
}
}
// planEnvSvcValues 纯函数:给定一个服务的落点定义与两张老表的内容,算出
//
// base 该服务每个字段的基础值(fieldKey → 值)
// regions 需要写成区域分叉的值(region → fieldKey → 值)
//
// 口径(与迁移前的下发结果对齐,这是本函数存在的全部意义):
// - 基础值取**中国区**的现值 → 区域 0 → 业务库 config 表兜底。选中国区是因为当前只有一个
// 应用且 app_registry.region=cn;其余区域一律用分叉表达差异。
// - 只有**与基础值不同**的区域才写分叉。global_config 里 31 个键里有 27 个是 14 个区域同值复制
// (实测),不做这一步就会把 473 行的重复原样搬进新表,等于换个地方继续乱。
// - 空值不写:区域那行是空串时按「该区域没配」处理,退回基础值,与老的
// 「env[k] 只在 global_config 有该区域行时才覆盖」一致。
func planEnvSvcValues(spec envSvcSpec, byKey map[string]map[int32]string, appFallback map[string]string,
) (base map[string]string, regions map[int32]map[string]string) {
cn := int32(pb.Region_RegionChina)
base = make(map[string]string, len(spec.Fields))
regions = map[int32]map[string]string{}
for _, f := range spec.Fields {
m := byKey[f.EnvKey]
b := ""
if m != nil {
if v, ok := m[cn]; ok && strings.TrimSpace(v) != "" {
b = v
} else if v, ok := m[0]; ok && strings.TrimSpace(v) != "" {
b = v
}
}
if b == "" {
b = appFallback[f.EnvKey]
}
base[f.FieldKey] = b
for region, v := range m {
if region == 0 || region == cn || strings.TrimSpace(v) == "" || v == b {
continue
}
if regions[region] == nil {
regions[region] = map[string]string{}
}
regions[region][f.FieldKey] = v
}
}
return
}
// hasAnyValue 整份落点一个值都取不到时不建服务——免得后台平白多出一堆空壳卡片。
func hasAnyValue(base map[string]string) bool {
for _, v := range base {
if strings.TrimSpace(v) != "" {
return true
}
}
return false
}
// upsertEnvSvc 建或补一个服务。幂等:
// - 服务已存在 → 保留它的名称/类别/启用状态,只补「缺失的字段」和「字段上缺失的 env_key」;
// - 字段已有非空 DefValue → 不覆盖(运营后来改过的值优先于历史 env 值)。
//
// ⚠️ migrated 只在**落库成功之后**才写。调用方拿它去删业务库 config 里的对应行——
// 提前标记会让「保存失败」变成「两张表都没有这个凭据」,那是不可逆的丢数据。
func upsertEnvSvc(spec envSvcSpec, byKey map[string]map[int32]string, appFallback map[string]string,
encKey string, migrated map[string]bool) error {
base, regionVals := planEnvSvcValues(spec, byKey, appFallback)
if !hasAnyValue(base) {
return nil
}
now := time.Now().Unix()
svc := &comm.ThirdSvcConfig{}
exists := true
if err := postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", "", spec.Id); err != nil {
if err != postgres.ErrNoDocuments {
return err
}
exists = false
svc = &comm.ThirdSvcConfig{
AppName: "", Id: spec.Id, Name: spec.Name, Provider: spec.Provider,
Categories: spec.Categories, Description: spec.Desc, Enable: true,
Fields: []comm.SvcField{}, Createtime: now,
}
}
have := map[string]int{}
for i, f := range svc.Fields {
have[f.Key] = i
}
done := map[string]bool{} // 本次确实带上了值的 env 键,落库成功后才并进 migrated
changed := !exists
for i, spf := range spec.Fields {
val := base[spf.FieldKey]
idx, ok := have[spf.FieldKey]
if !ok {
// 新字段:带上值(加密字段先加密)。
stored, err := storeVal(val, spf.Encrypted, encKey)
if err != nil {
return err
}
svc.Fields = append(svc.Fields, comm.SvcField{
Key: spf.FieldKey, Description: spf.Desc, Encrypted: spf.Encrypted,
DefValue: stored, Sort: int32(i), EnvKey: spf.EnvKey,
})
have[spf.FieldKey] = len(svc.Fields) - 1
changed = true
if strings.TrimSpace(val) != "" {
done[spf.EnvKey] = true
}
continue
}
// 已有字段:只补 env_key 与空值,不动运营填过的内容。
if svc.Fields[idx].EnvKey == "" {
svc.Fields[idx].EnvKey = spf.EnvKey
changed = true
}
if strings.TrimSpace(svc.Fields[idx].DefValue) == "" && strings.TrimSpace(val) != "" {
stored, err := storeVal(val, svc.Fields[idx].Encrypted, encKey)
if err != nil {
return err
}
svc.Fields[idx].DefValue = stored
changed = true
}
if strings.TrimSpace(svc.Fields[idx].DefValue) != "" && svc.Fields[idx].EnvKey == spf.EnvKey {
done[spf.EnvKey] = true
}
}
// ⚠️ 带着迁移来的凭据就必须是启用状态。老的 global_config 是**无条件**下发这些键的,
// 而 svc_config 的 enable=false 会让 resolveThirdSvcs 整条跳过——真机上 stt_azure
// 恰好是停用的,照搬这个状态就等于把 AZURE_SPEECH_KEY/REGION 从 env 里抹掉,
// 表现为 Azure 语音识别/合成突然鉴权失败,而后台看上去一切正常。
// 运营要停用某个服务,迁移之后再停——那时停用的语义才是真的「不下发」。
if len(done) > 0 && !svc.Enable {
svc.Enable = true
changed = true
log.Infof("console.migrate: 服务 %s 原为停用,因承接了老 env 键(%d 个)已置为启用——"+
"老链路是无条件下发这些凭据的,保持停用会让客户端拿不到", spec.Id, len(done))
}
if changed {
svc.Updatetime = now
if exists {
if err := postgres.Save(comm.TableSvcConfig, svc); err != nil {
return err
}
} else if err := postgres.Insert(comm.TableSvcConfig, svc); err != nil {
return err
}
log.Infof("console.migrate: 服务 %s 已%s(%d 字段)", spec.Id, map[bool]string{true: "补齐", false: "建立"}[exists], len(svc.Fields))
}
// 走到这里说明基础值已经在库里了(changed=false 时是上一轮就写好的),可以放心标记。
for k := range done {
migrated[k] = true
}
// 区域分叉:只为「与基础值不同」的区域写覆盖,同值区域一行都不写。
return upsertEnvSvcRegions(spec, svc, regionVals, encKey)
}
// upsertEnvSvcRegions 把 planEnvSvcValues 算出的区域差异落成 svc_region_override,
// 并**修掉会把基础值抹成空的历史空覆盖**。
//
// ⚠️ 后面这半件事是真机踩出来的:svc_config 里早就存在的 stt_azure 带着 14 行区域覆盖,
// 内容是 {"region":"", "subscription_key":""}——当初建服务时留下的空壳。而
// ResolveSvcPlainFields 判的是「这个 key 在 Overrides 里**存不存在**」,存在就用,
// 哪怕值是空串。于是迁移把凭据填进基础值之后,任何一个区域的用户拿到的仍然是空字符串,
// 表现为 Azure 语音识别/合成鉴权失败,而后台看上去字段明明填好了。
//
// 所以对**本迁移拥有的字段**(落点表里带 env_key 的那些)额外做一次清理:
// - 该区域有真值 → 写进去(空覆盖视同没填,可以覆盖);
// - 该区域没真值 → 把空覆盖**删掉**,让基础值生效。
//
// 运营真正填过的非空覆盖一律不动。
func upsertEnvSvcRegions(spec envSvcSpec, svc *comm.ThirdSvcConfig, regionVals map[int32]map[string]string, encKey string) error {
owned := make(map[string]bool, len(spec.Fields))
for _, f := range spec.Fields {
owned[f.FieldKey] = true
}
encOf := map[string]bool{}
for _, f := range svc.Fields {
encOf[f.Key] = f.Encrypted
}
// 已存在的覆盖行也要过一遍(它们可能带着空壳覆盖),所以取「已有区域 ∪ 有差异的区域」。
existing := make([]*comm.SvcRegionOverride, 0)
if err := postgres.Find(comm.TableSvcRegionOverride, &existing,
"app_name=? AND svc_id=?", "", spec.Id); err != nil && err != postgres.ErrNoDocuments {
return err
}
byRegion := make(map[int32]*comm.SvcRegionOverride, len(existing))
regionSet := map[int32]bool{}
for _, o := range existing {
byRegion[o.Region] = o
regionSet[o.Region] = true
}
for r := range regionVals {
regionSet[r] = true
}
if len(regionSet) == 0 {
return nil
}
regions := make([]int, 0, len(regionSet))
for r := range regionSet {
regions = append(regions, int(r))
}
sort.Ints(regions) // map 迭代无序,排一下让写入顺序与日志可复现
written, cleaned := 0, 0
for _, ri := range regions {
region := int32(ri)
ovr, exists := byRegion[region]
if !exists {
ovr = &comm.SvcRegionOverride{AppName: "", SvcId: spec.Id, Region: region,
Overrides: map[string]string{}, ExtraFields: []comm.SvcField{}, DisabledKeys: []string{}}
}
if ovr.Overrides == nil {
ovr.Overrides = map[string]string{}
}
changed := !exists
for fk := range owned {
cur, has := ovr.Overrides[fk]
blank := has && strings.TrimSpace(cur) == ""
want := regionVals[region][fk]
if strings.TrimSpace(want) != "" {
if has && !blank {
continue // 运营填过真值,不动
}
stored, err := storeVal(want, encOf[fk], encKey)
if err != nil {
return err
}
ovr.Overrides[fk] = stored
changed = true
written++
continue
}
if blank {
delete(ovr.Overrides, fk) // 空壳覆盖会把基础值抹成空,删掉让基础值生效
changed = true
cleaned++
}
}
if !changed {
continue
}
ovr.Updatetime = time.Now().Unix()
if exists {
if err := postgres.Save(comm.TableSvcRegionOverride, ovr); err != nil {
return err
}
} else if err := postgres.Insert(comm.TableSvcRegionOverride, ovr); err != nil {
return err
}
}
if written > 0 || cleaned > 0 {
log.Infof("console.migrate: 服务 %s 区域覆盖:写入 %d 项差异,清掉 %d 项会抹空基础值的空覆盖",
spec.Id, written, cleaned)
}
return nil
}
// storeVal 按字段是否加密决定入库形态。空值不加密(加密空串会得到一段无意义密文,
// 而 ResolveSvcPlainFields 对空值是直接放行的,两边口径要一致)。
func storeVal(val string, encrypted bool, encKey string) (string, error) {
if strings.TrimSpace(val) == "" {
return "", nil
}
if !encrypted {
return val, nil
}
return comm.Encrypt(encKey, val)
}
// cleanAppConfig 删掉业务库 config 表里那些已经迁进 svc_config 的凭据键,以及确认无人读取的死键。
//
// ⚠️ 只删「本次确认迁走了」的键。迁移没成功的键留在原地——宁可后台多显示几行,
// 也不能出现「两张表都没有值」的窗口。
// ⚠️ 非凭据的应用参数(AGENT_TYPE / MOBILE_ELF_* / iapCustomerServiceQQ / COS_* / 算力与运营参数…)
// 一律不碰:它们本来就该住在「应用环境配置」里。
func cleanAppConfig(appName string, db mysql.ISys, migrated map[string]bool,
byKey map[string]map[int32]string, fallbackOwner map[string]string) {
items := make([]*pb.DBAppConfigItem, 0)
if err := db.Find(comm.TableAppConfig, &items, ""); err != nil {
return
}
dropIds := make([]uint64, 0)
dropKeys := make([]string, 0)
for _, it := range items {
// 填错列的历史行按它真正表达的键名判定,否则永远清不掉(key 里装的是值)。
k, _ := normalizeAppCfgRow(it)
reason := ""
switch {
case envKeysDropped[k] != "":
reason = envKeysDropped[k]
case migrated[k] && globalCovered(byKey, k):
// 全局表里有这个键 → 应用自己这行在迁移前就被盖住、从来没生效过,删掉不改变行为。
reason = "已迁入 svc_config(原本就被 global_config 盖住)"
case migrated[k] && fallbackOwner[k] == appName:
// 全局表没有,值就是从这个应用取的,已原样搬进全局服务行。
reason = "已迁入 svc_config"
case migrated[k]:
// 全局表没有、值也不是从本应用取的:删了就等于让本应用改用别的应用的凭据。保留。
log.Warnf("console.migrate: 应用 %s 的 %s 未清理——全局服务行里的值来自应用 %s,"+
"两者可能不同;如需按应用区分,请在「第三方服务配置」里为本应用建覆盖行",
appName, k, fallbackOwner[k])
continue
}
if reason == "" {
continue
}
dropIds = append(dropIds, it.Id)
dropKeys = append(dropKeys, fmt.Sprintf("%s(%s)", k, reason))
}
if len(dropIds) == 0 {
return
}
if err := db.Delete(comm.TableAppConfig, "id IN ?", dropIds); err != nil {
log.Warnf("console.migrate: 应用 %s 清理 config 表失败: %v", appName, err)
return
}
log.Infof("console.migrate: 应用 %s 的 config 表清掉 %d 个已归一的键:%s",
appName, len(dropKeys), strings.Join(dropKeys, ", "))
}

191
apps/services/modules/console/migrate_envtosvc_test.go

@ -0,0 +1,191 @@
package console
import (
"testing"
"yunyan/pb"
)
// 迁移口径必须与迁移**前**的下发结果逐键一致——错一个键,已发布的客户端就拿不到凭据,
// 而且只表现为「某个能力鉴权失败」,不报配置错误。下面的数据取自 2026-09-12 线上真实布局:
// global_config 里 31 个键中只有 4 组因区域而异,其余 27 个是 14 个区域同值复制。
func TestPlanEnvSvcValues(t *testing.T) {
const (
cn = int32(pb.Region_RegionChina)
us = int32(pb.Region_RegionUSA)
ea = int32(pb.Region_RegionEastAsia)
)
spec := envSvcSpec{
Id: "stt_azure",
Fields: []envFieldSpec{
{EnvKey: "AZURE_SPEECH_KEY", FieldKey: "subscription_key", Encrypted: true},
{EnvKey: "AZURE_SPEECH_REGION", FieldKey: "region"},
{EnvKey: "AZURE_SPEECH_ENDPOINT", FieldKey: "endpoint"},
},
}
t.Run("基础值取中国区,同值区域不写分叉", func(t *testing.T) {
byKey := map[string]map[int32]string{
// 真实布局:key 每区不同,region 每区不同,但很多区其实同值
"AZURE_SPEECH_KEY": {cn: "k-cn", us: "k-us", ea: "k-cn"},
"AZURE_SPEECH_REGION": {cn: "southeastasia", us: "southeastasia", ea: "eastasia"},
}
base, regions := planEnvSvcValues(spec, byKey, nil)
if base["subscription_key"] != "k-cn" {
t.Fatalf("基础值应取中国区,实际 %q", base["subscription_key"])
}
if base["region"] != "southeastasia" {
t.Fatalf("基础值应取中国区,实际 %q", base["region"])
}
// 美区 key 不同 → 要分叉;美区 region 与中国区同值 → 不该出现在分叉里
if regions[us]["subscription_key"] != "k-us" {
t.Fatalf("美区 key 不同,应写分叉,实际 %+v", regions[us])
}
if _, ok := regions[us]["region"]; ok {
t.Fatal("与基础值同值的区域字段不能写分叉——那正是老表 473 行重复的来源")
}
// 东亚 key 与中国区同值、region 不同 → 只分叉 region
if _, ok := regions[ea]["subscription_key"]; ok {
t.Fatal("东亚 key 与中国区同值,不该写分叉")
}
if regions[ea]["region"] != "eastasia" {
t.Fatalf("东亚 region 不同,应写分叉,实际 %+v", regions[ea])
}
})
t.Run("global_config 没有该键时回落业务库 config", func(t *testing.T) {
byKey := map[string]map[int32]string{}
base, regions := planEnvSvcValues(spec, byKey, map[string]string{
"AZURE_SPEECH_KEY": "from-app-config",
})
if base["subscription_key"] != "from-app-config" {
t.Fatalf("应回落业务库 config 的值,实际 %q", base["subscription_key"])
}
if len(regions) != 0 {
t.Fatalf("没有区域数据就不该有分叉,实际 %+v", regions)
}
})
t.Run("中国区缺行时退回区域0,再退回业务库", func(t *testing.T) {
byKey := map[string]map[int32]string{
"AZURE_SPEECH_KEY": {0: "k-default", us: "k-us"},
}
base, regions := planEnvSvcValues(spec, byKey, map[string]string{"AZURE_SPEECH_KEY": "k-app"})
if base["subscription_key"] != "k-default" {
t.Fatalf("中国区缺行应退回区域0,实际 %q", base["subscription_key"])
}
if regions[us]["subscription_key"] != "k-us" {
t.Fatal("与基础值不同的区域仍要分叉")
}
})
t.Run("区域行是空串按没配处理", func(t *testing.T) {
byKey := map[string]map[int32]string{
"AZURE_SPEECH_KEY": {cn: "k-cn", us: " "},
}
_, regions := planEnvSvcValues(spec, byKey, nil)
if _, ok := regions[us]; ok {
t.Fatal("空值区域不该写分叉——老逻辑下它会下发空串覆盖,那本身就是配置事故")
}
})
t.Run("整份取不到值的服务不建", func(t *testing.T) {
base, _ := planEnvSvcValues(spec, nil, nil)
if hasAnyValue(base) {
t.Fatalf("一个值都没有时不该判定为可建,实际 %+v", base)
}
})
}
// 落点表本身的自检:EnvKey 是发给已发布客户端的键名,全局不能重名——重名时
// 下发结果取决于服务 id 排序,表现为「凭据时对时不对」。FieldKey 在同一个服务内也不能重名。
func TestEnvToSvcPlanNoDuplicateKeys(t *testing.T) {
seenEnv := map[string]string{}
seenSvc := map[string]bool{}
for _, spec := range envToSvcPlan {
if seenSvc[spec.Id] {
t.Fatalf("服务 id 重复: %s", spec.Id)
}
seenSvc[spec.Id] = true
fields := map[string]bool{}
for _, f := range spec.Fields {
if f.EnvKey == "" {
t.Fatalf("服务 %s 的字段 %s 没有 env_key——那它就不会下发给老客户端", spec.Id, f.FieldKey)
}
if owner, ok := seenEnv[f.EnvKey]; ok {
t.Fatalf("下发键名 %s 被 %s 和 %s 同时占用", f.EnvKey, owner, spec.Id)
}
seenEnv[f.EnvKey] = spec.Id
if fields[f.FieldKey] {
t.Fatalf("服务 %s 的字段名 %s 重复", spec.Id, f.FieldKey)
}
fields[f.FieldKey] = true
}
if spec.Categories == "" {
t.Fatalf("服务 %s 没有类别——第三方服务配置页按类别分组,没类别就只会落进「未分类」", spec.Id)
}
}
// 被明确丢弃的键不能同时又出现在落点表里
for k := range envKeysDropped {
if owner, ok := seenEnv[k]; ok {
t.Fatalf("键 %s 既在丢弃清单里、又被服务 %s 迁移,口径矛盾", k, owner)
}
}
}
// 线上存在「填错列」的 config 行(group 里是键名、key 里是值、value 空),
// 后果是客户端读不到这个键、后台怎么改都没效果。迁移必须按它真正表达的含义读,
// 否则 ALIBABA_BAILIAN_* 会在归一之后继续丢失。
func TestNormalizeAppCfgRow(t *testing.T) {
t.Run("填错列的行按 group 当键名读", func(t *testing.T) {
k, v := normalizeAppCfgRow(&pb.DBAppConfigItem{
Group: "ALIBABA_BAILIAN_APP_ID", Key: "mm_982f215733384f79aa5072c3c2bf", Value: "",
})
if k != "ALIBABA_BAILIAN_APP_ID" || v != "mm_982f215733384f79aa5072c3c2bf" {
t.Fatalf("应识别为 (键=group, 值=key),实际 (%q, %q)", k, v)
}
})
t.Run("正常行原样返回", func(t *testing.T) {
k, v := normalizeAppCfgRow(&pb.DBAppConfigItem{Group: "AI", Key: "AGENT_TYPE", Value: "default"})
if k != "AGENT_TYPE" || v != "default" {
t.Fatalf("正常行不该被改写,实际 (%q, %q)", k, v)
}
})
t.Run("group 不是落点表里的键名就不猜", func(t *testing.T) {
// 值确实为空的普通配置行很常见,绝不能把它的 group 当成键名
k, v := normalizeAppCfgRow(&pb.DBAppConfigItem{Group: "APP", Key: "APP_AUTHCODE_OPEN", Value: ""})
if k != "APP_AUTHCODE_OPEN" || v != "" {
t.Fatalf("不满足识别条件的行必须原样返回,实际 (%q, %q)", k, v)
}
})
t.Run("value 非空时一律原样", func(t *testing.T) {
k, v := normalizeAppCfgRow(&pb.DBAppConfigItem{
Group: "ALIBABA_BAILIAN_APP_ID", Key: "x", Value: "real",
})
if k != "x" || v != "real" {
t.Fatalf("value 有值说明这行是正常的,实际 (%q, %q)", k, v)
}
})
}
// 多应用部署下的清理判定:这是个 SaaS 平台,一台 console 管多个应用的业务库。
// 删错一行就等于让某个应用改用别人的凭据,且不报任何错。
func TestGlobalCovered(t *testing.T) {
byKey := map[string]map[int32]string{
"OPENAI_API_KEY": {1: "k-cn", 2: "k-us"},
"ALIBABA_BAILIAN_APP_ID": {}, // 全局表里没有
"EMPTY_EVERYWHERE": {1: "", 2: " "}, // 有行但全是空
}
if !globalCovered(byKey, "OPENAI_API_KEY") {
t.Fatal("全局表有非空值就算覆盖——应用自己那行在迁移前就没生效过")
}
if globalCovered(byKey, "ALIBABA_BAILIAN_APP_ID") {
t.Fatal("全局表没有该键,应用自己那行才是生效值,不能当死值删")
}
if globalCovered(byKey, "EMPTY_EVERYWHERE") {
t.Fatal("全是空值不算覆盖")
}
if globalCovered(byKey, "NOT_PRESENT") {
t.Fatal("键根本不存在时不算覆盖")
}
}

40
apps/services/modules/console/model_account.go

@ -1,6 +1,8 @@
package console
import (
"time"
"yunyan/comm"
"yunyan/lego/sys/postgres"
"yunyan/pb"
@ -9,7 +11,8 @@ import (
)
// Account 后台账号,存 console 主库(postgres,console_account 表)。
// 角色 Identity:1 超管 / 2 管理员 / 3 代理商(品牌商) / 4 运营 / 5 渠道商(语义见 pb.Identity)。
// 角色 Identity:1 超管 / 2 管理员 / 4 运营 / 5 渠道商(语义见 pb.Identity)。
// 3 代理商(品牌商) 已于 2026-09-12 下线,存量行由 migrateBrandAccountsToOperator 处置。
// 超管引导账号(yaml AdminAccount/AdminPassword)不入表,account_id=0;表里存的是后台新建的账号。
type Account struct {
Id uint32 `gorm:"primaryKey;autoIncrement;column:id" json:"id"`
@ -23,14 +26,15 @@ type Account struct {
Apps string `gorm:"column:apps;type:text" json:"apps"` // 绑定应用名称列表(与 stats_global_day.app_id 一致),如 "Voitrans,Echomeet"
Regions string `gorm:"column:regions;type:text" json:"regions"` // 绑定区域代码列表,如 "cn,us"
Products string `gorm:"column:products;type:text" json:"products"` // 绑定产品 id 列表,如 "45058,45059"
// 渠道分成体系的归属绑定:
// 代理(3) = 品牌商账号:只填 Brandid,可见该品牌商及其名下全部渠道商的数据;
// 渠道商(5) :Brandid + Channelid 都填,只可见自己这一个渠道商的数据。
// 渠道分成体系的归属绑定:渠道商(5) 填 Channelid,只可见自己这一个渠道商的数据。
// 与 Apps/Products/Regions 一样随 JWT 下发,请求级由 scopeOf 强制收敛,前端传参不可越权。
Brandid uint32 `gorm:"column:brandid;index" json:"brandid"` // 绑定品牌商 id,0=不绑定
//
// ⚠️ 2026-09-12 品牌商(identity=3)下线,这里原本还有个 Brandid。库里那一列**没有删**
// (存量账号的归属线索,且 migrateBrandAccountsToOperator 要读它),只是 struct 不再映射。
Channelid string `gorm:"column:channelid;size:16;index" json:"channelid"` // 绑定渠道商 id(短码),空=不绑定
// 代理资源点余额(仅代理 identity=3 有意义):管理员充值入账,代理给终端用户赠送时按类原子扣减。
// 代理资源点余额(仅已下线的代理 identity=3 有意义,保留列与存量数据,后台不再产出这类账号):
// 管理员充值入账,代理给终端用户赠送时按类原子扣减。
// 单位与赠送表单/公码奖励一致:VIP=天、翻译/会议=分钟。
BalanceVipday int64 `gorm:"column:balance_vipday" json:"balance_vipday"` // VIP天数余额
BalanceTrademin int64 `gorm:"column:balance_trademin" json:"balance_trademin"` // 翻译分钟余额
@ -73,11 +77,29 @@ func (this *modelComp) ensureAccountTable() error {
return res.Error
}
}
// 渠道分成体系的归属绑定列(品牌商账号/渠道商账号),幂等补列。
if res := postgres.Exec("ALTER TABLE " + comm.TableConsoleAccount + " ADD COLUMN IF NOT EXISTS brandid bigint DEFAULT 0"); res.Error != nil {
// 渠道商账号的归属绑定列,幂等补列。
// ⚠️ brandid 那一列刻意不补也不删:品牌商 2026-09-12 下线后新库不该再有它,
// 老库里那列留着是存量账号唯一的归属线索(migrateBrandAccountsToOperator 的错误提示要用)。
if res := postgres.Exec("ALTER TABLE " + comm.TableConsoleAccount + " ADD COLUMN IF NOT EXISTS channelid varchar(16) DEFAULT ''"); res.Error != nil {
return res.Error
}
if res := postgres.Exec("ALTER TABLE " + comm.TableConsoleAccount + " ADD COLUMN IF NOT EXISTS channelid varchar(16) DEFAULT ''"); res.Error != nil {
return migrateBrandAccountsToOperator()
}
// migrateBrandAccountsToOperator 处置存量的品牌商账号(identity=3):**禁用 + 降级为运营(4)**。
//
// ⚠️ 这一步是安全必需的,不是清理洁癖。品牌商账号原先靠 acctScope.brandId 把数据收敛到
// 自家品牌;brandId 随品牌商下线一并删除后,scopeOf 对它只剩 apps/products/regions/channelid
// 四维,而这类账号这四项**本来就都是空的**——按「非空才限制」的语义,它们会当场从
// 「只看自己品牌」变成「看全平台」。降级成运营也救不了(运营同样是空绑定=不限),
// 所以必须同时 enabled=false,把可见性交回超管手里重新配。
//
// 幂等:只改 identity=3 的行,改完就没有这类行了。
func migrateBrandAccountsToOperator() error {
res := postgres.Exec(
"UPDATE "+comm.TableConsoleAccount+" SET identity=?, enabled=false, updatetime=? WHERE identity=?",
int32(pb.Identity_Operator), time.Now().Unix(), int32(pb.Identity_Agent))
if res.Error != nil {
return res.Error
}
return nil

80
apps/services/modules/console/model_device.go

@ -46,51 +46,16 @@ func dvCountProductsByProvider(sys *appConn, providerId uint32) (n int64, err er
return
}
// ---- 品牌商(brand,原厂家)/ 产品(下拉只读)----
func dvBrands(sys *appConn) (models []*pb.DBBrand, err error) {
models = make([]*pb.DBBrand, 0)
err = sys.AdminDB().Find(comm.TableBrand, &models, "")
return
}
func dvBrand(sys *appConn, id uint32) (model *pb.DBBrand, err error) {
model = &pb.DBBrand{}
// 用 FindOne:品牌商不存在时返回 ErrRecordNotFound,存在性校验才有效(Find 对空结果不报错)。
err = sys.AdminDB().FindOne(comm.TableBrand, model, "id=?", id)
return
}
func dvAddBrand(sys *appConn, model *pb.DBBrand) (err error) {
err = sys.AdminDB().Insert(comm.TableBrand, model)
return
}
func dvDelBrand(sys *appConn, id uint32) (err error) {
err = sys.AdminDB().Delete(comm.TableBrand, "id=?", id)
return
}
func dvSaveBrand(sys *appConn, model *pb.DBBrand) (err error) {
err = sys.AdminDB().Save(comm.TableBrand, model)
return
}
// ---- 渠道商(channel)----
//
// 渠道商挂在品牌商下,id 为 8 位可读短码(主键)。新增走审批:status=0 待审核时不可用于生产。
// 渠道商现在是全局实体(2026-09-12 品牌商下线后不再有上级归属),id 为 8 位可读短码(主键)。
// 新增走审批:status=0 待审核时不可用于生产。
// dvChannels 按条件列出渠道商。brandid=0 不限品牌商;status<0 不限状态;keyword 匹配名称或 id。
func dvChannels(sys *appConn, brandid uint32, status int32, keyword string) (models []*pb.DBChannel, err error) {
// dvChannels 按条件列出渠道商。status<0 不限状态;keyword 匹配名称或 id。
func dvChannels(sys *appConn, status int32, keyword string) (models []*pb.DBChannel, err error) {
models = make([]*pb.DBChannel, 0)
where := make([]string, 0, 3)
args := make([]interface{}, 0, 3)
if brandid != 0 {
// brandid=0 的是「全局渠道商」(后台新增渠道商已不再选品牌商),任何品牌商都看得到、用得上。
// 只写 brandid=? 会让品牌商账号除了自家官方渠道之外什么也看不见。
where = append(where, "(brandid=? OR brandid=0)")
args = append(args, brandid)
}
if status >= 0 {
where = append(where, "status=?")
args = append(args, status)
@ -360,43 +325,6 @@ func dvProductionBatches(sys *appConn, query interface{}, args ...interface{}) (
return
}
// ---- 品牌商公码 ----
// dvFactoryPublicCodes 按品牌商 + 状态过滤(brandid=0 不过滤品牌商,statusFilter<0 不过滤状态)。
func dvFactoryPublicCodes(sys *appConn, brandid uint32, statusFilter int32) (models []*pb.DBFactoryPublicCode, err error) {
models = make([]*pb.DBFactoryPublicCode, 0)
tx := sys.AdminDB().Table(comm.TableFactoryPublicCode)
if brandid != 0 {
tx = tx.Where("brandid=?", brandid)
}
if statusFilter >= 0 {
tx = tx.Where("status=?", statusFilter)
}
err = tx.Order("createtime DESC").Find(&models).Error
return
}
func dvFactoryPublicCode(sys *appConn, code string) (model *pb.DBFactoryPublicCode, err error) {
model = &pb.DBFactoryPublicCode{}
err = sys.AdminDB().FindOne(comm.TableFactoryPublicCode, model, "code=?", code)
return
}
func dvAddFactoryPublicCode(sys *appConn, model *pb.DBFactoryPublicCode) (err error) {
err = sys.AdminDB().Insert(comm.TableFactoryPublicCode, model)
return
}
func dvSaveFactoryPublicCode(sys *appConn, model *pb.DBFactoryPublicCode) (err error) {
err = sys.AdminDB().Save(comm.TableFactoryPublicCode, model)
return
}
func dvDelFactoryPublicCode(sys *appConn, code string) (err error) {
err = sys.AdminDB().Delete(comm.TableFactoryPublicCode, "code=?", code)
return
}
// dvAuthCodesByCodes 查这批 code(=MAC) 已经存在哪些,返回已存在的 code。
// 导入 MAC 前的查重用:MAC 就是主键,撞了会让整批 INSERT 失败而序号游标已经推进过。
//

40
apps/services/modules/console/model_device_cache.go

@ -63,40 +63,10 @@ func (this *deviceCacheComp) loopRefresh() {
// Refresh 全量刷新所有数据集。best-effort:单个数据集失败仅记日志,不影响其它。
func (this *deviceCacheComp) Refresh() {
this.refreshBrands()
this.refreshProducts()
this.refreshProductVersions()
}
// ---- 品牌商 ----
func (this *deviceCacheComp) refreshBrands() {
models, err := dvBrands(consoleDeviceConn())
if err != nil {
this.module.Error("deviceCache refreshBrands load", log.Field{Key: "err", Value: err.Error()})
return
}
items := make(map[string]any, len(models))
for _, m := range models {
items[fmt.Sprintf("%d", m.Id)] = m
}
if err = cache.ReplaceAll(context.Background(), comm.Cache_Brand, items, deviceCacheTTL); err != nil {
this.module.Error("deviceCache refreshBrands write", log.Field{Key: "err", Value: err.Error()})
}
}
// GetBrands 读全部品牌商:缓存优先,缓存为空或异常时回退查 DB。
// 缓存底层是 Redis Hash(HGetAll 无序),统一按品牌商 id 升序,保证列表顺序稳定。
func (this *deviceCacheComp) GetBrands() (models []*pb.DBBrand, err error) {
if list, cerr := cache.GetAll[pb.DBBrand](context.Background(), comm.Cache_Brand); cerr == nil && len(list) > 0 {
models = list
} else if models, err = dvBrands(consoleDeviceConn()); err != nil {
return
}
sort.Slice(models, func(i, j int) bool { return models[i].Id < models[j].Id })
return models, nil
}
// ---- 产品 ----
func (this *deviceCacheComp) refreshProducts() {
@ -115,19 +85,15 @@ func (this *deviceCacheComp) refreshProducts() {
}
// GetProducts 读全部产品:缓存优先,回退 DB。
// 缓存底层是 Redis Hash(HGetAll 无序),统一按 厂家id、产品id 升序,保证列表顺序稳定。
// 缓存底层是 Redis Hash(HGetAll 无序),统一按产品 id 升序,保证列表顺序稳定。
// (2026-09-12 品牌商下线前这里先按 brandid 排,那一维现在恒为 0,已去掉。)
func (this *deviceCacheComp) GetProducts() (models []*pb.DBProduct, err error) {
if list, cerr := cache.GetAll[pb.DBProduct](context.Background(), comm.Cache_Product); cerr == nil && len(list) > 0 {
models = list
} else if models, err = dvProducts(consoleDeviceConn()); err != nil {
return
}
sort.Slice(models, func(i, j int) bool {
if models[i].Brandid != models[j].Brandid {
return models[i].Brandid < models[j].Brandid
}
return models[i].Id < models[j].Id
})
sort.Slice(models, func(i, j int) bool { return models[i].Id < models[j].Id })
return models, nil
}

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

@ -91,6 +91,14 @@ func (this *modelComp) Init(service core.IService, module core.IModule, comp cor
if err = ensureSettlementTable(); err != nil {
this.module.Errorln(err)
}
// 凭据归一:两张扁平 env 表(global_config / 业务库 config) → svc_config。
// 幂等,每次启动跑,跑完即空转;细节与取舍见 migrate_envtosvc.go。
// 必须排在 ensureConfigTables 之后(要 svc_config 表已存在)。
if apps, e := this.listApps(); e != nil {
this.module.Errorln(e)
} else {
migrateEnvToSvcConfig(this.module.options.EncryptKey, apps)
}
return
}

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

@ -191,20 +191,21 @@ func ensureDeviceTables() error {
// 方案商 id 从 0xAB01 起:Floor 语义,已有历史行(id 1、2、3…)保留,只把序列抬上去,
// 后台新建的方案商从 0xAB01 开始发号。
_ = adb.AutoIncrementFloor(comm.TableSolutionProvider, "id", 0xAB01)
if err := adb.CreateTable(comm.TableBrand, &pb.DBBrand{}); err != nil {
return err
}
_ = adb.AutoIncrementStart(comm.TableBrand, "id", 0xA001)
// createtime/sharerate 是后加字段(品牌商建档时间、分成比例万分比):CreateTable 对已存在的表
// 跳过 AutoMigrate,故显式补列。历史品牌商 createtime=0(列表展示为空)、sharerate=0(不分成)。
for _, ddl := range []string{
"ALTER TABLE " + comm.TableBrand + " ADD COLUMN IF NOT EXISTS createtime bigint DEFAULT 0",
"ALTER TABLE " + comm.TableBrand + " ADD COLUMN IF NOT EXISTS sharerate integer DEFAULT 0",
} {
if res := adb.Exec(ddl); res.Error != nil {
// 2026-09-12 品牌商(brand)整体下线:后台「品牌商管理」页、console/api 的 brand 接口、
// 品牌商账号角色(identity=3) 全部删除,表一并 DROP。
//
// ⚠️ 不可逆。删表前请确认这三件事已经不需要了:品牌商档案本身、它的联系邮箱
//(原先是 MAC生成 产品通知邮件的收件人之一)、以及 productlists 那份产品归属清单。
//
// ⚠️ 其它表的 brandid 列**刻意保留**,不在这里删:
// · product.brandid —— modules/user/compat.go 靠它给老 App 回填 DBProduct.factoryid,
// 不下发会让已发布的客户端整个 fromJson 抛 TypeError;
// · production_batch.brandid / device_mac.brandid —— 历史批次的归属留痕;
// · payorder.src_brandid —— 订单分成溯源。
// 这些列今后只写 0,不再指向任何档案。
if res := adb.Exec("DROP TABLE IF EXISTS " + comm.TableBrand); res.Error != nil {
return res.Error
}
}
if err := adb.CreateTable(comm.TableChannel, &pb.DBChannel{}); err != nil {
return err
}
@ -248,7 +249,7 @@ func ensureDeviceTables() error {
}
_ = adb.AutoIncrementStart(comm.TableProduct, "id", 0xB001)
// appnames 是后加字段(产品归属的应用名 CSV):CreateTable 对已存在的表跳过 AutoMigrate,故显式补列。
// 历史行留空,语义为「未绑定应用」——此时品牌商账号的应用下拉退化为不限制(见 brandAppNames)。
// 历史行留空,语义为「未绑定应用」——此时账号的应用下拉退化为不限制(见 accountAppNames)。
// oemfactoryid 是后加字段(这款产品默认由哪家代工厂生产,oem_factory.id)。
// ⚠️ 不叫 factoryid:DBProduct 里那个 factoryid 是老客户端兼容字段(gorm:"-",值同 brandid),同名会打架。
if res := adb.Exec("ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS oemfactoryid bigint DEFAULT 0"); res.Error != nil {
@ -286,8 +287,12 @@ func ensureDeviceTables() error {
if err := adb.CreateTable(comm.TableProductVersion, &pb.DBProductVersion{}); err != nil {
return err
}
if err := adb.CreateTable(comm.TableFactoryPublicCode, &pb.DBFactoryPublicCode{}); err != nil {
return err
// 2026-09-12 厂家公码下线:后台入口、console/api 接口、客户端 user_bindauthcode 全部删除,
// 表一并 DROP(本次是用户明确要求的彻底清除,不同于 license 分表那种"搬完留着当回退依据")。
// ⚠️ 不可逆:表里若还有已发给客户、尚未扫的公码,DROP 之后无法找回。
// api 服务那边的建表也已删除,否则它一重启就把空表建回来。
if res := adb.Exec("DROP TABLE IF EXISTS " + comm.TableFactoryPublicCode); res.Error != nil {
return res.Error
}
// 设备 MAC 表:**全局一张**,不再按产品切分表。
// 设备的身份是它自己的 MAC(主键 code = devicemac),productid 只是归属列。

29
apps/services/modules/console/scope.go

@ -5,7 +5,7 @@ package console
登录时把账号绑定(apps/products/regions,CSV)写进 JWT,tokenValid 解析后放进 gin.Context。
每个数据接口用 scopeOf(c) 取出作用域,对查询/结果强制按绑定收敛——无论前端传什么、是否传 X-App-Id,
代理(3)/运营(4) 都只能拿到自己绑定范围内的数据;超管(1)/管理员(2) 不受限。
运营(4)/渠道商(5) 都只能拿到自己绑定范围内的数据;超管(1)/管理员(2) 不受限。
语义(与前端既有行为一致):某维度绑定为空 = 该维度不限制;非空 = 仅限列表内。
*/
@ -24,11 +24,10 @@ type acctScope struct {
apps []string // 绑定应用名(空=应用维度不限)
products []uint32 // 绑定产品 id(空=产品维度不限)
regions []string // 绑定区域代码(空=区域维度不限)
// 渠道分成体系的归属绑定(P3 起):
// 品牌商账号(代理,3):brandId 非 0,可见该品牌商及其名下全部渠道商的数据;
// 渠道商账号(5) :brandId + channelId 都有,只可见自己这一个渠道商的数据。
// 与 apps/products 一样是「非空才限制」,故超管/管理员天然不受影响。
brandId uint32
// 渠道分成体系的归属绑定(P3 起):渠道商账号(5) 的 channelId 非空,
// 只可见自己这一个渠道商的数据。与 apps/products 一样是「非空才限制」,
// 故超管/管理员天然不受影响。
// (品牌商账号 identity=3 已于 2026-09-12 随 brand 表一并下线,原先还有个 brandId。)
channelId string
}
@ -80,7 +79,6 @@ func scopeOf(c *gin.Context) acctScope {
apps: splitCSV(ctxStr(c, "apps")),
products: splitCSVU32(ctxStr(c, "products")),
regions: splitCSV(ctxStr(c, "regions")),
brandId: ctxU32(c, "brandid"),
channelId: ctxStr(c, "channelid"),
}
}
@ -96,29 +94,19 @@ func ctxU32(c *gin.Context, k string) uint32 {
return 0
}
// allowBrand 该账号是否可见某品牌商的数据。品牌商/渠道商账号只可见自己绑定的品牌商。
func (s acctScope) allowBrand(id uint32) bool {
return s.unlimited || s.brandId == 0 || s.brandId == id
}
// allowChannel 该账号是否可见某渠道商的数据。渠道商账号只可见自己那一个。
// 品牌商账号不在此限制(它看的是名下全部渠道商,由 allowBrand + 列表过滤把关)。
func (s acctScope) allowChannel(id string) bool {
return s.unlimited || s.channelId == "" || s.channelId == id
}
// filterChannels 把渠道商列表收敛到账号可见范围:渠道商账号只剩自己,品牌商账号只剩
// 本品牌商名下的 + 全局渠道商。
//
// ⚠️ brandid==0 是「全局渠道商」(后台新增渠道商已不再选品牌商),对所有账号可见。
// 少了这个判断,品牌商账号会看到一个空列表——allowBrand(0) 在 brandId 非 0 时恒为 false。
// filterChannels 把渠道商列表收敛到账号可见范围:渠道商账号只剩自己那一个,其余不限。
func (s acctScope) filterChannels(in []*pb.DBChannel) []*pb.DBChannel {
if s.unlimited || (s.brandId == 0 && s.channelId == "") {
if s.unlimited || s.channelId == "" {
return in
}
out := make([]*pb.DBChannel, 0, len(in))
for _, ch := range in {
if (ch.Brandid == 0 || s.allowBrand(ch.Brandid)) && s.allowChannel(ch.Id) {
if s.allowChannel(ch.Id) {
out = append(out, ch)
}
}
@ -170,7 +158,6 @@ func (s acctScope) statsClause() (string, []interface{}) {
args := make([]interface{}, 0, 2)
// 渠道商账号:直接锁死到自己那一个渠道商,比产品/应用维度都具体,优先于它们。
// (品牌商账号的收敛在调用方按「本品牌商名下渠道商集合」注入,见 statsChannelClause。)
if s.channelId != "" {
conds = append(conds, "channel_id = ?")
args = append(args, s.channelId)

34
apps/services/modules/console/scope_app.go

@ -1,7 +1,6 @@
package console
import (
"sort"
"strings"
"yunyan/pb"
@ -58,40 +57,15 @@ func (this *serverComp) checkAppNames(csv string) string {
return ""
}
// brandAppNames 取某品牌商名下全部产品绑定的应用名并集(走产品缓存,避免每次查库)。
// 返回空切片 = 该品牌的产品都没绑应用,调用方按「不限制」处理。
func (this *serverComp) brandAppNames(brandId uint32) []string {
products, err := this.module.deviceCache.GetProducts()
if err != nil {
return nil
}
seen := make(map[string]bool, 4)
out := make([]string, 0, 4)
for _, p := range products {
if p.Brandid != brandId {
continue
}
for _, n := range splitCSV(p.Appnames) {
if !seen[n] {
seen[n] = true
out = append(out, n)
}
}
}
sort.Strings(out)
return out
}
// accountAppNames 当前账号在「应用」维度的绑定名单(空=该维度不限制):
// 品牌商/渠道商账号按名下产品的应用归属推导,其余角色沿用账号手工勾选的 apps。
// accountAppNames 当前账号在「应用」维度的绑定名单(空=该维度不限制):沿用账号手工勾选的 apps。
//
// ⚠️ 2026-09-12 之前品牌商账号(identity=3)走的是另一条路——按「名下产品绑定的应用」推导
// (brandAppNames)。品牌商随 brand 表一并下线后这条路没有调用方了,已删除。
func (this *serverComp) accountAppNames(c *gin.Context) []string {
s := scopeOf(c)
if s.unlimited {
return nil
}
if s.brandId != 0 {
return this.brandAppNames(s.brandId)
}
return s.apps
}

91
apps/services/modules/console/scope_channel.go

@ -1,104 +1,27 @@
package console
import (
"yunyan/pb"
"github.com/gin-gonic/gin"
)
// 品牌商账号 / 渠道商账号的数据收敛(P3 渠道分成体系)。
// 渠道商账号的数据收敛(P3 渠道分成体系)。
//
// 语义:
// - 渠道商账号(5):锁死到自己那一个渠道商——由 acctScope.statsClause 直接出 channel_id = ?;
// - 品牌商账号(3, 即代理):可见「本品牌商名下全部渠道商」的行,外加「本品牌商产品」的行
// (历史数据 channel_id 为空,只能靠产品归属找回,否则品牌商看不到自己迁移前的数据)。
// 语义:渠道商账号(5) 锁死到自己那一个渠道商——由 acctScope.statsClause 直接出 channel_id = ?。
//
// 两条件取 OR:任一命中都是本品牌商的数据,不会串到别的品牌商——product 与 channel 都按
// brandid 归属,交集之外的行必然不属于本品牌商。
// ⚠️ 2026-09-12 品牌商(identity=3)整体下线:brand 表、品牌商管理页、品牌商账号角色一并删除,
// 原先「品牌商账号可见名下全部渠道商 ∪ 本品牌商产品」那层额外收敛随之取消。
// 渠道商现在是全局实体,不再有上级归属。
//
// 这些收敛在后端强制执行,前端传什么过滤条件都只能在这个范围内再缩小。
// brandChannelIds 取某品牌商名下全部渠道商 id(含未启用的:历史统计行仍挂在它名下)。
func (this *serverComp) brandChannelIds(brandId uint32) []string {
sys := consoleDeviceConn()
list, err := dvChannels(sys, brandId, -1, "")
if err != nil {
return nil
}
out := make([]string, 0, len(list))
for _, ch := range list {
out = append(out, ch.Id)
}
return out
}
// brandProductIds 取某品牌商名下全部产品 id(走产品缓存,避免每次查库)。
func (this *serverComp) brandProductIds(brandId uint32) []uint32 {
products, err := this.module.deviceCache.GetProducts()
if err != nil {
return nil
}
out := make([]uint32, 0, 8)
for _, p := range products {
if p.Brandid == brandId {
out = append(out, p.Id)
}
}
return out
}
// statsScopeClause 组合出统计查询的账号作用域条件:
// 先取通用收敛(应用/产品/区域/渠道商),品牌商账号再额外补上「本品牌商的渠道商 ∪ 产品」限定。
// statsScopeClause 组合出统计查询的账号作用域条件(应用/产品/区域/渠道商)。
// 返回的条件由调用方与前端下钻条件 AND,越权不可能穿透。
func (this *serverComp) statsScopeClause(c *gin.Context) (string, []interface{}) {
s := scopeOf(c)
// 账号绑定里存的是应用名与国内/海外,统计表按部署粒度存,收敛前同样要展开(口径与前端下钻一致)。
s.apps = this.expandAppNames(s.apps)
s.regions = expandRegionCodes(s.regions)
where, args := s.statsClause()
// 只有「品牌商账号」需要额外收敛:超管/管理员不受限;渠道商账号已被 statsClause 锁死。
if s.unlimited || s.brandId == 0 || s.channelId != "" {
return where, args
}
channels := this.brandChannelIds(s.brandId)
products := this.brandProductIds(s.brandId)
var bw string
var ba []interface{}
switch {
case len(channels) > 0 && len(products) > 0:
bw = "(channel_id IN ? OR product_id IN ?)"
ba = []interface{}{channels, products}
case len(channels) > 0:
bw = "channel_id IN ?"
ba = []interface{}{channels}
case len(products) > 0:
bw = "product_id IN ?"
ba = []interface{}{products}
default:
// 该品牌商既无渠道商也无产品:没有任何数据属于它,返回恒假而不是「不过滤」,
// 否则会退化成看到全量。
bw = "1=0"
}
return andWhere(where, args, bw, ba)
}
// requireBrandScope 校验请求里的品牌商 id 在账号可见范围内;越界即写好错误响应并返回 false。
func (this *serverComp) requireBrandScope(c *gin.Context, brandId uint32) bool {
if scopeOf(c).allowBrand(brandId) {
return true
}
writeErr(c, pb.ErrorCode_InsufficientPermissions, "无权访问该品牌商的数据")
return false
}
// effectiveBrandId 把请求里的品牌商过滤收敛到账号绑定:品牌商/渠道商账号一律强制成自己的
// brandid(忽略前端传值),其余角色按前端传值。用于列表类查询。
func effectiveBrandId(c *gin.Context, reqBrandId uint32) uint32 {
s := scopeOf(c)
if s.unlimited || s.brandId == 0 {
return reqBrandId
}
return s.brandId
return s.statsClause()
}
// effectiveChannelId 把请求里的渠道商过滤收敛到账号绑定:渠道商账号一律强制成自己的 channelid,

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

@ -217,7 +217,6 @@ func (this *serverComp) tokenValid(c *gin.Context) bool {
c.Set("apps", claims.Apps)
c.Set("products", claims.Products)
c.Set("regions", claims.Regions)
c.Set("brandid", claims.Brandid)
c.Set("channelid", claims.Channelid)
return true
}
@ -301,19 +300,9 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_getcostoken":
this.getUploadURL(c)
return
// 服务配置域:全局环境配置/MCP/会议模板,三类共享配置存 console 主库(supabase),与选中应用无关。
case "api_getglobalconfigs":
this.getGlobalConfigs(c)
return
case "api_addglobalconfig":
this.addGlobalConfig(c)
return
case "api_updateglobalconfig":
this.updateGlobalConfig(c)
return
case "api_delglobalconfig":
this.delGlobalConfig(c)
return
// 服务配置域:会议模板等共享配置存 console 主库(supabase),与选中应用无关。
// (「全局环境配置」global_config 已于 2026-09-12 下线:凭据统一收进 svc_config,
// 见 migrate_envtosvc.go 与 modules/user 的 appEnvForUser。)
case "api_getmeettemplates":
this.getMeetTemplates(c)
return
@ -393,22 +382,6 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_delsvctemplate":
this.delSvcTemplate(c)
return
// yunyan 自管 Agent 配置(agent):编排第三方服务(引用 svc id),配置下发客户端;存 console 主库,与选中应用无关。
case "api_getagents":
this.getAgents(c)
return
case "api_getagent":
this.getAgent(c)
return
case "api_addagent":
this.addAgent(c)
return
case "api_updateagent":
this.updateAgent(c)
return
case "api_delagent":
this.delAgent(c)
return
// MCP 工具发现:连上 MCP 服务列出它暴露的工具(服务配置页填完 url 点一下、agent 绑定时勾工具都用它)。
case "api_getmcptools":
this.getMcpTools(c)
@ -417,22 +390,6 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_getttsvoices":
this.getTTSVoices(c)
return
// 通话翻译规则(calltranslate):区域×多源/多目标语言×优先级 → agent,存 console 主库,按 app_name 作用域。
case "api_getcalltranslaterules":
this.getCallTranslateRules(c)
return
case "api_savecalltranslaterule":
this.saveCallTranslateRule(c)
return
case "api_delcalltranslaterules":
this.delCallTranslateRules(c)
return
case "api_synccalltranslaterules":
this.syncCallTranslateRules(c)
return
case "api_testcalltranslaterule":
this.testCallTranslateRule(c)
return
// 应用发版与入口控制(app_release):版本/强更/送审版本/游客显隐,按 app_name 一应用一行、不分渠道。
// 两个保存接口各写一组字段,共用同一行——别合成一个「整行保存」,会互相清空。
case "api_getapprelease":
@ -457,22 +414,6 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_getchannelpaystatus":
this.getChannelPayStatus(c)
return
// 会议记录服务编排:引用 svc_config 的服务,按 app_name+region 分层(app_name=''&region=0 为全局默认)。
case "api_getechoorch":
this.getEchoOrch(c)
return
case "api_saveechoorch":
this.saveEchoOrch(c)
return
case "api_delechoorch":
this.delEchoOrch(c)
return
case "api_reorderechoorch":
this.reorderEchoOrch(c)
return
case "api_saveechoorchsetting":
this.saveEchoOrchSetting(c)
return
// 邮件下发(生产设备码清单):multipart 表单 + 全局 email 系统,与选中应用无关。
case "api_sendsmtpemail":
this.sendSmtpEmail(c)
@ -575,14 +516,6 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_delproductversion":
this.delProductVersion(c, sys)
// 品牌商(原厂家)
case "api_getbrands":
this.getBrands(c, sys)
case "api_addbrand":
this.addBrand(c, sys)
case "api_updatebrand":
this.updateBrand(c, sys)
case "api_delbrand":
this.delBrand(c, sys)
// 渠道商(挂在品牌商下)
case "api_getchannels":
this.getChannels(c, sys)
@ -618,14 +551,6 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_restfactorydevics":
this.restFactoryDevics(c, sys)
// 品牌商公码
case "api_getfactorypubliccodes":
this.getFactoryPublicCodes(c, sys)
case "api_createfactorypubliccode":
this.createFactoryPublicCode(c, sys)
case "api_updatefactorypubliccode":
this.updateFactoryPublicCode(c, sys)
case "api_delfactorypubliccode":
this.delFactoryPublicCode(c, sys)
default:
writeErr(c, pb.ErrorCode_NoFindServiceHandleFunc, "无此接口: "+method)
}
@ -672,11 +597,10 @@ func (this *serverComp) login(c *gin.Context) {
now := time.Now()
// 作用域绑定(代理/运营才有;超管/管理员为空=不受限):先查出来,既写进 token 供请求级强制隔离,也回传前端控菜单/筛选。
var access, apps, regions, products, channelid string
var brandid uint32
if accountId > 0 {
if acc, e := this.module.model.getAccount(accountId); e == nil && acc != nil {
access, apps, regions, products = acc.Access, acc.Apps, acc.Regions, acc.Products
brandid, channelid = acc.Brandid, acc.Channelid
channelid = acc.Channelid
}
}
claims := &consoleClaims{
@ -686,7 +610,6 @@ func (this *serverComp) login(c *gin.Context) {
Apps: apps,
Products: products,
Regions: regions,
Brandid: brandid,
Channelid: channelid,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "console",
@ -703,13 +626,10 @@ func (this *serverComp) login(c *gin.Context) {
return
}
// 把绑定应用名解析为 {id,name}:代理无权调 apps/list,但 users.html / 切换器需要注册 id 取 X-App-Id。
// 品牌商/渠道商账号不靠手工勾选,而是按「名下产品绑定的应用」推导(见 scope_app.go)。
// 渠道商账号不靠手工勾选,而是按「名下产品绑定的应用」推导(见 scope_app.go)。
// 超管/管理员无绑定 → 留空(它们走 apps/list,且这里若下发全量会让前端误设 X-App-Id)。
appBinds := make([]gin.H, 0)
bindNames := splitCSV(apps)
if brandid != 0 {
bindNames = this.brandAppNames(brandid)
}
if len(bindNames) > 0 {
appBinds = this.visibleAppBinds(bindNames, false)
}
@ -723,7 +643,6 @@ func (this *serverComp) login(c *gin.Context) {
"apps": apps,
"regions": regions,
"products": products,
"brandid": brandid, // 品牌商账号/渠道商账号绑定的品牌商(0=不绑定)
"channelid": channelid, // 渠道商账号绑定的渠道商短码(空=不绑定)
"appbinds": appBinds, // [{id,name,region}]:代理用它取应用注册 id
})
@ -778,44 +697,6 @@ func (this *serverComp) getSolutionProvider(c *gin.Context, sys *appConn) {
writeOK(c, &pb.ApiGetSolutionProviderResp{Provider: model})
}
// ---- 品牌商(读,原厂家)----
func (this *serverComp) getBrands(c *gin.Context, sys *appConn) {
models, err := this.module.deviceCache.GetBrands()
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
// 品牌商/渠道商账号:只可见自己绑定的那一个品牌商。
if s := scopeOf(c); !s.unlimited && s.brandId != 0 {
kept := make([]*pb.DBBrand, 0, 1)
for _, b := range models {
if b.Id == s.brandId {
kept = append(kept, b)
}
}
models = kept
} else if s := scopeOf(c); !s.unlimited && len(s.products) > 0 {
// 代理/运营:由绑定产品反推可见品牌商(只保留拥有绑定产品的品牌商)。
allowBid := make(map[uint32]bool)
if prods, perr := this.module.deviceCache.GetProducts(); perr == nil {
for _, p := range prods {
if s.allowProduct(p.Id) {
allowBid[p.Brandid] = true
}
}
}
kept := make([]*pb.DBBrand, 0, len(models))
for _, b := range models {
if allowBid[b.Id] {
kept = append(kept, b)
}
}
models = kept
}
writeOK(c, &pb.ApiGetBrandsResp{Brands: models})
}
func (this *serverComp) getProducts(c *gin.Context, sys *appConn) {
models, err := this.module.deviceCache.GetProducts()
if err != nil {
@ -823,16 +704,6 @@ func (this *serverComp) getProducts(c *gin.Context, sys *appConn) {
return
}
models = scopeOf(c).filterProducts(models) // 代理/运营:仅返回绑定产品
// 品牌商/渠道商账号:再按绑定品牌商收敛(它们不靠 products 绑定,而是整个品牌商可见)。
if s := scopeOf(c); !s.unlimited && s.brandId != 0 {
kept := make([]*pb.DBProduct, 0, len(models))
for _, p := range models {
if p.Brandid == s.brandId {
kept = append(kept, p)
}
}
models = kept
}
writeOK(c, &pb.ApiGetProductsResp{Products: models})
}
@ -848,9 +719,6 @@ func (this *serverComp) getProduct(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
if !this.requireBrandScope(c, model.Brandid) {
return
}
writeOK(c, &pb.ApiGetProductResp{Product: model})
}
@ -869,17 +737,6 @@ func (this *serverComp) getProductVersions(c *gin.Context, sys *appConn) {
writeOK(c, &pb.ApiGetProductVersionsResp{Versions: models})
}
func (this *serverComp) delBrand(c *gin.Context, sys *appConn) {
var req pb.ApiDelBrandReq
_ = c.ShouldBindJSON(&req)
if err := dvDelBrand(sys, req.Id); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
this.module.deviceCache.refreshBrands() // 写后刷新品牌商缓存
writeOK(c, &pb.ApiDelBrandResp{})
}
func (this *serverComp) getAuthCodes(c *gin.Context, sys *appConn) {
var req pb.ApiGetAuthCodesReq
_ = c.ShouldBindJSON(&req)
@ -900,11 +757,6 @@ func (this *serverComp) getAuthCodes(c *gin.Context, sys *appConn) {
conds = append(conds, "devicemac=?")
args = append(args, req.Devicemac)
}
// 品牌商/渠道商账号:过滤条件强制成自己的绑定,忽略前端传值。
if bid := effectiveBrandId(c, req.Brandid); bid != 0 {
conds = append(conds, "brandid=?")
args = append(args, bid)
}
if req.Batchno != "" {
conds = append(conds, "batchno=?")
args = append(args, req.Batchno)
@ -976,10 +828,6 @@ func (this *serverComp) getProductionBatches(c *gin.Context, sys *appConn) {
_ = c.ShouldBindJSON(&req)
query := "1=1"
args := make([]interface{}, 0)
if bid := effectiveBrandId(c, req.Brandid); bid > 0 {
query += " AND brandid = ?"
args = append(args, bid)
}
if req.Productid > 0 {
query += " AND productid = ?"
args = append(args, req.Productid)
@ -1030,110 +878,6 @@ func (this *serverComp) resetLicenseStatus(c *gin.Context, sys *appConn) {
writeOK(c, &pb.ApiResetLicenseStatusResp{})
}
func (this *serverComp) getFactoryPublicCodes(c *gin.Context, sys *appConn) {
var req pb.ApiGetFactoryPublicCodesReq
_ = c.ShouldBindJSON(&req)
models, err := dvFactoryPublicCodes(sys, req.Brandid, req.Status)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, &pb.ApiGetFactoryPublicCodesResp{Codes: models})
}
func (this *serverComp) createFactoryPublicCode(c *gin.Context, sys *appConn) {
var req pb.ApiCreateFactoryPublicCodeReq
_ = c.ShouldBindJSON(&req)
if req.Brandid == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "brandid 必填")
return
}
if _, err := dvBrand(sys, req.Brandid); err != nil {
writeErr(c, pb.ErrorCode_DBError, "品牌商不存在: "+err.Error())
return
}
// 防碰撞:最多重试 5 次
var code string
for i := 0; i < 5; i++ {
gc, err := comm.GeneratePublicCode()
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, err.Error())
return
}
if _, e := dvFactoryPublicCode(sys, gc); e != nil { // 不存在 → 可用
code = gc
break
}
}
if code == "" {
writeErr(c, pb.ErrorCode_SystemError, "公码生成多次碰撞,请重试")
return
}
now := time.Now().Unix()
model := &pb.DBFactoryPublicCode{
Code: code,
Brandid: req.Brandid,
Productid: req.Productid,
Remark: req.Remark,
Status: 0,
Maxuses: req.Maxuses,
Useduses: 0,
Expiretime: req.Expiretime,
Bindrewardvpitime: req.Bindrewardvpitime,
Bindrewardtranslate: req.Bindrewardtranslate,
Bindrewardmeeting: req.Bindrewardmeeting,
Createtime: now,
Updatetime: now,
}
if err := dvAddFactoryPublicCode(sys, model); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, &pb.ApiCreateFactoryPublicCodeResp{Code: model})
}
func (this *serverComp) updateFactoryPublicCode(c *gin.Context, sys *appConn) {
var req pb.ApiUpdateFactoryPublicCodeReq
_ = c.ShouldBindJSON(&req)
if req.Code == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "code 必填")
return
}
model, err := dvFactoryPublicCode(sys, req.Code)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "公码不存在: "+err.Error())
return
}
model.Productid = req.Productid
model.Remark = req.Remark
model.Status = req.Status
model.Maxuses = req.Maxuses
model.Expiretime = req.Expiretime
model.Bindrewardvpitime = req.Bindrewardvpitime
model.Bindrewardtranslate = req.Bindrewardtranslate
model.Bindrewardmeeting = req.Bindrewardmeeting
model.Updatetime = time.Now().Unix()
if err = dvSaveFactoryPublicCode(sys, model); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, &pb.ApiUpdateFactoryPublicCodeResp{Code: model})
}
func (this *serverComp) delFactoryPublicCode(c *gin.Context, sys *appConn) {
var req pb.ApiDelFactoryPublicCodeReq
_ = c.ShouldBindJSON(&req)
if req.Code == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "code 必填")
return
}
if err := dvDelFactoryPublicCode(sys, req.Code); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, &pb.ApiDelFactoryPublicCodeResp{})
}
// ============================ 用户查询 ============================
// getUserInfo 用户查询:前端选定「应用 + 区域」即定位到唯一注册项(X-App-Id),
@ -1322,7 +1066,6 @@ func (this *serverComp) accountMe(c *gin.Context) {
resp["balance_vipday"] = acc.BalanceVipday
resp["balance_trademin"] = acc.BalanceTrademin
resp["balance_meetmin"] = acc.BalanceMeetmin
resp["brandid"] = acc.Brandid
resp["channelid"] = acc.Channelid
}
}

185
apps/services/modules/user/api_bindauthcode.go

@ -1,185 +0,0 @@
package user
import (
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
"strings"
"time"
)
// @Summary 扫激活码领取奖励
// @Description 新设计:激活码 = 厂家公码。每个用户一生仅可领取 1 次(DBUser.Isactivatecode 判重)
// @Tags User
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param user body pb.UserBindAuthCodeReq true "扫激活码请求"
// @Success 200 {object} comm.HttpResult{data=pb.UserBindAuthCodeResp} "成功返回"
// @Router /api/home/user_bindauthcode [post]
func (this *apiComp) BindAuthCode(session comm.IUserSession, req *pb.UserBindAuthCodeReq) (resp *pb.UserBindAuthCodeResp, errdata *pb.ErrorData) {
var (
user *pb.DBUser
code *pb.DBFactoryPublicCode
err error
now = time.Now().Unix()
codeStr = strings.ToUpper(strings.TrimSpace(req.Code))
affected int64
)
if codeStr == "" {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_ReqParameterError,
Message: "激活码不能为空",
}
return
}
if user, err = this.module.model.findforid(session.GetUserId()); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_UserSessionNobeing,
Message: err.Error(),
}
return
}
// 1) 快速判重:user.Isactivatecode 为 true 表示已经领过激活码奖励
if user.Isactivatecode {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_AuthorizeNoCanUse,
Message: "您已领取过激活码奖励,不可重复领取",
}
return
}
// 2) 校验公码
if code, err = this.module.model.getFactoryPublicCode(codeStr); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_AuthorizeIllegal,
Message: "激活码无效",
}
return
}
if code.Status != 0 {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_AuthorizeNoCanUse,
Message: "激活码已停用",
}
return
}
if code.Expiretime > 0 && code.Expiretime < now {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_AuthorizeNoCanUse,
Message: "激活码已过期",
}
return
}
if code.Maxuses > 0 && code.Useduses >= code.Maxuses {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_AuthorizeNoCanUse,
Message: "激活码已达核销上限",
}
return
}
// 3) 原子置位 user.isactivatecode → 防同账号并发扫码兜底,输的请求 affected=0
if affected, err = this.module.model.tryMarkActivateCode(user.Uid); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
return
}
if affected == 0 {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_AuthorizeNoCanUse,
Message: "您已领取过激活码奖励,不可重复领取",
}
return
}
user.Isactivatecode = true
// 4) 原子扣减公码名额(CAS 防超发);失败需回滚 user.isactivatecode 以便用户换码重试
if affected, err = this.module.model.incrFactoryPublicCodeUsed(codeStr); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
return
}
if affected == 0 {
// 公码已达上限:回滚 user.isactivatecode,让用户换码重试
if rbAff, rbErr := this.module.model.rollbackActivateCode(user.Uid); rbErr != nil || rbAff == 0 {
this.module.Error("BindAuthCode rollback isactivatecode fail",
log.Field{Key: "uid", Value: user.Uid},
log.Field{Key: "rbAff", Value: rbAff})
}
errdata = &pb.ErrorData{
Code: pb.ErrorCode_AuthorizeNoCanUse,
Message: "激活码已达核销上限",
}
return
}
// 5) 计算奖励:VIP 按天累加到自购到期时间(公码是发给**账号**的,归属用户,与设备无关);
// 公码上配的翻译/会议分钟数(旧字段)按当前换算系数折成算力发到用户余额。
addVipDay := int64(code.Bindrewardvpitime)
addTradeSec := int64(code.Bindrewardtranslate) * 60
addMeetSec := int64(code.Bindrewardmeeting) * 60
addCompute := comm.LegacyBucketsToCompute(comm.LoadComputeRates(), addTradeSec, addMeetSec, 0)
if addVipDay > 0 {
addVipSec := addVipDay * 24 * 60 * 60
if user.Vipexptime == 0 || user.Vipexptime < now {
user.Vipexptime = now + addVipSec
} else {
user.Vipexptime += addVipSec
}
}
user.Computebalance += addCompute
// 6) 保存用户余额(isactivatecode 已经在步骤 3 提前落库,这里 Save 会覆盖一并写回,是幂等的)
if err = this.module.model.save(user); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
return
}
if addVipDay > 0 || addCompute > 0 {
_ = this.module.model.addIntegralLog(&pb.DBUserUseLog{
Uid: user.Uid,
Logtype: pb.UserLogType_ActivityReward,
Ts: now,
Addvipday: addVipDay,
Compute: addCompute,
Extra: code.Code,
})
}
// 埋点:二维码(公码)激活成功。只统计到 应用×区域(ProductId 留 0,不落地到产品),与用户口径一致。
if this.module.analyze != nil {
this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventQRCodeActive, Uid: user.Uid})
}
this.module.Debug("BindAuthCode",
log.Field{Key: "uid", Value: user.Uid},
log.Field{Key: "code", Value: code.Code},
log.Field{Key: "factoryid", Value: code.Brandid})
resp = &pb.UserBindAuthCodeResp{
Isgiveaway: addVipDay > 0 || addTradeSec > 0 || addMeetSec > 0,
Viplv: user.Viplv,
Vipexptime: user.Vipexptime,
Tradeintegral: user.Tradeintegral,
Tradetotalintegral: user.Tradetotalintegral,
Meetintegral: user.Meetintegral,
Meettotalintegral: user.Meettotalintegral,
Addvipday: addVipDay,
Addtradesecond: addTradeSec,
Addmeetsecond: addMeetSec,
Factoryid: code.Brandid,
Productid: code.Productid,
}
return
}

3
apps/services/modules/user/api_binddevice.go

@ -250,7 +250,8 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic
// 判定时按「当前绑定着且未到期」算。原来把天数加进 user.vipexptime 的做法一旦累加就与自购
// 混成同一个数字,解绑时拆不开;三个额度桶也已停用。
//
// `user.Isgiveaway = true` 仍然要写:厂家公码(api_bindauthcode.go)拿它当防重领标记。
// `user.Isgiveaway = true` 仍然要写:厂家公码(2026-09-12 已下线)原先拿它当防重领标记,
// 现在已无读取方,但这是 DBUser 上的存量列、老客户端仍会解析,故只停用语义、不删字段。
if codestate == 0 {
Isgiveaway = true
user.Isgiveaway = true

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

@ -23,7 +23,6 @@ func (this *apiComp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAppC
region pb.Region
err error
config []*pb.DBAppConfigItem
globalconfig []*pb.DBGlobalConfigItem
agents []*pb.DBAgent
mcpcfgs []*mcpEntry
env map[string]string
@ -42,19 +41,10 @@ func (this *apiComp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAppC
log.Field{Key: "region", Value: region},
log.Field{Key: "err", Value: err},
)
config, globalconfig, agents, mcpcfgs = this.module.configmodel.getdb()
env = make(map[string]string)
config, agents, mcpcfgs = this.module.configmodel.getdb()
// env = 应用自有配置打底 + 第三方服务配置(svc_config)按 env_key 覆盖,见 appEnvForUser。
env = appEnvForUser(session.GetUserId(), config, region)
mcps = make(map[string]*pb.McpServer)
// 先写入全局第三方服务配置(按区域过滤,优先级低)
for _, v := range config {
env[v.Key] = v.Value
}
// 先写入全局第三方服务配置(按区域过滤,优先级低)
for _, v := range globalconfig {
if v.Region == region {
env[v.Key] = v.Value
}
}
// MCP 服务:按客户端区域解析(url 在该区域为空则不下发)。
for _, e := range mcpcfgs {

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

@ -26,7 +26,6 @@ func (this *apiV2Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp
region pb.Region
err error
config []*pb.DBAppConfigItem
globalconfig []*pb.DBGlobalConfigItem
agents []*pb.DBAgent
mcpcfgs []*mcpEntry
env map[string]string
@ -45,19 +44,10 @@ func (this *apiV2Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp
log.Field{Key: "region", Value: region},
log.Field{Key: "err", Value: err},
)
config, globalconfig, agents, mcpcfgs = this.module.configmodel.getdb()
env = make(map[string]string)
config, agents, mcpcfgs = this.module.configmodel.getdb()
// env = 应用自有配置打底 + 第三方服务配置(svc_config)按 env_key 覆盖,见 appEnvForUser。
env = appEnvForUser(session.GetUserId(), config, region)
mcps = make(map[string]*pb.McpServer)
// 先写入全局第三方服务配置(按区域过滤,优先级低)
for _, v := range config {
env[v.Key] = v.Value
}
// 先写入全局第三方服务配置(按区域过滤,优先级低)
for _, v := range globalconfig {
if v.Region == region {
env[v.Key] = v.Value
}
}
// MCP 服务:按客户端区域解析(url 在该区域为空则不下发)。
for _, e := range mcpcfgs {

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

@ -38,10 +38,26 @@ func (this *apiV3Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp
log.Field{Key: "err", Value: err},
)
// 环境变量:应用自有配置(app_config) 打底,本区域的 global_config 覆盖(口径同 v2)。
// 注意 getdb 的第三个返回值是**旧 agent 表**,v3 已不下发它(见下方 agentItemsV3)。
config, globalconfig, _, _ := this.module.configmodel.getdb()
env := mergeAppEnv(config, globalconfig, region)
// 第三方服务(含 MCP 类):与 user_getthirdsvcs_v2 共用同一份解析口径。
// v3 同时要 thirdsvcs 和 env 两份产物,所以一次解析两用——两者必须来自同一份
// VIP 过滤结果,否则 VIP 过期的用户拿不到 AST 条目、却能从 env 里读出同一套凭据。
thirdsvcs, envById, err := resolveThirdSvcsWithEnv(comm.AppName(), region, nil)
if err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return
}
thirdsvcs = filterSvcsByVip(session.GetUserId(), thirdsvcs)
// 环境变量:应用自有配置(config 表) 打底,第三方服务配置的 env_key 覆盖(口径同 v1/v2)。
// 注意 getdb 的第二个返回值是**旧 agent 表**,v3 已不下发它(见下方 agentItemsV3)。
config, _, _ := this.module.configmodel.getdb()
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、变量、已补全音色),
// 客户端只调 appconfig 也能直接把 agent 跑起来。
@ -57,18 +73,11 @@ func (this *apiV3Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp
return
}
// 第三方服务(含 MCP 类):与 user_getthirdsvcs_v2 共用同一份解析口径。
thirdsvcs, err := resolveThirdSvcs(comm.AppName(), region, nil)
if err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return
}
resp = &pb.UserGetAppConfigV3Resp{
Env: env,
Agents: agents,
Products: products,
Thirdsvcs: filterSvcsByVip(session.GetUserId(), thirdsvcs),
Thirdsvcs: thirdsvcs,
Meet: this.meetCapabilities(),
// 通话翻译:按上面解析出的区域筛好的选路规则 + 规则引用的翻译 agent(见 api_v3_calltranslate.go)。
CallTranslate: this.callTranslateV3(region),
@ -76,22 +85,6 @@ func (this *apiV3Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp
return
}
// mergeAppEnv 合并下发给客户端的环境变量:应用自有配置(app_config) 打底,
// 本区域的 global_config 覆盖同名 key。口径与 v2 一致——非本区域的 global_config 一律不下发,
// 否则客户端会拿到别的区域的服务地址/密钥。
func mergeAppEnv(config []*pb.DBAppConfigItem, globalconfig []*pb.DBGlobalConfigItem, region pb.Region) map[string]string {
env := make(map[string]string, len(config)+len(globalconfig))
for _, v := range config {
env[v.Key] = v.Value
}
for _, v := range globalconfig {
if v.Region == region {
env[v.Key] = v.Value
}
}
return env
}
// meetCapabilities 取会议记录能力;echomeet 未随本服务装载时返回空壳(字段都在,列表为空),
// 免得客户端还要判 null。
func (this *apiV3Comp) meetCapabilities() *pb.MeetCapabilitiesItem {

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

@ -2,6 +2,8 @@ package user
import (
"context"
"encoding/json"
"sync"
"yunyan/comm"
"yunyan/lego/core"
"yunyan/lego/core/cbase"
@ -11,8 +13,6 @@ import (
"yunyan/lego/sys/postgres"
"yunyan/pb"
natssys "yunyan/sys/nats"
"encoding/json"
"sync"
gonats "github.com/nats-io/nats.go"
)
@ -29,7 +29,6 @@ type modelConfigComp struct {
module *User
lock sync.RWMutex
config []*pb.DBAppConfigItem
globalconfig []*pb.DBGlobalConfigItem
agents []*pb.DBAgent
mcps []*mcpEntry
}
@ -65,8 +64,9 @@ func (this *modelConfigComp) subscribeConfigChanged() {
return
}
switch ev.Kind {
// ConfigKindThirdSvc:MCP 已并入第三方服务,其增删改走该事件;ConfigKindMcp 兼容旧广播。
case comm.ConfigKindGlobalConfig, comm.ConfigKindMcp, comm.ConfigKindThirdSvc:
// ConfigKindThirdSvc:MCP 与全部第三方凭据都在 svc_config,其增删改走该事件;
// ConfigKindMcp 兼容旧广播。(ConfigKindGlobalConfig 随 global_config 表一起下线。)
case comm.ConfigKindMcp, comm.ConfigKindThirdSvc:
if e := this.loaddb(); e != nil {
this.module.Error("user: 配置变更重载失败", log.Field{Key: "err", Value: e.Error()})
return
@ -99,16 +99,12 @@ func (this *modelConfigComp) Rpc_ModifyAppConifg(ctx context.Context, args *pb.R
func (this *modelConfigComp) loaddb() (err error) {
var (
config []*pb.DBAppConfigItem
globalconfig []*pb.DBGlobalConfigItem
agents []*pb.DBAgent
mcps []*mcpEntry
)
if config, err = this.getconfig(); err != nil {
return
}
if globalconfig, err = this.getglobalconfig(); err != nil {
return
}
if agents, err = this.getagents(); err != nil {
return
}
@ -117,7 +113,6 @@ func (this *modelConfigComp) loaddb() (err error) {
}
this.lock.Lock()
this.config = config
this.globalconfig = globalconfig
this.agents = agents
this.mcps = mcps
this.lock.Unlock()
@ -129,10 +124,9 @@ func (this *modelConfigComp) loaddb() (err error) {
return
}
func (this *modelConfigComp) getdb() (config []*pb.DBAppConfigItem, globalconfig []*pb.DBGlobalConfigItem, agents []*pb.DBAgent, mcps []*mcpEntry) {
func (this *modelConfigComp) getdb() (config []*pb.DBAppConfigItem, agents []*pb.DBAgent, mcps []*mcpEntry) {
this.lock.RLock()
config = this.config
globalconfig = this.globalconfig
agents = this.agents
mcps = this.mcps
this.lock.RUnlock()
@ -146,13 +140,6 @@ func (this *modelConfigComp) getconfig() (config []*pb.DBAppConfigItem, err erro
return
}
// 全局第三方服务配置(AI、翻译等,按区域)
func (this *modelConfigComp) getglobalconfig() (config []*pb.DBGlobalConfigItem, err error) {
config = make([]*pb.DBGlobalConfigItem, 0)
err = postgres.Find(comm.TableGlobalConfig, &config, "")
return
}
// 智能体
func (this *modelConfigComp) getagents() (agents []*pb.DBAgent, err error) {
agents = make([]*pb.DBAgent, 0)
@ -160,7 +147,7 @@ func (this *modelConfigComp) getagents() (agents []*pb.DBAgent, err error) {
return
}
// mcp:MCP 已并入第三方服务(svc_config 的 MCP 服务类型)。读全局(app_name='')启用的 MCP 服务及其
// mcp:MCP 已并入第三方服务(svc_config 的 MCP 服务类型)。读全局(app_name=”)启用的 MCP 服务及其
// 全部区域覆盖,缓存为 mcpEntry;getappconfig 时按客户端区域用 comm.ResolveMcpServer 解析。
func (this *modelConfigComp) getmcpservers() (entries []*mcpEntry, err error) {
entries = make([]*mcpEntry, 0)

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

@ -15,7 +15,6 @@ import (
"yunyan/pb"
"yunyan/utils/devcode"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@ -489,46 +488,6 @@ func (this *modelUserComp) addIntegralLog(log *pb.DBUserUseLog) (err error) {
return
}
// -------------------
// 厂家公码(公码定义在 AdminDB;防重领靠 DBUser.Isgiveaway,审计靠 DBUserUseLog)
func (this *modelUserComp) getFactoryPublicCode(code string) (model *pb.DBFactoryPublicCode, err error) {
model = &pb.DBFactoryPublicCode{}
err = postgres.FindOne(comm.TableFactoryPublicCode, model, "code=?", code)
return
}
// 原子自增 useduses 并防超发:仅当 maxuses=0 或 useduses<maxuses 时才允许 +1
// 返回 affected:0 表示已达上限或公码不存在,1 表示扣减成功
func (this *modelUserComp) incrFactoryPublicCodeUsed(code string) (affected int64, err error) {
tx := postgres.Table(comm.TableFactoryPublicCode).
Where("code=? AND (maxuses=0 OR useduses<maxuses)", code).
UpdateColumn("useduses", gorm.Expr("useduses+1"))
affected = tx.RowsAffected
err = tx.Error
return
}
// 原子置位 user.isactivatecode:仅当当前为 false 时才允许置 true,
// 用于公码扫码的并发兜底(同账号同时发起多次扫码请求时只过一个)
func (this *modelUserComp) tryMarkActivateCode(uid string) (affected int64, err error) {
tx := mysql.Table(comm.TableUser).
Where("uid=? AND isactivatecode=?", uid, false).
UpdateColumn("isactivatecode", true)
affected = tx.RowsAffected
err = tx.Error
return
}
// 回滚 user.isactivatecode:在公码核销失败(如撞上限)时调用,仅当前为 true 才能改回 false
func (this *modelUserComp) rollbackActivateCode(uid string) (affected int64, err error) {
tx := mysql.Table(comm.TableUser).
Where("uid=? AND isactivatecode=?", uid, true).
UpdateColumn("isactivatecode", false)
affected = tx.RowsAffected
err = tx.Error
return
}
// sumComputeUsedSince 从 since 起的算力消耗合计(流水里消耗记为负数,这里取绝对值)。
func (this *modelUserComp) sumComputeUsedSince(uid string, since int64) (used int64, err error) {
var total struct{ Total int64 }

131
apps/services/modules/user/module_test.go

@ -53,57 +53,120 @@ func newV3Comp(echomeet comm.IEchomeet) *apiV3Comp {
// ② user_getappconfig_v3
// ---------------------------------------------------------------------------
// env 是客户端启动就要用的服务地址/密钥。合并口径错一点,客户端就连到别的区域去了。
func TestV3MergeAppEnv(t *testing.T) {
config := []*pb.DBAppConfigItem{
{Key: "OSS_BUCKET", Value: "app-oss"},
{Key: "LLM_URL", Value: "https://app-default"}, // 会被本区域的 global 覆盖
// env 是客户端启动就要用的服务地址/密钥。凭据 2026-09-12 起统一收在 svc_config,
// 由字段上的 env_key 回填一份 env 给存量客户端——这层改名映射错一点,
// 老客户端就拿不到凭据,且只会表现为「某个能力鉴权失败」,不报配置错误。
func TestSvcEnvOverlay(t *testing.T) {
svc := &comm.ThirdSvcConfig{
Id: "stt_azure", Name: "Azure STT", Categories: "1", Enable: true,
Fields: []comm.SvcField{
{Key: "subscription_key", DefValue: "k-default", EnvKey: "AZURE_SPEECH_KEY"},
{Key: "region", DefValue: "eastasia", EnvKey: "AZURE_SPEECH_REGION"},
{Key: "note", DefValue: "内部备注"}, // 没有 env_key,不该进 env
},
}
globalconfig := []*pb.DBGlobalConfigItem{
{Region: pb.Region_RegionChina, Key: "LLM_URL", Value: "https://cn"},
{Region: pb.Region_RegionChina, Key: "CN_ONLY", Value: "1"},
{Region: pb.Region_RegionUSA, Key: "LLM_URL", Value: "https://us"},
{Region: pb.Region_RegionUSA, Key: "US_ONLY", Value: "1"},
t.Run("按 env_key 改名进 env,没配 env_key 的字段不进", func(t *testing.T) {
fields, err := comm.ResolveSvcPlainFields(svc, nil, "")
if err != nil {
t.Fatal(err)
}
env := comm.ResolveSvcEnvKeys(svc, nil, fields)
if env["AZURE_SPEECH_KEY"] != "k-default" {
t.Fatalf("字段值应按 env_key 下发,实际 %q", env["AZURE_SPEECH_KEY"])
}
if _, ok := env["note"]; ok {
t.Fatal("没配 env_key 的字段不能出现在 env 里")
}
if len(env) != 2 {
t.Fatalf("只有两个字段配了 env_key,实际 %+v", env)
}
})
t.Run("本区域 global 覆盖 app_config", func(t *testing.T) {
env := mergeAppEnv(config, globalconfig, pb.Region_RegionChina)
if env["LLM_URL"] != "https://cn" {
t.Fatalf("同名 key 应被本区域 global_config 覆盖,实际 %s", env["LLM_URL"])
t.Run("区域覆盖的值进 env,区域停用的字段不进", func(t *testing.T) {
ovr := &comm.SvcRegionOverride{
SvcId: svc.Id,
Region: int32(pb.Region_RegionChina),
Overrides: map[string]string{"region": "chinaeast2"},
DisabledKeys: []string{"subscription_key"},
}
fields, err := comm.ResolveSvcPlainFields(svc, ovr, "")
if err != nil {
t.Fatal(err)
}
env := comm.ResolveSvcEnvKeys(svc, ovr, fields)
if env["AZURE_SPEECH_REGION"] != "chinaeast2" {
t.Fatalf("应取本区域覆盖值,实际 %q", env["AZURE_SPEECH_REGION"])
}
if env["OSS_BUCKET"] != "app-oss" {
t.Fatalf("global 没配的 key 应保留 app_config 的值,实际 %s", env["OSS_BUCKET"])
// 区域停用的字段在 ResolveSvcPlainFields 那一步就被剔掉了,env 里也不该凭空出现。
if _, ok := env["AZURE_SPEECH_KEY"]; ok {
t.Fatal("该区域停用的字段不能下发")
}
if env["CN_ONLY"] != "1" {
t.Fatal("本区域独有的 key 应下发")
})
t.Run("区域专属字段也能带自己的 env_key", func(t *testing.T) {
ovr := &comm.SvcRegionOverride{
SvcId: svc.Id,
Region: int32(pb.Region_RegionChina),
ExtraFields: []comm.SvcField{{Key: "endpoint", DefValue: "https://cn", EnvKey: "AZURE_SPEECH_ENDPOINT"}},
}
if _, ok := env["US_ONLY"]; ok {
t.Fatal("别的区域的 key 不能下发给中国用户")
fields, err := comm.ResolveSvcPlainFields(svc, ovr, "")
if err != nil {
t.Fatal(err)
}
env := comm.ResolveSvcEnvKeys(svc, ovr, fields)
if env["AZURE_SPEECH_ENDPOINT"] != "https://cn" {
t.Fatalf("区域专属字段应按自己的 env_key 下发,实际 %q", env["AZURE_SPEECH_ENDPOINT"])
}
})
t.Run("换个区域取到的是另一套值", func(t *testing.T) {
env := mergeAppEnv(config, globalconfig, pb.Region_RegionUSA)
if env["LLM_URL"] != "https://us" {
t.Fatalf("美区应取美区的值,实际 %s", env["LLM_URL"])
// 这条是闸门:VIP 过期时 filterSvcsByVip 会把 AST 服务从 items 里拿掉,
// env 必须跟着消失——否则老客户端照样能从 env 读出通话翻译凭据直连服务商。
t.Run("被过滤掉的服务,其 env 也不下发", func(t *testing.T) {
envById := map[string]map[string]string{
"stt_azure": {"AZURE_SPEECH_KEY": "k"},
"ast_alibaba": {"ALIBABA_OPENSPEECH_APP_KEY": "secret"},
}
kept := []*pb.ThirdSvcItem{{Id: "stt_azure", Categories: "1"}}
env := svcEnvOverlay(kept, envById)
if env["AZURE_SPEECH_KEY"] != "k" {
t.Fatal("保留下来的服务,其 env 应下发")
}
if _, ok := env["CN_ONLY"]; ok {
t.Fatal("中国区独有的 key 不能下发给美区用户")
if _, ok := env["ALIBABA_OPENSPEECH_APP_KEY"]; ok {
t.Fatal("已被 VIP 过滤掉的服务,其凭据不能从 env 漏出去")
}
})
t.Run("没有该区域配置时只剩 app_config 打底", func(t *testing.T) {
env := mergeAppEnv(config, globalconfig, pb.Region_RegionBrazil)
if env["LLM_URL"] != "https://app-default" {
t.Fatalf("无区域覆盖时应回落到 app_config,实际 %s", env["LLM_URL"])
// 客户端大量写的是 `AppConfig.env('X') ?? 默认值`,Dart 的 `??` 只兜 null——
// 下发一个空串会把默认值顶掉。老的两张扁平表对「没配的键」是**根本不下发**,
// 这里必须保持同样的行为。真机实测 AZURE_TRANSLATION_ENDPOINT 栽过这个坑。
t.Run("空值不产出 env 键", func(t *testing.T) {
svc := &comm.ThirdSvcConfig{
Id: "mt_azure", Enable: true,
Fields: []comm.SvcField{
{Key: "subscription_key", DefValue: "k", EnvKey: "AZURE_TRANSLATION_KEY"},
{Key: "endpoint", DefValue: "", EnvKey: "AZURE_TRANSLATION_ENDPOINT"},
{Key: "region", DefValue: " ", EnvKey: "AZURE_TRANSLATION_REGION"},
},
}
if len(env) != 2 {
t.Fatalf("不该混入别区域的 key,实际 %+v", env)
fields, err := comm.ResolveSvcPlainFields(svc, nil, "")
if err != nil {
t.Fatal(err)
}
env := comm.ResolveSvcEnvKeys(svc, nil, fields)
if env["AZURE_TRANSLATION_KEY"] != "k" {
t.Fatal("有值的字段照常下发")
}
if _, ok := env["AZURE_TRANSLATION_ENDPOINT"]; ok {
t.Fatal("空值字段不能下发——客户端的 ?? 默认值会被空串顶掉")
}
if _, ok := env["AZURE_TRANSLATION_REGION"]; ok {
t.Fatal("只有空白字符的字段同样按没配处理")
}
})
t.Run("空配置返回空 map 而不是 nil", func(t *testing.T) {
env := mergeAppEnv(nil, nil, pb.Region_RegionUSA)
t.Run("空输入返回空 map 而不是 nil", func(t *testing.T) {
env := svcEnvOverlay(nil, nil)
if env == nil {
t.Fatal("env 必须非 nil,否则下发 JSON 是 null,客户端取值直接崩")
}

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

@ -1,9 +1,9 @@
package user
import (
"time"
"os"
"sort"
"time"
"yunyan/comm"
"yunyan/lego/sys/log"
@ -29,6 +29,18 @@ import (
// resolveThirdSvcs 解析某应用在指定区域下应下发的第三方服务列表。
// ids 非空时只取这些服务 id;返回结果按 id 升序,便于客户端 diff。
func resolveThirdSvcs(app string, region pb.Region, ids []string) ([]*pb.ThirdSvcItem, error) {
items, _, err := resolveThirdSvcsWithEnv(app, region, ids)
return items, err
}
// resolveThirdSvcsWithEnv 同上,并额外产出「兼容 env 覆盖层」:envById[服务id][EnvKey] = 明文值。
//
// 为什么要它:凭据 2026-09-12 起统一收在 svc_config,但**已发布的客户端只会读 env**
// (user_getappconfig 的 map),所以下发时按每个字段的 EnvKey 回填一份 env。
// 按服务 id 分桶而不是直接合成一张表,是为了让 VIP 过滤(filterSvcsByVip)能对
// items 和 env **同步生效**——否则 VIP 过期的用户虽然拿不到 AST 服务条目,
// 却仍能从 env 里读出同一套凭据,闸门形同虚设。
func resolveThirdSvcsWithEnv(app string, region pb.Region, ids []string) ([]*pb.ThirdSvcItem, map[string]map[string]string, error) {
encKey := os.Getenv("FIELD_ENCRYPT_KEY")
// 本应用作用域链上的服务行(应用行 + 全局行),可按 ids 过滤。
@ -40,7 +52,7 @@ func resolveThirdSvcs(app string, region pb.Region, ids []string) ([]*pb.ThirdSv
args = append(args, ids)
}
if err := postgres.Find(comm.TableSvcConfig, &svcs, query, args...); err != nil && err != postgres.ErrNoDocuments {
return nil, err
return nil, nil, err
}
// 同 id 应用行覆盖全局行;仅启用的(与 echomeet 编排解析同语义:应用行停用则回退全局行)。
@ -67,7 +79,7 @@ func resolveThirdSvcs(app string, region pb.Region, ids []string) ([]*pb.ThirdSv
if len(svcIds) > 0 && region != 0 {
if err := postgres.Find(comm.TableSvcRegionOverride, &ovrs,
"(app_name=? OR app_name='') AND svc_id IN ? AND region=?", app, svcIds, int32(region)); err != nil && err != postgres.ErrNoDocuments {
return nil, err
return nil, nil, err
}
}
ovrOf := func(svc *comm.ThirdSvcConfig) *comm.SvcRegionOverride {
@ -80,8 +92,10 @@ func resolveThirdSvcs(app string, region pb.Region, ids []string) ([]*pb.ThirdSv
}
items := make([]*pb.ThirdSvcItem, 0, len(picked))
envById := make(map[string]map[string]string, len(picked))
for _, svc := range picked {
fields, err := comm.ResolveSvcPlainFields(svc, ovrOf(svc), encKey)
ovr := ovrOf(svc)
fields, err := comm.ResolveSvcPlainFields(svc, ovr, encKey)
if err != nil {
log.Warnf("第三方服务下发: 服务 %s 字段解析失败已跳过: %v", svc.Id, err)
continue
@ -93,11 +107,58 @@ func resolveThirdSvcs(app string, region pb.Region, ids []string) ([]*pb.ThirdSv
Categories: svc.Categories,
Fields: fields,
})
if e := comm.ResolveSvcEnvKeys(svc, ovr, fields); len(e) > 0 {
envById[svc.Id] = e
}
}
sort.Slice(items, func(i, j int) bool { return items[i].Id < items[j].Id })
return items, nil
return items, envById, nil
}
// svcEnvOverlay 把「按服务分桶的 env 覆盖层」压平成一张表,只取 items 里还在的服务
// (即已经过 filterSvcsByVip 的那一份)。items 按 id 升序,故同名 EnvKey 由 id 大的服务胜出,
// 结果可复现;同名冲突本身是配置错误,后台保存时会拦(见 console 的 env_key 查重)。
func svcEnvOverlay(items []*pb.ThirdSvcItem, envById map[string]map[string]string) map[string]string {
env := make(map[string]string, len(envById)*4)
for _, it := range items {
for k, v := range envById[it.Id] {
env[k] = v
}
}
return env
}
// appEnvForUser 合成下发给客户端的环境变量表 env:
//
// 应用自有配置(业务库 config 表) 打底 ← 第三方服务配置(svc_config) 的 env_key 覆盖
//
// 这取代了 2026-09-12 之前的「global_config 按区域覆盖 app_config」。旧法的问题是凭据同时存在
// 两张扁平表里、且**全局表悄悄赢**:同一个 AZURE_SPEECH_REGION / OPENAI_API_KEY / XUNFEI 密钥
// 在两边值不一样,后台在「应用环境配置」里改了半天,实际生效的一直是另一张表,且不报任何错。
// 现在凭据只有 svc_config 一个出处,区域差异由它自己的 svc_region_override 表达。
//
// env 是给**存量客户端**的兼容层:新客户端直接读 thirdsvcs(结构化、带类别),
// 老客户端继续读 env[KEY],两者同源,不会再出现两份值打架。
//
// 解析失败只记日志、退回「只有应用自有配置」:env 少几个键表现为对应能力鉴权失败,
// 而整个 user_getappconfig 报错会让客户端连产品表/智能体都拿不到,直接白屏。
func appEnvForUser(uid string, config []*pb.DBAppConfigItem, region pb.Region) map[string]string {
env := make(map[string]string, len(config)+16)
for _, v := range config {
env[v.Key] = v.Value
}
items, envById, err := resolveThirdSvcsWithEnv(comm.AppName(), region, nil)
if err != nil {
log.Warnf("下发 env: 第三方服务解析失败,本次只下发应用自有配置: %v", err)
return env
}
// 与 thirdsvcs 用同一份 VIP 过滤结果——否则 VIP 过期的用户拿不到 AST 条目,
// 却仍能从 env 里读出同一套凭据。
for k, v := range svcEnvOverlay(filterSvcsByVip(uid, items), envById) {
env[k] = v
}
return env
}
// filterSvcsByVip VIP 过期时把端到端翻译类(SvcCatAST)服务从下发列表里去掉。
//

Loading…
Cancel
Save