From 268ce26bc93d83b073748c8261df22671985c146 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Sun, 13 Sep 2026 00:28:09 +0800 Subject: [PATCH] =?UTF-8?q?services,admin:=20=E5=87=AD=E6=8D=AE=E5=BD=92?= =?UTF-8?q?=E4=B8=80=E5=88=B0=20svc=5Fconfig=EF=BC=9B=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E9=85=8D=E7=BD=AE=E8=8F=9C=E5=8D=95=E6=94=B6?= =?UTF-8?q?=E6=95=9B=EF=BC=9B=E5=93=81=E7=89=8C=E5=95=86=E4=B8=8E=E5=85=AC?= =?UTF-8?q?=E7=A0=81=E4=B8=8B=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这一提交里有两条相对独立的线,落在同一批文件上(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) --- CLAUDE.md | 581 +++++++- apps/admin/app/components/AppEnvTab.vue | 264 ++++ apps/admin/app/pages/accounts.vue | 178 +-- apps/admin/app/pages/agents.vue | 1206 ----------------- apps/admin/app/pages/appconfig.vue | 312 +---- apps/admin/app/pages/calltranslate.vue | 622 --------- apps/admin/app/pages/code.vue | 445 ------ apps/admin/app/pages/dashboard.vue | 20 +- apps/admin/app/pages/device.vue | 33 +- apps/admin/app/pages/environments.vue | 2 +- apps/admin/app/pages/factory.vue | 354 ----- apps/admin/app/pages/globalconfig.vue | 236 +--- apps/admin/app/pages/meetingsvc.vue | 629 --------- apps/admin/app/pages/serviceconfig.vue | 45 +- apps/admin/app/stores/auth.ts | 23 +- apps/admin/app/utils/menus.ts | 43 +- apps/services/comm/const.go | 4 +- apps/services/comm/nats.go | 2 +- apps/services/comm/svcpool.go | 55 +- apps/services/modules/api/api_addbrand.go | 57 - .../modules/api/api_addglobalconfigs.go | 28 - apps/services/modules/api/api_addproduct.go | 30 +- apps/services/modules/api/api_channel.go | 22 +- .../modules/api/api_createauthcodes.go | 8 +- .../api/api_createfactorypubliccode.go | 87 -- apps/services/modules/api/api_delbrand.go | 24 - .../modules/api/api_delfactorypubliccode.go | 29 - .../modules/api/api_delglobalconfigs.go | 27 - apps/services/modules/api/api_getbrand.go | 28 - apps/services/modules/api/api_getbrands.go | 47 - .../modules/api/api_getfactorypubliccodes.go | 23 - .../modules/api/api_getglobalconfig.go | 27 - apps/services/modules/api/api_updatebrand.go | 53 - .../api/api_updatefactorypubliccode.go | 51 - .../modules/api/api_updateglobalconfig.go | 27 - apps/services/modules/api/core.go | 12 +- .../modules/api/interceptor_permission.go | 13 - apps/services/modules/api/model.go | 114 +- apps/services/modules/console/api_account.go | 67 +- apps/services/modules/console/api_agent.go | 357 ----- .../modules/console/api_agent_test.go | 58 - .../modules/console/api_calltranslate.go | 249 ---- apps/services/modules/console/api_channel.go | 106 +- apps/services/modules/console/api_config.go | 96 +- apps/services/modules/console/api_device.go | 182 +-- .../modules/console/api_echomeet_orch.go | 324 ----- .../modules/console/api_settlement.go | 12 +- .../services/modules/console/api_svcconfig.go | 47 +- .../modules/console/api_svctemplate.go | 52 +- .../modules/console/api_svctemplate_test.go | 34 + apps/services/modules/console/core.go | 4 +- .../modules/console/migrate_envtosvc.go | 629 +++++++++ .../modules/console/migrate_envtosvc_test.go | 191 +++ .../services/modules/console/model_account.go | 40 +- apps/services/modules/console/model_device.go | 80 +- .../modules/console/model_device_cache.go | 40 +- .../modules/console/model_registry.go | 8 + apps/services/modules/console/registry.go | 37 +- apps/services/modules/console/scope.go | 29 +- apps/services/modules/console/scope_app.go | 34 +- .../services/modules/console/scope_channel.go | 91 +- apps/services/modules/console/server.go | 267 +--- .../services/modules/user/api_bindauthcode.go | 185 --- apps/services/modules/user/api_binddevice.go | 3 +- .../services/modules/user/api_getappconfig.go | 16 +- .../modules/user/api_v2_getappconfig.go | 16 +- .../modules/user/api_v3_getappconfig.go | 49 +- apps/services/modules/user/model_config.go | 43 +- apps/services/modules/user/model_user.go | 41 - apps/services/modules/user/module_test.go | 137 +- apps/services/modules/user/svcresolve.go | 71 +- 71 files changed, 2355 insertions(+), 7001 deletions(-) create mode 100644 apps/admin/app/components/AppEnvTab.vue delete mode 100644 apps/admin/app/pages/agents.vue delete mode 100644 apps/admin/app/pages/calltranslate.vue delete mode 100644 apps/admin/app/pages/code.vue delete mode 100644 apps/admin/app/pages/factory.vue delete mode 100644 apps/admin/app/pages/meetingsvc.vue delete mode 100644 apps/services/modules/api/api_addbrand.go delete mode 100644 apps/services/modules/api/api_addglobalconfigs.go delete mode 100644 apps/services/modules/api/api_createfactorypubliccode.go delete mode 100644 apps/services/modules/api/api_delbrand.go delete mode 100644 apps/services/modules/api/api_delfactorypubliccode.go delete mode 100644 apps/services/modules/api/api_delglobalconfigs.go delete mode 100644 apps/services/modules/api/api_getbrand.go delete mode 100644 apps/services/modules/api/api_getbrands.go delete mode 100644 apps/services/modules/api/api_getfactorypubliccodes.go delete mode 100644 apps/services/modules/api/api_getglobalconfig.go delete mode 100644 apps/services/modules/api/api_updatebrand.go delete mode 100644 apps/services/modules/api/api_updatefactorypubliccode.go delete mode 100644 apps/services/modules/api/api_updateglobalconfig.go delete mode 100644 apps/services/modules/console/api_agent.go delete mode 100644 apps/services/modules/console/api_agent_test.go delete mode 100644 apps/services/modules/console/api_calltranslate.go delete mode 100644 apps/services/modules/console/api_echomeet_orch.go create mode 100644 apps/services/modules/console/migrate_envtosvc.go create mode 100644 apps/services/modules/console/migrate_envtosvc_test.go delete mode 100644 apps/services/modules/user/api_bindauthcode.go diff --git a/CLAUDE.md b/CLAUDE.md index 1e12eba5..f6a486e7 100644 --- a/CLAUDE.md +++ b/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//` 或出现 `BesBluetoothService`/`HolderDeviceService`/`package:bluetooth_manager` 的文件。 +- **新接一种芯片**:① `lib/devices//` 写一个 `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 用 `../../..//ios/` 引用兄弟插件, + 而 Flutter 把插件软链在 `ios/Flutter/ephemeral/Packages/.packages/` 下,SPM 不解析软链,`rm -rf ios/Flutter/ephemeral` + 之后必然报 "the package at 'ephemeral//…' 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//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 -n 1 HGET ':cache:product' '' ### 设备确权:连上就校验 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//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 [action]` 已删除(只指向灵谱、生产位留空),统一走 `dev-deploy.sh` / `prod-build.sh` / `prod-deploy.sh`。 ## 迁移上下文 diff --git a/apps/admin/app/components/AppEnvTab.vue b/apps/admin/app/components/AppEnvTab.vue new file mode 100644 index 00000000..1a9e5488 --- /dev/null +++ b/apps/admin/app/components/AppEnvTab.vue @@ -0,0 +1,264 @@ + + + diff --git a/apps/admin/app/pages/accounts.vue b/apps/admin/app/pages/accounts.vue index 03c0ba61..014070ab 100644 --- a/apps/admin/app/pages/accounts.vue +++ b/apps/admin/app/pages/accounts.vue @@ -32,7 +32,7 @@ 停用 - 全部权限 -
- 余额 VIP{{ a.balance_vipday || 0 }}天 · 翻译{{ a.balance_trademin || 0 }}分 · 会议{{ a.balance_meetmin || 0 }}分 -
{{ a.remark || '' }}
- @@ -80,7 +76,6 @@ - - - -
-

- 数据隔离由后端强制:品牌商账号只能看到本品牌商及其名下渠道商的数据,渠道商账号只能看到自己渠道的数据。 + 数据隔离由后端强制:渠道商账号只能看到自己渠道的数据。

-
+
作用域绑定 (代理/运营生效;限制仪表盘数据范围)
@@ -172,17 +160,14 @@
-
绑定产品 (按厂家分组;仪表盘只显示这些产品数据;不勾=不限)
+
绑定产品 (仪表盘只显示这些产品数据;不勾=不限)
加载产品中... - 无产品 -
-
🏭 {{ g.label }}
-
- -
+ 无产品 +
+
@@ -194,24 +179,6 @@
- - - - - - @@ -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 = { 1: '超管', 2: '管理员', 3: '品牌商', 4: '运营', 5: '渠道商' } -const ROLE_CLS: Record = { 1: 'badge-error', 2: 'badge-primary', 3: 'badge-warning', 4: 'badge-info', 5: 'badge-accent' } +// 3=品牌商 已于 2026-09-12 下线:不再出现在角色下拉里,但存量行可能还有(后端已禁用并降级), +// 所以标签表里保留一条,免得列表显示成裸数字。 +const ROLE: Record = { 1: '超管', 2: '管理员', 3: '品牌商(已下线)', 4: '运营', 5: '渠道商' } +const ROLE_CLS: Record = { 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(() => ROLE_ACCESS_CEILING[form.identity] ?? null) const allowedMenuIds = computed(() => 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([]) -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([]) -const productGroups = ref([]) +const productOptions = ref([]) 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 = {} - facs.forEach((b) => { facName[b.id] = b.brand || ('品牌商#' + b.id) }) - const groups: Record = {} - 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,23 +357,18 @@ 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 - } + if (idt === 5) { + if (!form.channelid) { error('请选择所属渠道商'); return } + body.channelid = form.channelid } submitting.value = true try { @@ -469,45 +408,10 @@ function delAccount(id: number, name: string) { .catch((e: any) => error(e?.message ?? '失败')) } -// ── 代理充值 / 调整资源点 ── -const topupModal = ref(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) diff --git a/apps/admin/app/pages/agents.vue b/apps/admin/app/pages/agents.vue deleted file mode 100644 index 6c5b3fda..00000000 --- a/apps/admin/app/pages/agents.vue +++ /dev/null @@ -1,1206 +0,0 @@ - diff --git a/apps/admin/app/pages/calltranslate.vue b/apps/admin/app/pages/calltranslate.vue deleted file mode 100644 index 1faf2783..00000000 --- a/apps/admin/app/pages/calltranslate.vue +++ /dev/null @@ -1,622 +0,0 @@ - - - - - diff --git a/apps/admin/app/pages/code.vue b/apps/admin/app/pages/code.vue deleted file mode 100644 index 9d193824..00000000 --- a/apps/admin/app/pages/code.vue +++ /dev/null @@ -1,445 +0,0 @@ - - - diff --git a/apps/admin/app/pages/dashboard.vue b/apps/admin/app/pages/dashboard.vue index 9863c084..c44cc338 100644 --- a/apps/admin/app/pages/dashboard.vue +++ b/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}` } diff --git a/apps/admin/app/pages/device.vue b/apps/admin/app/pages/device.vue index 8991163f..50afd7cc 100644 --- a/apps/admin/app/pages/device.vue +++ b/apps/admin/app/pages/device.vue @@ -365,7 +365,6 @@
生产批次(BatchNo):{{ preview.batchno || '生产时分配' }}
-
品牌商:{{ preview.brandname || '-' }}(无品牌归属,由所选渠道商推出)
渠道商:{{ preview.channelname }}({{ preview.channelid }})
渠道分成比例:{{ (Number(cSharepct) || 0).toFixed(2) }}%
代工厂:{{ nameOfFactory(cFactory) }}
@@ -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([]) const channels = ref([]) // 全量渠道商(含未启用),前端按品牌商过滤 // 全量代工厂(含已停用):列表列名要能显示历史批次里已停用的工厂,生产下拉再单独筛 enabled const factories = ref([]) @@ -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(',') : '' diff --git a/apps/admin/app/pages/environments.vue b/apps/admin/app/pages/environments.vue index 5d1555dd..3c34c45c 100644 --- a/apps/admin/app/pages/environments.vue +++ b/apps/admin/app/pages/environments.vue @@ -75,7 +75,7 @@
- +
diff --git a/apps/admin/app/pages/factory.vue b/apps/admin/app/pages/factory.vue deleted file mode 100644 index 2a12aaf5..00000000 --- a/apps/admin/app/pages/factory.vue +++ /dev/null @@ -1,354 +0,0 @@ - - - diff --git a/apps/admin/app/pages/globalconfig.vue b/apps/admin/app/pages/globalconfig.vue index b79469bd..4932aefe 100644 --- a/apps/admin/app/pages/globalconfig.vue +++ b/apps/admin/app/pages/globalconfig.vue @@ -1,234 +1,16 @@ diff --git a/apps/admin/app/pages/meetingsvc.vue b/apps/admin/app/pages/meetingsvc.vue deleted file mode 100644 index 7127d4b1..00000000 --- a/apps/admin/app/pages/meetingsvc.vue +++ /dev/null @@ -1,629 +0,0 @@ - - - - - diff --git a/apps/admin/app/pages/serviceconfig.vue b/apps/admin/app/pages/serviceconfig.vue index 6d83dfdb..245bd56d 100644 --- a/apps/admin/app/pages/serviceconfig.vue +++ b/apps/admin/app/pages/serviceconfig.vue @@ -1,13 +1,26 @@
+
@@ -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 || '' diff --git a/apps/admin/app/stores/auth.ts b/apps/admin/app/stores/auth.ts index e5f8adeb..dde8c5da 100644 --- a/apps/admin/app/stores/auth.ts +++ b/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 = { 1: '超管', 2: '管理员', - 3: '品牌商', 4: '运营', 5: '渠道商', } @@ -12,7 +11,8 @@ export const IDENTITY_LABELS: Record = { // 身份常量(与后端 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 diff --git a/apps/admin/app/utils/menus.ts b/apps/admin/app/utils/menus.ts index 15f4443c..7b88802f 100644 --- a/apps/admin/app/utils/menus.ts +++ b/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 = { - 3: DEFAULT_BRAND_ACCESS, 5: DEFAULT_DEALER_ACCESS, } diff --git a/apps/services/comm/const.go b/apps/services/comm/const.go index 71d66a34..9cab4e0b 100644 --- a/apps/services/comm/const.go +++ b/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 加密) diff --git a/apps/services/comm/nats.go b/apps/services/comm/nats.go index 6e1b61d4..b00ce96b 100644 --- a/apps/services/comm/nats.go +++ b/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" // 依赖服务配置(业务侧重载字段缓存) diff --git a/apps/services/comm/svcpool.go b/apps/services/comm/svcpool.go index 035dd84e..88e96943 100644 --- a/apps/services/comm/svcpool.go +++ b/apps/services/comm/svcpool.go @@ -19,16 +19,17 @@ import ( // SvcField 第三方服务的自定义字段项(内嵌于 ThirdSvcConfig.fields jsonb 列,也复用于模板/区域专属字段)。 // 加密字段:DefValue 存 AES-GCM 密文;console API 对加密字段只返回 "[encrypted]" 占位。 type SvcField struct { - Key string `json:"key"` // 字段名,同服务内唯一,如 secret_key - Description string `json:"description"` // 字段用途描述,前端展示 - Type string `json:"type"` // 字段类型:空/text 普通文本;langs 支持语言多选(值=逗号分隔 BCP-47);lang 单选默认语言 - Encrypted bool `json:"encrypted"` // 是否加密存储 - DefValue string `json:"def_value"` // 默认值(全区域共用;加密字段存 AES-GCM 密文) - Sort int32 `json:"sort"` // 展示排序,越小越靠前 + Key string `json:"key"` // 字段名,同服务内唯一,如 secret_key + Description string `json:"description"` // 字段用途描述,前端展示 + Type string `json:"type"` // 字段类型:空/text 普通文本;langs 支持语言多选(值=逗号分隔 BCP-47);lang 单选默认语言 + 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 字段 diff --git a/apps/services/modules/api/api_addbrand.go b/apps/services/modules/api/api_addbrand.go deleted file mode 100644 index 72bc7193..00000000 --- a/apps/services/modules/api/api_addbrand.go +++ /dev/null @@ -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, ",") -} diff --git a/apps/services/modules/api/api_addglobalconfigs.go b/apps/services/modules/api/api_addglobalconfigs.go deleted file mode 100644 index c7d51a88..00000000 --- a/apps/services/modules/api/api_addglobalconfigs.go +++ /dev/null @@ -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 -} diff --git a/apps/services/modules/api/api_addproduct.go b/apps/services/modules/api/api_addproduct.go index 484a0f00..50d9739c 100644 --- a/apps/services/modules/api/api_addproduct.go +++ b/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()}) } diff --git a/apps/services/modules/api/api_channel.go b/apps/services/modules/api/api_channel.go index d8efb85f..fb7dd714 100644 --- a/apps/services/modules/api/api_channel.go +++ b/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} } diff --git a/apps/services/modules/api/api_createauthcodes.go b/apps/services/modules/api/api_createauthcodes.go index e81bd5ee..a20e713a 100644 --- a/apps/services/modules/api/api_createauthcodes.go +++ b/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 } diff --git a/apps/services/modules/api/api_createfactorypubliccode.go b/apps/services/modules/api/api_createfactorypubliccode.go deleted file mode 100644 index 05402b36..00000000 --- a/apps/services/modules/api/api_createfactorypubliccode.go +++ /dev/null @@ -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 -} diff --git a/apps/services/modules/api/api_delbrand.go b/apps/services/modules/api/api_delbrand.go deleted file mode 100644 index 1126e500..00000000 --- a/apps/services/modules/api/api_delbrand.go +++ /dev/null @@ -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 -} diff --git a/apps/services/modules/api/api_delfactorypubliccode.go b/apps/services/modules/api/api_delfactorypubliccode.go deleted file mode 100644 index f65f3ac3..00000000 --- a/apps/services/modules/api/api_delfactorypubliccode.go +++ /dev/null @@ -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 -} diff --git a/apps/services/modules/api/api_delglobalconfigs.go b/apps/services/modules/api/api_delglobalconfigs.go deleted file mode 100644 index c9b81860..00000000 --- a/apps/services/modules/api/api_delglobalconfigs.go +++ /dev/null @@ -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 -} diff --git a/apps/services/modules/api/api_getbrand.go b/apps/services/modules/api/api_getbrand.go deleted file mode 100644 index 6c9e9c84..00000000 --- a/apps/services/modules/api/api_getbrand.go +++ /dev/null @@ -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 -} diff --git a/apps/services/modules/api/api_getbrands.go b/apps/services/modules/api/api_getbrands.go deleted file mode 100644 index 977b7791..00000000 --- a/apps/services/modules/api/api_getbrands.go +++ /dev/null @@ -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 -} diff --git a/apps/services/modules/api/api_getfactorypubliccodes.go b/apps/services/modules/api/api_getfactorypubliccodes.go deleted file mode 100644 index cce90e52..00000000 --- a/apps/services/modules/api/api_getfactorypubliccodes.go +++ /dev/null @@ -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 -} diff --git a/apps/services/modules/api/api_getglobalconfig.go b/apps/services/modules/api/api_getglobalconfig.go deleted file mode 100644 index ec4aa347..00000000 --- a/apps/services/modules/api/api_getglobalconfig.go +++ /dev/null @@ -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 -} diff --git a/apps/services/modules/api/api_updatebrand.go b/apps/services/modules/api/api_updatebrand.go deleted file mode 100644 index 20c66549..00000000 --- a/apps/services/modules/api/api_updatebrand.go +++ /dev/null @@ -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 -} diff --git a/apps/services/modules/api/api_updatefactorypubliccode.go b/apps/services/modules/api/api_updatefactorypubliccode.go deleted file mode 100644 index 17964547..00000000 --- a/apps/services/modules/api/api_updatefactorypubliccode.go +++ /dev/null @@ -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 -} diff --git a/apps/services/modules/api/api_updateglobalconfig.go b/apps/services/modules/api/api_updateglobalconfig.go deleted file mode 100644 index f1fa09d2..00000000 --- a/apps/services/modules/api/api_updateglobalconfig.go +++ /dev/null @@ -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 -} diff --git a/apps/services/modules/api/core.go b/apps/services/modules/api/core.go index e0841609..f0565f86 100644 --- a/apps/services/modules/api/core.go +++ b/apps/services/modules/api/core.go @@ -19,12 +19,9 @@ var auditableAPIMap = map[string]bool{ "api_deladminuser": true, "api_updateadminuser": true, // 配置管理 - "api_addconfig": true, - "api_updateconfig": true, - "api_delconfig": true, - "api_addglobalconfigs": true, - "api_updateglobalconfig": true, - "api_delglobalconfigs": true, + "api_addconfig": true, + "api_updateconfig": true, + "api_delconfig": 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, diff --git a/apps/services/modules/api/interceptor_permission.go b/apps/services/modules/api/interceptor_permission.go index b97669b7..15ca0dd6 100644 --- a/apps/services/modules/api/interceptor_permission.go +++ b/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", diff --git a/apps/services/modules/api/model.go b/apps/services/modules/api/model.go index 978e4ed7..421e4735 100644 --- a/apps/services/modules/api/model.go +++ b/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_ 分表(见下方按产品建表的循环), // 不带后缀的那张从来没有代码读写,建出来只是每个环境多一张恒空表。 - 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) { diff --git a/apps/services/modules/console/api_account.go b/apps/services/modules/console/api_account.go index fc6ad89a..49b471c7 100644 --- a/apps/services/modules/console/api_account.go +++ b/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 { diff --git a/apps/services/modules/console/api_agent.go b/apps/services/modules/console/api_agent.go deleted file mode 100644 index 435864d0..00000000 --- a/apps/services/modules/console/api_agent.go +++ /dev/null @@ -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}) -} diff --git a/apps/services/modules/console/api_agent_test.go b/apps/services/modules/console/api_agent_test.go deleted file mode 100644 index 01386d78..00000000 --- a/apps/services/modules/console/api_agent_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/apps/services/modules/console/api_calltranslate.go b/apps/services/modules/console/api_calltranslate.go deleted file mode 100644 index d9b9d098..00000000 --- a/apps/services/modules/console/api_calltranslate.go +++ /dev/null @@ -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) -} diff --git a/apps/services/modules/console/api_channel.go b/apps/services/modules/console/api_channel.go index ce5e23d7..dc957e34 100644 --- a/apps/services/modules/console/api_channel.go +++ b/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) } diff --git a/apps/services/modules/console/api_config.go b/apps/services/modules/console/api_config.go index 72f60087..fbf1a7a8 100644 --- a/apps/services/modules/console/api_config.go +++ b/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 一次性迁移后停止读写(仅保留兜底)。 diff --git a/apps/services/modules/console/api_device.go b/apps/services/modules/console/api_device.go index 963a1788..ea641203 100644 --- a/apps/services/modules/console/api_device.go +++ b/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, diff --git a/apps/services/modules/console/api_echomeet_orch.go b/apps/services/modules/console/api_echomeet_orch.go deleted file mode 100644 index 516667f3..00000000 --- a/apps/services/modules/console/api_echomeet_orch.go +++ /dev/null @@ -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}) -} diff --git a/apps/services/modules/console/api_settlement.go b/apps/services/modules/console/api_settlement.go index a487bc89..9b0615a0 100644 --- a/apps/services/modules/console/api_settlement.go +++ b/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()) diff --git a/apps/services/modules/console/api_svcconfig.go b/apps/services/modules/console/api_svcconfig.go index 85af1b74..9a0a364b 100644 --- a/apps/services/modules/console/api_svcconfig.go +++ b/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() diff --git a/apps/services/modules/console/api_svctemplate.go b/apps/services/modules/console/api_svctemplate.go index 10f32f46..56d1e19a 100644 --- a/apps/services/modules/console/api_svctemplate.go +++ b/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"}, + "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...) diff --git a/apps/services/modules/console/api_svctemplate_test.go b/apps/services/modules/console/api_svctemplate_test.go index 2d54c63a..64f6fa18 100644 --- a/apps/services/modules/console/api_svctemplate_test.go +++ b/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("未声明废弃字段的模板不该动任何字段") + } + }) +} diff --git a/apps/services/modules/console/core.go b/apps/services/modules/console/core.go index e6177b12..360a73f2 100644 --- a/apps/services/modules/console/core.go +++ b/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 } diff --git a/apps/services/modules/console/migrate_envtosvc.go b/apps/services/modules/console/migrate_envtosvc.go new file mode 100644 index 00000000..2c6a1b74 --- /dev/null +++ b/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, ", ")) +} diff --git a/apps/services/modules/console/migrate_envtosvc_test.go b/apps/services/modules/console/migrate_envtosvc_test.go new file mode 100644 index 00000000..bea44bdf --- /dev/null +++ b/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("键根本不存在时不算覆盖") + } +} diff --git a/apps/services/modules/console/model_account.go b/apps/services/modules/console/model_account.go index 3eaa85aa..fc115889 100644 --- a/apps/services/modules/console/model_account.go +++ b/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 diff --git a/apps/services/modules/console/model_device.go b/apps/services/modules/console/model_device.go index 595755ee..7d70311a 100644 --- a/apps/services/modules/console/model_device.go +++ b/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 失败而序号游标已经推进过。 // diff --git a/apps/services/modules/console/model_device_cache.go b/apps/services/modules/console/model_device_cache.go index 9d9724bc..aac5e2ec 100644 --- a/apps/services/modules/console/model_device_cache.go +++ b/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 } diff --git a/apps/services/modules/console/model_registry.go b/apps/services/modules/console/model_registry.go index d4fe751d..eff6ee3f 100644 --- a/apps/services/modules/console/model_registry.go +++ b/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 } diff --git a/apps/services/modules/console/registry.go b/apps/services/modules/console/registry.go index 3e5bcdd3..84cfcfca 100644 --- a/apps/services/modules/console/registry.go +++ b/apps/services/modules/console/registry.go @@ -191,19 +191,20 @@ 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 { - return res.Error - } + // 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 只是归属列。 diff --git a/apps/services/modules/console/scope.go b/apps/services/modules/console/scope.go index 419f783a..14749f18 100644 --- a/apps/services/modules/console/scope.go +++ b/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) diff --git a/apps/services/modules/console/scope_app.go b/apps/services/modules/console/scope_app.go index 64e3fbe3..26478668 100644 --- a/apps/services/modules/console/scope_app.go +++ b/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 } diff --git a/apps/services/modules/console/scope_channel.go b/apps/services/modules/console/scope_channel.go index cdc30112..930a03dd 100644 --- a/apps/services/modules/console/scope_channel.go +++ b/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, diff --git a/apps/services/modules/console/server.go b/apps/services/modules/console/server.go index 26f6dde6..4558c690 100644 --- a/apps/services/modules/console/server.go +++ b/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=''®ion=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 } } diff --git a/apps/services/modules/user/api_bindauthcode.go b/apps/services/modules/user/api_bindauthcode.go deleted file mode 100644 index e810b2ec..00000000 --- a/apps/services/modules/user/api_bindauthcode.go +++ /dev/null @@ -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 -} diff --git a/apps/services/modules/user/api_binddevice.go b/apps/services/modules/user/api_binddevice.go index cce7ae9d..385f8f34 100644 --- a/apps/services/modules/user/api_binddevice.go +++ b/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 diff --git a/apps/services/modules/user/api_getappconfig.go b/apps/services/modules/user/api_getappconfig.go index 8a0c1961..0034eec7 100644 --- a/apps/services/modules/user/api_getappconfig.go +++ b/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 { diff --git a/apps/services/modules/user/api_v2_getappconfig.go b/apps/services/modules/user/api_v2_getappconfig.go index bb1f03ed..991a8e05 100644 --- a/apps/services/modules/user/api_v2_getappconfig.go +++ b/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 { diff --git a/apps/services/modules/user/api_v3_getappconfig.go b/apps/services/modules/user/api_v3_getappconfig.go index fa07a79e..d3679eb2 100644 --- a/apps/services/modules/user/api_v3_getappconfig.go +++ b/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 { diff --git a/apps/services/modules/user/model_config.go b/apps/services/modules/user/model_config.go index 4cce14a2..365faba3 100644 --- a/apps/services/modules/user/model_config.go +++ b/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" ) @@ -26,12 +26,11 @@ type mcpEntry struct { // 代理模型 type modelConfigComp struct { cbase.ModuleCompBase - module *User - lock sync.RWMutex - config []*pb.DBAppConfigItem - globalconfig []*pb.DBGlobalConfigItem - agents []*pb.DBAgent - mcps []*mcpEntry + module *User + lock sync.RWMutex + config []*pb.DBAppConfigItem + 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 @@ -98,17 +98,13 @@ 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 + config []*pb.DBAppConfigItem + 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) diff --git a/apps/services/modules/user/model_user.go b/apps/services/modules/user/model_user.go index 6a7fbee5..75bebe95 100644 --- a/apps/services/modules/user/model_user.go +++ b/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 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)服务从下发列表里去掉。 //