# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## 项目概览 `yunyan-sas` 是一个从单产品服务 `deep_server`(Go 模块名 `earphone`)迁移而来的 **monorepo SaaS 平台**,目标是把单产品后端改造成可托管多个应用/产品的 SaaS。迁移时 Go 模块名 `earphone` → `yunyan`,所有 import 前缀随之变更。回复一律用中文。 ## 仓库结构 ``` yunyan-sas/ ├── go.work # Go workspace:use ./apps/services ├── build.sh # 构建/推送镜像(被 deploy.sh 调用,也可单用) ├── apps/ │ ├── proto/ # .proto 源文件(按业务域分目录) │ ├── services/ # Go 后端(module: yunyan, go 1.25.0) │ │ ├── lego/ # 内部服务框架(IService/IModule/Comp + sys 子系统) │ │ ├── sys/ # 第三方集成(aliyun/tencentyun/openai/coze/nats/...) │ │ ├── comm/ # 跨模块公共:常量、ErrorCode、IUserSession、NATS key │ │ ├── modules/ # 业务模块(每个 = 一组 Comp) │ │ ├── services/ # 各服务的 main.go 入口 + conf/*.yaml │ │ ├── pb/ # protoc 生成的 *.pb.go(DO NOT EDIT) │ │ ├── swagger/、utils/ │ ├── admin/ # 管理后台前端(Nuxt 4 SSR + Tailwind + DaisyUI 4 + Pinia) │ ├── client/ # 移动端 App(Flutter 3.41 + GetX,pubspec name: eaimar) │ └── web/ # 用户侧前端骨架(Nuxt 4) └── deploy// # 每服务一份 deploy.sh + docker-compose.yml + env/ ``` ## 常用命令 ### Go 后端(在 `apps/services/` 下执行) ```bash go build ./... # 编译全部 go vet ./... # 静态检查 go test ./... # 跑全部测试(测试集中在 sys/* 与 utils/) go test ./sys/sms/ -run TestXxx -v # 跑单个测试 go build -o /tmp/console ./services/console/ # 编译单个服务 go run ./services/console -conf ./services/console/conf/console.yaml # 本地起服务 ``` 服务列表:`api`、`gateway`、`home`、`mcp`、`migu`、`timer`(集群服务,走 ETCD/rpcx)、`console`(单例服务,不接 ETCD)。每个服务 `main.go` 用 `-conf` 指向自己的 `conf/*.yaml`,默认相对路径 `./conf/.yaml`。 ### Admin 前端(在 `apps/admin/` 下执行) ```bash pnpm install pnpm dev # 开发,默认 :3000 pnpm build # 产出 .output/(SSR) ``` 本地开发需同时起 console 后端(默认代理到 `127.0.0.1:8080`,见下文 proxy)。超管账号见各 `console.yaml` 的 `AdminAccount/AdminPassword`。 ### Client 移动端(在 `apps/client/` 下执行) ```bash cp .env.example .env # 首次必做,缺 .env 直接起不来(见下文) flutter pub get flutter run # 需先 `flutter devices` 确认真机/模拟器 flutter analyze flutter build apk --debug ./build_channels.sh # 多渠道打包(Android) ``` Flutter 3.41.5 stable(本机 `~/flutter`)。只支持 Android / iOS 两端(pubspec 的 `platforms:` 已锁死)。 iOS 首次还需 `cd ios && pod install`。 ### 构建镜像与部署 ```bash ./build.sh console # 构建 registry.voitrans.net/starpivot-console:latest(PUSH=0 只构建不推) ./build.sh admin # 测试环境发布(在 deploy/<服务>/ 下,服务 = app | console | admin) cd deploy/console && ./dev-deploy.sh along # 阿龙测试环境(阿里云 8.133.166.29);目前只有这一个环境 # 生产:先构建带版本号的镜像,再部署 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`(云雁自有仓库;**只用于推镜像,本仓库已无云雁的部署目标**) - `lingpu` → `registry.lingpu.net`(灵谱自有仓库;同上) - `along` → `registry.ymaikj.com`(仓库实体就在阿龙测试机 8.133.166.29 上,**账号是 `admin` 不是 `liwei`**);阿龙测试机与正式机 47.116.104.181 都从这里拉镜像 测试环境档案在各 `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 共用这一份,别在各脚本里写死。 compose 的 image 由脚本注入 `IMAGE_NAME` 变量,同样不写死。 ⚠️ app 的旧名 `voitrans-a11` 已于 2026-08-10 废弃,仓库里该名下的旧 tag(如 0.1.2)**不会自动搬到新名**, 换名后首次上线要重新 `./prod-build.sh ` 推一版;服务器上手工维护的 `docker-compose.yml` 也要同步成新模板(脚本只下发 `.example`,不覆盖真实文件)。 注意 `voitrans-a11` 这个串还用在**别的语义**上,那些不能动:`confs/*.yaml` 的 `CLUSTER_TAG`(etcd 集群标签, 改了服务互相发现不到)、`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` 的写法),**只有 `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()`),**也只剩 `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 时落错目录、还和已有容器撞名,已移除; 阿龙机上那份旧的 `/home/work/starpivot/app` 部署保持原样、不再由脚本管理)。容器名由应用 key 推导为 `<应用>-a11`, 经 `CONTAINER_NAME`(同时作 `COMPOSE_PROJECT_NAME`)注入 `docker-compose.yml` 的 `container_name: ${CONTAINER_NAME:-starpivot-app}`, 部署后写回服务器 `.env`。compose 里的 service 名固定为 `app`,别写成具体应用名。 dev(`dev-deploy.sh`)仍是每台机单份部署,走默认容器名 `starpivot-app`;admin/console 的 compose 也尚未参数化。 `docker-compose.yml` 由 `prod-deploy.sh` **按应用现生成再下发**:模板里 service 名是占位的 `app`,下发时 `sed` 改写成本应用名(`ym` → `ym-a11:`),于是服务器上 service 名 = 容器名 = `<应用>-a11`。 下发前与远端文件逐字节比对,一致就跳过、不一致才备份 `.bak` 后覆盖(纯"存在就不碰"会让旧 compose 永不更新—— ym 首次部署容器一直叫 `starpivot-app` 正是如此)。service 名变更后旧容器占着 container_name, 远端脚本会按 compose 标签识别并 `docker rm -f` 掉再重建。服务器上只需维护 `.env` 与 `confs/*.yaml`。 **新应用首次部署 checklist**(2026-08-18 部署 ym 时逐条踩出来的): 1. `confs/home.yaml` 的 `wordfilter.WorldFile` 若列了词库 txt,服务器上必须真有这些文件,否则 home `panic: init sys.wordfilter err: no found file:...` → entrypoint 杀容器 → 无限重启。词库不在 git 里, 不需要就保持注释(模板已默认注释)。**表象是 api 报 `Table '<库>.userdevice' doesn't exist`**—— userdevice/user 等表由 home 里的 user 模块启动时 `CreateTable` 建,home 起不来就永远没表,别去查数据库,先看 panic。 2. `confs/ip2region_v4.xdb` / `v6.xdb`:`sys.ipinfo` 启动必需,缺了同样 panic。现在 prod-deploy 会自动下发(仅远端缺失时)。 3. `.env` 别照抄模板:`CLUSTER_TAG`、`ANALYZE_APP_NAME`、`REDIS_KEY_PREFIX` 都要换成本应用的身份, 其中 `ANALYZE_APP_NAME` 必须与 `app_registry` 的 **`name` 与 `app_name` 两列同时**相等(大小写敏感), 所以登记应用时就要把这两列填成同一个值。两列不同值时填哪个都只对一半: 填 `app_name` → 作用域配置能读到、**统计快照被 console 拒收**;填 `name` → 统计能落库、按应用作用域的配置全部退化成全局默认。 拒收时只在 console 日志留一行 `console.stat: 快照应用未在 console 注册,未落库`, 数据滞留在业务侧 Redis,看板恒为 0 且**不报任何错**——排查「看板没数据」先看这行日志,别去查埋点。 判定点在 [model_registry.go](apps/services/modules/console/model_registry.go) 的 `getAppByName`(查的是 `name=?`)。 4. console 后台要先把该应用登记进 `app_registry`(阿龙正式机 2026-08-18 时该表还是空的)。 `dev-deploy.sh <环境>` 只做镜像部署:构建推送 → 远端 pull 重启,**不上传** 服务器上的真实 `.env` / `confs/*.yaml`(只下发 `*.example` 模板)。 ## 后端架构(lego 框架) 整套后端围绕 `lego` 自研框架,核心是 **Service → Module → Comp** 三层 + 反射注册路由。 - **启动**:`lego.Run(service, modules...)`([lego/lego.go](apps/services/lego/lego.go))依次 `Init → Start → Run → Destroy`。 - **两种 Service 基类**: - `lego/base/rpcx`(集群服务):接 ETCD 做服务发现,服务间用 rpcx RPC 互调。`api/gateway/home/mcp/migu/timer` 用它,且 `OnInstallComp` 装 `services.NewHttpRouteComp()`。 - `lego/base/single`(单例服务):**不接 ETCD/集群**,只驱动单个模块。`console` 用它,主库 Supabase Postgres + 独立 Redis + NATS,维护 `app_registry` 注册表,按"选中应用"动态建到目标应用库的连接。 - **Module → Comp**:模块(如 `modules/api`)在 `OnInstallComp` 里 `RegisterComp` 一组组件:`apiComp`(HTTP 入口)、`modelComp`(数据访问)、各种 consumer/interceptor。见 [modules/api/module.go](apps/services/modules/api/module.go)。 - **HTTP 路由靠反射自动注册**([modules/comp_httpgate.go](apps/services/modules/comp_httpgate.go)):继承 `modules.MCompHttpGate` 的组件,其**每个导出方法**只要签名是 ```go func (c *apiComp) MethodName(session comm.IUserSession, req *pb.XxxReq) (*pb.XxxResp, *pb.ErrorData) ``` 就会被注册成路由 `_`(如 `api_getfactorys`)。新增接口 = 新建 `modules//api_xxx.go` 加一个符合签名的方法即可,**无需手动注册**。后缀 `Check` 的方法不注册。 - **错误处理约定**:成功填 `resp`;失败构造 `&pb.ErrorData{Code: pb.ErrorCode_XXX, Message: ...}` 并 `return`。 - **pb 包**:`pb/*.pb.go` 由 `apps/proto/` 下 `.proto` 经 `protoc` + `protoc-gen-go` **v1.36.6** 生成 (`cd apps/proto && python3 pb.py`,需 `~/go/bin` 在 PATH 里)。 ⚠️ **别照 go.mod 的 `google.golang.org/protobuf v1.36.2` 去装生成器**——那是运行时库版本, 生成器版本写在每个 `.pb.go` 头部(`protoc-gen-go v1.36.6`)。装错版本会让 `pb.py` 把 **全部 22 个文件**降版重写,产出几千行与本次改动无关的 diff。生成后务必 `git diff --stat -- apps/services/pb/` 确认只动了你改的那几个,**手动改 .pb.go 无效**;改协议要改 `.proto` 再重新生成。注意 console 模块有本地 gorm struct(非 pb)的 model 文件,见 `modules/console/model_*.go`。 ## Admin 前端约定(关键,易踩坑) - **API 代理**:[server/middleware/proxy.ts](apps/admin/server/middleware/proxy.ts) 把 `/web/api/*` 和 `/console/api/*` 代理到 `CONSOLE_BACKEND`(容器内由 env 注入,本地默认 `http://127.0.0.1:8080`)。 - **`useApi().webApi(method, data)`**([app/composables/useApi.ts](apps/admin/app/composables/useApi.ts)): - 已自动拼成 `/web/api/api_${method}` 并**已解包 `data`**(成功返回 `data` 本体;失败 `throw`;`code:18` 自动登出)。 - ⚠️ 调用时 `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`):后端按账号手工勾选的 `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))。两个页面各自只提交自己那组字段, 后端 `saveAppVersion`/`saveAppTourists` 都是「读整行 → 只覆盖本组 → 整行写回」。 ⚠️ 别图省事合成一个「整行保存」接口,也别在前端 `{...form}` 整包提交—— 在版本页点一次保存就会把游客登录配置清空,反之亦然。读那一步走 `FindOnePrimary` (强制主库),否则刚在另一页保存完就可能读到副本的旧行、把对方打回上一版。 - 样式:`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`**,历史留在原仓库。 ⚠️ 搬仓时源仓库 .gitignore 忽略的文件**不会跟过来**,已手工补回:`.env`、`.mcp.json`、`.metadata`、 `.vscode/settings.json`。(`.icon_preview`、`.vercel`、空的 `.claude` 判定为本机产物,未搬。) ### 与后端怎么对上(联动开发的关键链路) - 客户端所有业务请求:`POST {SERVER_URL}/api/home/<模块>_<方法小写>` —— 路径由 gateway 的 `this.gin.Any("/api/:param1/:param2", this.api)` ([wservice_comp.go:66](apps/services/modules/gateway/wservice_comp.go#L66))接住,转发到对应服务里 **反射注册**出来的同名方法。所以**后端加一个 `api_xxx.go` 方法 = 客户端立刻多一个可调接口**, 两边都不用改路由表,只需在 [lib/data/services/network/api.dart](apps/client/lib/data/services/network/api.dart) 补一个静态方法。 - `SERVER_URL` 在 `apps/client/.env` 里,走 `flutter_dotenv`; [dio_manager.dart](apps/client/lib/data/services/network/dio_manager.dart) 的 `baseUrl` 再按 地区/渠道在 `SERVER_URL` 与 `HW_SERVER_URL` 间选一个(`HW_SERVER_URL` 留空会**回退**到 `SERVER_URL`,不会变成空 host)。 `lib/data/services/network/nw_api.dart` 里那个 `baseApi` 常量已注释废弃,别照着它改。 - **当前这份 client 对应 `ym` 应用**:真实 `.env` 里 `SERVER_URL=HW_SERVER_URL=https://ym-dev.ymaikj.com` (阿龙测试环境),`Channel=xiaomi`。别照着 `nw_api.dart` 里注释掉的 `api.voitrans.net` 改。 - **本机联调**:`SERVER_URL=http://<局域网IP>:7100`(gateway HTTP 监听端口,见 [deploy/app/confs/gateway.yaml.example](deploy/app/confs/gateway.yaml.example))。 真机调试别写 `127.0.0.1` —— 那是手机自己。 - **两套 "env" 别混**: - `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-14 起 `env` **只来自 `svc_config` 字段的 `env_key`**:`global_config`(9-12)和业务库 `config` 表(9-14)都不再参与下发。客户端读的应用参数(AGENT_TYPE / MOBILE_ELF_* / iapCustomerServiceQQ) 也在 svc_config 里(服务 `llm_mobile_elf` / `app_params`)。详见下文「凭据归一」及「应用参数标签页下线」。 - 客户端 `UserGetAppConfigResp`、`DBAgent`、`DBProduct` 等模型是**手写镜像**服务端 `pb.UserGetAppConfigResp`([user_msg.proto:168](apps/proto/user/user_msg.proto#L168))的, **没有代码生成把两边绑在一起**。改 proto 字段后要手动同步 [appconfig_model.dart](apps/client/lib/data/models/appconfig_model.dart) 并重跑 `dart run build_runner build`(生成 `*.g.dart`)。服务端已为此留了兼容层, 见 [modules/user/compat.go](apps/services/modules/user/compat.go):**Dart 里 non-nullable 字段一旦后端不下发就是 TypeError,整个 `fromJson` 失败**,表现为设备列表/应用配置整体空白——所以后端删字段前先看这个文件。 - `user` 模块的 v2/v3 api 把 `GetAppConfig`/`GetAgents` 列入 `EncryptMsgs`(加密协议), 抓包看到的是密文不是明文 JSON,别以为接口挂了。 ### 客户端凭据走结构化通道:加密 v3 + `AppConfig.cred()`(2026-09-14) EAIMAR 客户端不再从 `env` 兼容层拿第三方凭据,改走**自己的路**: | 环节 | 位置 | |---|---| | 请求 | `Api.getappconfig()` → `.env` 配了 `GATEWAY_ENCRYPT_KEY` 就打 **`user_getappconfig_v3`**(整体 AES 加密),否则回退明文 v1 | | 解密 | [api_crypto.dart](apps/client/lib/data/services/network/api_crypto.dart) 的 `ApiDecryptInterceptor`,**必须排在 `AuthInterceptor` 之前**(它一看到不是业务 JSON 就当失败) | | 模型 | `UserGetAppConfigResp.thirdsvcs: List`(id/name/provider/categories/fields),缺键 → 空列表 | | 取值 | `AppConfig.cred(svcId, fieldKey, envKey)`:**thirdsvcs 的 (服务 id, 字段) 优先,退回 env[老键名],两边都没有 → null** | - 服务 id / 字段名与服务端 `migrate_envtosvc.go` 的落点表一一对应(`stt_azure.subscription_key`、 `ast_alibaba.app_key`、`llm_doubao.api_key`…),**改哪边都要同改**;老键名只作兜底。 - 加密口径(`lego/utils/crypto/aes/cbc.go`):AES-CBC,IV 固定 16 个 `'0'`,PKCS5/7,密文 base64, 响应头 `X-Encrypted: 1`、`Content-Type: text/plain`。`test/api_crypto_test.dart` 的密文是 **openssl 按 Go 口径** 生成的金向量——用同一套 Dart 加密再解密只能证明自洽,证明不了与 Go 端一致。 - ⚠️ **`GATEWAY_ENCRYPT_KEY` 要进客户端 `.env`**(gitignore,值与服务器 `deploy/app` 的 `.env` 一致,测试/正式同一把); 没配就静默回退 v1,`thirdsvcs` 为空、`AppConfig.usingThirdSvcs == false`——排查「明明配了服务却读不到」先看这个。 密钥不一致的表现是解密后 PKCS7 去填充失败 → 拦截器 reject,报错文案指向密钥,别去查网络。 - ⚠️ **v3 必须在网关白名单**(`deploy/app/confs/gateway.yaml.example` 与各机 `confs/gateway.yaml`):splash 在**登录前** 就取配置,少这一行返回 `code:18` 然后整个启动流程走兜底。测试机已加;**正式机 ym-a11 的 gateway.yaml 尚未加**(要重启容器), 新客户端上正式前必须补。 - 未登录时 v3 的 `filterSvcsByVip` 拿不到 uid → 按有 VIP 放行;实名(11) 类照旧永不下发。 - `cred()` 返回 null 而不是空串:空串会把调用方的 `?? 默认值` 顶掉(`AZURE_TRANSLATION_ENDPOINT` 栽过)。 ### 游客登录与功能闸门(2026-08-29 改) 游客登录**走服务端** `api_sgin` 的 `Tourists` 分支(`stype=6` + `phonemac=设备id`),拿真实雪花 uid 与 JWT。 早先是 `_guestLoginLocal()` 本地伪造会话(token 形如 `guest-xxx`),网关解不出 uid, 游客一个后端请求都发不出去——实名认证接口自然也调不通。 - 服务端 `findtouristformac`([model_user.go](apps/services/modules/user/model_user.go))按设备找回游客账号。 ⚠️ **不能改用现成的 `findformac`**:`phonemac` 是所有登录方式建号时都会写的字段, 同一台设备先用手机号登录过、再点游客登录会串进那个正式账号。该函数额外要求 `phone/mail/account/*openid` 全为空,只有游客账号满足。 - ⚠️ 找回逻辑原先是注释掉的,恒定走新建账号分支,后果是游客每次登录都是新账号: 实名记录留不住,且新账号分支紧接着发**新用户开户礼**,等于可以无限白嫖。 - **功能闸门不再按游客身份拦,一律按实名状态拦**:`GuestGuard` 已删除,所有调用点改用 `IdVerifyGuard.blocked()`([id_verify_guard.dart](apps/client/lib/core/utils/id_verify_guard.dart))。 游客点功能弹实名窗,认证通过就能用。卡片角标从「仅浏览」改成「未认证」(复用 `idVerifyNotDone`)。 - `User.isGuest` 现在读登录时写下的 `login_is_tourist` 标记(游客拿到的是正经 uid/JWT, 光看 uid/token 认不出来)。**它已不是功能闸门**,只用于展示层区分身份;登出时随会话一起清。 - 老装机升级上来时本地还留着伪造的 `guest-` token,过不了网关, splash 的 `_validateTokenAsync` 会清掉会话把人踢回登录页——这是预期行为。 #### 游客登录入口的显隐:后台开关 + 版本限定(2026-08-29 加,2026-08-31 改配置入口) 入口显隐由后台「**服务配置 → 游客显隐控制**」按**应用**配置(不分渠道),**判定在服务端** ([comm.AppRelease.ShowTourists](apps/services/comm/apprelease.go),同 `NeedForceUpdate`/`IsReviewing` 的规矩): **白名单语义:只有被点名的那一个版本可见,其余一律隐藏。** | `tourists` | `touristsversion` | 效果 | |---|---|---| | 关 | 任意 | 全部版本隐藏 | | 开 | 空 | 全部版本隐藏(没点名任何版本 = 谁都不放行;后台已拦住这种等于没开的配置) | | 开 | `1.0.2` | **只有 1.0.2 可见,其余版本一律隐藏** | 留空按「全隐藏」而非「全放行」是刻意选的失败方向:判宽的后果是不该露游客入口的版本 露了出去(过审风险、白嫖开户礼),判严只是入口少显示。**要全部隐藏就取消勾选, 不要靠留空版本号表达**——后台 `saveAppTourists` 会拒绝「勾了开关却没填版本」。 ⚠️ **这不向后兼容**:升级前 `tourists=true` 且没填版本的存量配置,升级后入口会消失。 部署后必须去后台把生效版本填上(或确认就是要关掉)。 ⚠️ **配置位置 2026-08-31 变了**:原先在「SaaS 管理 → 渠道分发」按 (应用, 渠道) 配, 现在按应用配、存 `app_release` 表。**旧 `channel_app` 行里的 `tourists`/`touristsversion` 不会自动搬过来**,升级后要在新页面重填一次。 第三行是送审场景:只给正在过审的那一版开游客登录。客户端在 `user_getchannelapps` 的请求里带 `version`(净版本号,不含构建号),服务端把每条渠道配置的 `tourists` **换算成结论**再下发——客户端拿到的 `tourists` 已经是「这一版该不该显示」, **不要再拿 `touristsversion` 自己比一遍**。没上报版本时按不匹配处理(隐藏), 点名了版本却认不出是哪一版只能从严。 - ⚠️ **已发布的老客户端关不掉**:这次改动之前 `_checkGuestLoginConfig()` 恒定写死 `true`([login_controller.dart](apps/client/lib/modules/login/controllers/login_controller.dart)), 入口根本不读服务端配置。所以「其他版本全部隐藏」只对装了本次改动及以后版本的客户端成立, 存量包无论后台怎么配都仍会显示入口。 - 入口所在的 `_buildSocialLoginSection` 必须是 `Obx` 不能是 `Builder`:配置是异步拿到的, `Builder` 只渲染 onInit 时的初值 `false`,入口再也不会出现。 - 拿不到配置(无网/接口失败)时按**隐藏**处理。反过来默认显示会在送审期把不该露的入口露给审核员。 代价是断网启动后 10 分钟内(`checkForUpdate` 的缓存窗口)入口不出现。 - ⚠️ **`user_getchannelapps` 必须在网关白名单里**([gateway.yaml.example](deploy/app/confs/gateway.yaml.example))。 游客入口的显隐结论就在这个响应里,而入口**恰恰只在未登录时才需要显示** —— 少了这一行 会返回 `code:18 NoLogin`,客户端静默按「隐藏」处理,表现为「后台开了但 App 里没有」, 且不报任何错。白名单里原本只有单数的 `user_getchannelapp`(客户端从不调它), 这个洞让游客入口在任何未登录设备上都不可能出现。 - ⚠️ 一次失败会把入口**锁死 10 分钟**:`checkForUpdate` 在**发请求之前**就写了 `_lastCheckTime` ([version_update_service.dart](apps/client/lib/data/services/version_update_service.dart)), 请求失败后缓存时间戳已落下,而缓存命中分支**不重算** `_isShowTourists`。 splash 那次失败之后,登录页再怎么重进都是隐藏的。 ### EMAI 助手的端侧指令(2026-08-29 接入) 百炼多模态会在 `RespondingContent` 这一帧里下发端侧函数调用,客户端原先**整个丢掉**了 (只取了 event/text/finished/state 四个字段),所以助手嘴上说"闹钟已经定好",实际什么都没做。 链路:协议层解析 → [assistant_directive_service.dart](apps/client/lib/data/services/assistant_directive_service.dart) 分发 + 落本地记录 → 「代办」页展示(`CalendarController._rebuildEvents`)。 App 内对话页与耳机唤醒(`DeviceAiSessionService`,原 `BesAiSessionService`)两条链路都往同一个 service 送,按指令 id 去重。 抓包实测(原始帧见 `test/assistant_directive_test.dart` 的用例,全是真实 payload): - 指令在 `payload.output.extra_info.tool_calls`,`arguments` 是**字符串形式的 JSON**,要二次 parse。 - ⚠️ 参数值是**中文自然语言**,哪怕用户全程说英文:`repeat="周一"`、`content="开会"`。别指望 `MON` 这种枚举。 - ⚠️ `extra_info.tool_infos` 是**另一回事**——服务端插件(如 weather)**已经执行完**的结果,端侧不用做事,两者不能混。 - ⚠️ `finish_reason` 决定这一帧的形状:`stop` = 有文案(可能顺带指令); `command_calls` = **纯指令帧,text/spoken 都是空,而且这一轮不会有 RespondingStarted/Ended**。 照着 text 建气泡会在聊天记录里留下永远填不上的空泡。 - ⚠️ **`time` 不能无条件相信**:实测说 "3:30pm"、文案写"下午3:30",`time` 却给 `"03:30"` (同批样本里 5pm→17:00、9pm→21:00 又是对的)。`AssistantReminder.disambiguateAfternoon` 按同帧文案里的"下午/晚上/pm"做保守校正,删了会把闹钟定到凌晨。 已知指令(探测所得,**不完整,完整清单要从百炼控制台导**):`SET_clock`(time/date/content/repeat)、 `ROUTE_map`(endLoc_city/endLoc_poi)、`SEND_message`(contact/content)、 `MAKE_A_PHONE_CALL_phone_call`(contact_name)、`INCREASE_DEFAULT_volume`(无参)。 目前**只有 `SET_clock` 真正落地**,其余记一条 `unsupported` 流水而不是丢掉。 `SEND_message` / `MAKE_A_PHONE_CALL_phone_call` 列在 `_declined` 里——本版已移除电话/联系人能力, 接了也执行不了,**要治本得去百炼后台把这两个工具摘掉**。 另有两处模型侧的坑,不是客户端问题:**"取消闹钟"和"记一下待办"都不下发任何指令**,模型却回"已经帮你取消了"; `SEND_message` 的 content 出现过幻觉(说的是"我会迟到",下发的是"项目进展如何了?")。 **当前边界**:指令只在 EMAI 会话建立时(对话页打开或耳机唤醒)才收得到, 「App 没打开也能定闹钟」需要常驻的会话持有者,光把执行代码挪到原生层不解决。 落地也**只到本地**(GetStorage),服务端同步接口未开发——两个模型的 `synced` 字段和 `toJson()` 已按可直接上传的形状设计,接口就绪后补上传即可,不用改结构。 ### 通话翻译的服务商路由:豆包已停用,统一走阿里(2026-08-31 改) 通话翻译选哪家端到端服务,**唯一入口**是 [language_manager.dart](apps/client/lib/data/services/language_manager.dart) 的 `findBestMatchingProvider`(只被 `TranslationController._initializeCallModeTranslationService` 调用)。原策略是 `豆包(zh/en/ja/ko 白名单且含 zh|en) > 阿里 > 微软 > 讯飞`, 现已把 `SpeechServiceType.volcano` 从 `providers` 列表里摘掉,变成 **阿里(通义 qwen3-livetranslate 端到端) > 微软 > 讯飞**。 - 摘得干净的前提:豆包覆盖的 `{zh,en,ja,ko}` 是阿里翻译表 (zh/en/ja/ko/fr/de/es/it/pt/ru)的**真子集**,中英通话翻译不会掉到微软那条 「ASR+翻译+TTS 三段拼装」的慢路上去。 - 原生侧 `DoubaoE2ETranslateHelper`(Android/iOS 都有)和 `volcano_language_config.dart` **保留未删**,只是不再被选中;`azure_ast_service` 仍会把豆包的 `appKey/accessKey/resourceId` 传给原生 initialize,原生只在 `provider=="volcano"` 时才用,属于无害入参。 - ⚠️ **别把 volcano 加回 `providers` 列表**——它排在阿里前面就会又把中英通话翻译抢走。 - 这只影响通话翻译。其它豆包用法(`VolcanoTtsService`/`VolcanoAsrApiService`、 ble_manager 里的 `doubao-1-5-vision-pro` 视觉模型)不走这个函数,未动。 #### 通话翻译服务商四档降级:阿里3.5(声音复刻) > 阿里旧版 > 微软 > 讯飞(2026-09-01) `findBestMatchingProvider` 的 `providers` 列表就是这个顺序,改顺序=改策略。 | 档 | provider 串 | 模型 | 覆盖 | 实际吃到的语种 | |---|---|---|---|---| | 1 | `alibaba35` | `qwen3.5-livetranslate-flash-realtime` | 「音频+文本输出」**29 语** | 绝大多数 | | 2 | `alibaba` | `qwen3-livetranslate-flash-realtime` | 旧版 **18 语** | **只剩 yue / el** | | 3 | `azure` | 微软三段拼装 | app 开放的 80 语里其余约 50 个 | 稀有语种 | | 4 | `iflytek` | — | 兜底 | — | ⚠️ **「3.5 支持 60 语」不能直接当路由依据**:其中 31 语(yue、el、uk、ro、hu…)是 **仅文本输出**。通话翻译必须拿到 TTS PCM 回灌耳机,没音频等于功能不成立, 所以第 1 档卡的是 29 语音频档。yue/el 正因如此才落到第 2 档——旧版能出音频。 ⚠️ **第 2 档的语言表必须列全旧版 18 语**,虽然其中 16 语被第 1 档吃掉、永远走不到。 按「反正走不到」删成 10 语,yue/el 就会直接掉到微软,等于这一档白设。 ⚠️ `el`(希腊语)走旧版时用的是 Cherry —— **Cherry 能不能播希腊语没有实测过**, 真机验证优先测这一对。yue 强制 Kiki(见 `getAlibabaVoiceForUser` 的 yue 分支)。 **声音复刻怎么落地的**(这是 3.5 档相对旧版的全部价值): `enable_voice_clone: true` + `voice_clone_options.frequency: "once"`,译文用**说话人本人 的声音**说外语——于是「对方是男是女」这个问题自动消失,不需要猜性别。 `once` 而不是 `always`:通话里每一路说话人固定(A 路永远是机主、B 路永远是对端), 开头刻一次就够,`always` 更贵更慢。 ⚠️ 阿里要求 **frequency 为 once/always 时 `voice` 必须是 `"default"`**, 所以原生 helper 在开复刻时**无条件覆盖**上层传来的音色。也就是说 上一节那套按性别选音色的逻辑,在 3.5 档不生效,**只对第 2 档(旧版)有意义**。 **两档共用同一个原生 helper**:WS 协议、`AliyunBailianE2EHelper`、 `session.update` 形状完全一样,只差 `model` 与两个复刻开关。所以 `AzureAstService.initialize` 把 `alibaba35` **映射成原生 provider `alibaba`** 再下发(`nativeProvider`),原生侧不需要为 3.5 多一个分支——否则 Android/iOS 各有 6 处 `== "alibaba"` 的比较要同步加,漏一处就是静默失效。 ⚠️ 原生两端原先**把模型名写死在 WS URL 里**(`?model=qwen3-livetranslate-flash-realtime`, iOS 连 `cfgA.appId` 也是写死的),Dart 传什么模型都不生效。现在都改成读 `conf.appId`。 这个坑的表象是「3.5 和声音复刻都配好了却完全没效果,且不报任何错」—— 连的还是旧模型,旧模型直接忽略 `enable_voice_clone`。 ⚠️ **3.5 的模型名里是点号** `qwen3.5-livetranslate-flash-realtime`, 不是文档 URL 里的 `qwen3-5-`。写错握手会被拒。 `.env` 的 `ALIBABA_OPENSPEECH_APP_ID` **只覆盖第 2 档**,3.5 走代码里的常量 `kAlibabaModel35`——否则运维改了 env 会把 3.5 悄悄打回旧版。 WS 地址仍是 `wss://dashscope.aliyuncs.com/api-ws/v1/realtime`(中国大陆), model 由 helper 拼成 query 参数。文档里那个 `wss://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/...` 是业务空间端点,需要 WorkspaceId,我们没用。 #### 通话翻译的音色:按路分开,默认女声(2026-09-01) 阿里 `qwen3-livetranslate-flash-realtime` 的 `voice` 是 **session 级参数**—— 握手第一条 `session.update` 里定死,之后再发会被服务端忽略;模型**不做说话人 性别识别**。所以「按对方声音是男是女选音色」这件事当前做不到。 音色是**按路**给的,两路含义不同(映射在 `AzureSpeechPlugin`,Android/iOS 一致): | 路 | 音频来源 | 谁听 | 代表谁 | 原生取值 | 对应 Dart | |---|---|---|---|---|---| | A | 己方麦克风 | 对方 | **用户本人** | `bailianConfigA.voice = ttsLang1` = `supportedLanguages[5]` | `targetSpec['ttsCode']` | | B | 对端通话音 | 用户 | **对方** | `bailianConfigB.voice = ttsLang0` = `supportedLanguages[4]` | `sourceSpec['ttsCode']` | 于是口径定为:**A 路跟用户设置的性别,B 路固定女声**(对方性别未知)。 `getAlibabaVoiceForUser()` / `getAlibabaVoiceForPeer()` 在 [alibaba_language_config.dart](apps/client/lib/data/services/language_configs/alibaba_language_config.dart)。 - 性别口径与 `LanguageManager._getVoiceByGender` 对齐:**只有 gender==1(男) 用男声,未设置(0) 与 女(2) 一律女声**。原实现是 `gender == 2 ? Cherry : Nofish`, 把「未设置」判成了男声(Nofish 不吃鱼是男声),与全项目相反,已修。 - ⚠️ 改 A/B 那两行赋值前先回去核原生的 `ttsLang0/ttsLang1`,**写反了不会报任何错**, 只会变成「对方说话用我的性别、我说话用默认女声」。 - ⚠️ 音色**必须**从阿里音色表里取。填不上会退到 `getTtsVoiceNameByAsrCode` 的 Azure 音色名(`zh-CN-XiaoxiaoNeural`),对阿里是非法值。 - ⚠️ 日志/取值别裸调 `User.instance`——未初始化时它 **throw** 而不是返回 null, 异常会被 `_initializeCallModeTranslationService` 的 catch 吞成「初始化失败」。 - 想真正做到「听起来像本人说外语」,得升到 `qwen3.5-livetranslate-flash-realtime` 用实时声音复刻(`enable_voice_clone` + `voice_clone_options.frequency`);旧版模型没这能力,且阿里已标注旧版 「不再作为首选推荐」(旧版只支持 18 语,3.5 支持 60 语)。 #### 阿里端到端本来就是流式的,是**增量文本事件名接错了**(2026-09-03) 「通话翻译像是等说完才翻」不是协议的问题,是客户端订阅了一个不存在的事件名: | 方向 | 事件 | 原状态 | |---|---|---| | 输入音频 | `input_audio_buffer.append` 逐帧推 | ✅ 一直是流式 | | 输出音频 | `response.audio.delta` → `processAudioChunk`(1280B≈40ms/块)→ `aPcmWriter/bPcmWriter` → EventChannel → `bes.pushTranslationTtsPcm` | ✅ 一直是逐帧回灌,无整段缓冲 | | 输出文本 | 代码监听 `response.audio_transcript.**delta**`(字段 `delta`) | ❌ **永不触发** | 阿里文档里,实时语音翻译(livetranslate)的增量文本事件是 **`response.audio_transcript.text`**(audio+text 模态,字段 **`text`**) 或 `response.text.text`(纯文本模态);`...delta` 是 OpenAI Realtime / 通义 Omni 的写法, 文档专门写了两者不同。所以 `onPartialText` 一次都没触发过,译文只在 `.done` 时整句蹦出来, `recvTextBuffer` 也恒为空。 现在两套名字都认、`text`/`delta` 两个字段都取,并且**按首次出现的名字锁定** (`partialTextEvent`/`finalTextEvent`)——万一服务端兼容层双发,不锁会让同一段文本累计两遍。 Android/iOS 两端同改,改动都在各自的 `AliyunBailianE2EHelper` 的 `handleJsonMessage`。 ⚠️ 判断有没有生效看这行日志:`增量文本事件名锁定为: <事件名>`。一次都没打 说明服务端两套都没发,那才该回头怀疑模态配置。 ⚠️ 顺带存疑(**未改,别当已验证**):`session.update` 里写的是 `input_audio_format="pcm16"` / `output_audio_format="pcm24"`,而阿里文档的取值是 `"pcm"`。 现在能收到 24k 音频说明服务端要么接受要么忽略了,但这不是文档写法。 `turn_detection` 也完全没配,走服务端默认 VAD——想调切句节奏就从这里下手 (`{"type":"server_vad","silence_duration_ms":...}`)。 ⚠️ 官方标称 2.8s 同传延迟,那是模型为语义完整做的等待,配不掉。文本接上流式之后 提前的是「开始出字」的时间,音频总延迟不变。 ### 面对面 / 同声翻译:延迟构成与已做的优化(2026-09-03) 这两个模式**不走端到端**(端到端只用在 call 模式),是 ASR → HTTP 机器翻译 → TTS 三次串行往返。排查「慢」先按这个顺序拆,别一上来怀疑网络: | 段 | 位置 | |---|---| | ASR 出整句 final | `AzureAsrHelper`(Android/iOS 各一份) | | 机器翻译 | `translation_controller._performTranslation` → `ServerTranslationService` → 自家网关 → 服务商,**两跳** | | TTS | `playTranslatedText` → `speakOnce` | 已做的五处(都在 2026-09-03): 1. **补上 `Speech_SegmentationSilenceTimeoutMs`=300**(两端 `AzureAsrHelper`, 常量 `SEGMENTATION_SILENCE_TIMEOUT_MS` / `segmentationSilenceTimeoutMs`)。 原来一行都没设 → 走 Azure 默认 500ms,而这是「说完到出译文」里唯一一段纯等待。 ⚠️ 别再往下调,值越小越容易把一句话切碎,每段各翻一次反而更差。合法区间 100~5000。 2. **同传 / 音视频改单语 ASR**(`_asrLanguagesForCurrentMode()`)。 给 ASR 传 2 种语言 = 原生打开 Azure **连续语种识别**(`LanguageIdMode=Continuous`), 它要先缓冲够音频、给候选语言逐个打分才敢出 final。只有面对面真需要判断「谁在说」, 同传/音视频是单向的,开着纯亏延迟。 ⚠️ 固定语种时原生上报的 `detectedLanguage` 就是 `supportedLanguages[0]`, `_determineLanguageCodes` 据此算出 `shouldSwap=true`,**方向仍然正确**—— 改这里之前先回去核对那个 if/else,判反了不会报错,只会全程翻译方向颠倒。 代价:同传下用户改说目标语种不再被自动识别成反向(同传本来就是单向场景)。 3. **中间结果翻译从「每 300ms 无条件发」改成节流 + 可取消** (`_interimTranslateDebounce`=700ms、`_interimMinGrowth`=4 字、`CancelToken`、 `_interimTranslateSeq` 丢弃过期结果)。中间译文只上屏、不播报、不写历史, 原来一句 5 秒的话要打十几次真实服务商调用,还会跟说完之后那次 final 抢链路和配额。 ⚠️ final 的请求**永远不带 CancelToken**:它要写历史、要播报,不能被后来者掐掉。 4. **TTS 提到 `_updateExistingHistoryItem` 最前面**。原来排在浮窗更新、写历史、 记统计这一串 await 之后——播报被白白推迟了这一整串的耗时。 5. **`startRecognition` 先 await onInit 那次 `changeTranslationMode`**(`_modeInitFuture`)。 ⚠️ `AzureAsrService.startContinuousRecognition` 里那条 `if (!_isInitialized) await initialize()` 兜底分支**不传语言**,会落到默认 `['zh-CN','en-US']`。正常流程由 onInit 的 `changeTranslationMode → _reinitializeAsrService` 带着真实语言对先跑一遍, 但那是个没人 await 的 async 调用——用户进页面立刻点开始就会抢在它前面, 于是选了别的语种却拿中英在做识别,表现为「识别半天不出结果」。 ⚠️ 顺带改了 `AuthInterceptor` 的 `DioExceptionType.cancel` 分支:**不再弹红色 「请求已取消」**。取消永远是客户端自己发起的,不是故障;不改的话上面第 3 条 一接上就会一路狂弹。 **还没做**(下一步的大头):让面对面/同传也走阿里端到端。`AstService.pushExternalAudio` 已经现成,把手机麦克风 PCM 灌 leg A 即可,一步省掉三次串行往返; `TtsService` 里的 `speakStream`/`flushStream` 也早写好但没人调,配流式译文正好。 ### 固件升级(OTA):链路梳理与恒玄适配(2026-09-01) **服务端其实一直是通的,只是客户端没去读。** 固件版本的完整链路: | 环节 | 位置 | |---|---| | 后台上传固件 | 产品管理 → 产品行「版本」按钮 → 版本管理抽屉 → 添加新版本(版本号+描述+**固件文件**,落到 `yunyan/product/<产品id>/<文件名>`),可勾「设为当前使用版本」。见 [product.vue](apps/admin/app/pages/product.vue) 的 `showVersions/addVersion/useVersion` | | 后台接口 | `api_addproductversion` / `api_getproductversions` / `api_delproductversion`([modules/api/](apps/services/modules/api/)),表 `DBProductVersion`(id/productid/version/description/updatepackageaddress) | | 下发给客户端 | **`user_getappconfig` 的 products**:设为「当前使用版本」后,`DBProduct` 的 `version` / `updatepackageaddress` / `updatedescription` / `isforceupdate` 就是这一版。另有 `user_getproductversions`(按 pid 拿全部固件版本列表)——**客户端至今没调过**,只做版本对比的话不需要它 | | 客户端读取 | `OtaCoreService.getLatestVersionInfo()` → `findProduct(deviceName)` → `product.version` | | 当前设备版本 | 恒玄:`BesBluetoothService.deviceVersion`(连上时 `AA 06` → `BB 86` 解析);杰理:`BleManager.deviceInfo` | #### 恒玄固件推送已打通(2026-09-01,从 deepvoice 移植) 原先「恒玄只能版本对比、点升级提示 `otaBesNotSupported`」的状态已结束。 从 `/Users/yunyanzhineng/work/src/deepvoice` 移植了 `local_plugins/bes_ota_manager` (自包含 BES OTA SDK:Android Java + iOS ObjC,648K),路由 `Routes.OTA_UPGRADE` 已切到 `BesOtaUpgradeView` / `BesOtaUpgradeController`。 | 文件 | 职责 | |---|---| | `local_plugins/bes_ota_manager/` | BES SDK + 原生桥接,SPP OTA 2.0;`init/startOta/stopOta` + 事件流 | | `controllers/bes_ota_upgrade_controller.dart` | 流程编排:读产品配置 → 断 SPP → 下载 → 推送 → 回连 | | `controllers/bes_ota_download_service.dart` | Dio 下载到 `ota_upgrades/`,`.part` 原子 rename | | `views/bes_ota_upgrade_view.dart` | 进度/结果页,升级中禁止直接返回 | ⚠️ **插件自管到耳机的 SPP 连接,不复用 `bluetooth_manager`**:多数固件同时只接受一条 SPP 链路,所以 `startUpdate` 必须先 `BesBluetoothService.disconnectDevice()` 让路,且**留 2s 缓冲** ——disconnect 返回只代表本端 socket 已关,耳机侧拆链路还要在空中传播,固件已缓存时衔接 近乎瞬时,立刻连 OTA 会被拒。退出页面时 `onClose` 先 `stopOta()` 再延时回连(成功 5s、 其余 2s),两条连接不能同时在。 ⚠️ **升级中 app 层连接是我们自己断的**,`_statusWorker` 里那个 `deviceStatus != 2` 只用于展示, **不能拿它去中止 OTA**——OTA 掉线由插件经 `onError` 上报。 ⚠️ **看门狗(90s 无进度即判失败)不能删**:原生侧存在「既不推进也不报 onError」的情况, 没有它 UI 会永久停在「升级中」。失败/成功都是终态并锁定,迟到的事件一律忽略, 否则成功会被迟到的 Canceled 覆盖成失败。 ⚠️ 杰理那套 `OtaUpgradeController` / `OtaUpgradeView` / `OtaCoreService` / `OtaDownloadService` **文件还在但已不挂路由**。新链路完全不碰 `BleManager`/`Jielihome`——`OtaCoreService` 构造时 就 `Get.find()`,复用它等于把杰理依赖又拖回来。 ⚠️ 产品匹配用 controller 自己的 `_findProduct`(名字互相包含 → 唯一 `devicetype==1` → null), **别改回 `AppConfig.getproduct()`**:它内部 `firstWhere` 没有 orElse,查不到直接抛 StateError。 移植时新增的依赖:`wakelock_plus`(升级期间屏幕常亮)。翻译新增 28 个 key ×41 语种 (deepvoice 那边只有中英两语,其余 39 语是这次补的)。 修掉的三个坑(都会表现成「点进去没拉服务器接口」): 1. `OtaUpgradeController._initializeController` 原来是「**杰理链路没连上就直接 return**」, 而本项目用恒玄 —— `_loadVersionInfo()` 永远不会被调到,页面从头到尾一次服务端读取都没有。 现在恒玄连着也继续走;杰理的 OTA 事件流仍只在杰理链路上订阅。 2. `AppConfig.getproduct()` 内部 `firstWhere` **没有 orElse,查不到直接抛 StateError**, 而三处调用点写的都是 `if (product == null)` —— 那个 null 判断永远等不到,异常直接穿出去。 已换成 `OtaCoreService.findProduct()`(名字互相包含 → 唯一的经典蓝牙耳机产品 → null)。 老兜底名 `"DeapSound"` 是上一代品牌,早已不在产品表里,删掉了。 3. `getDeviceName()` 只认经典蓝牙 currentDevice 和杰理 deviceInfo,恒玄两个都拿不到, 恒为 `unknownEquipment` → 按名字查产品必然落空。已补恒玄来源。 另外两处顺手纠正的口径: - 强更判定原来是 `product.isforceupdate = product.version?.contains("+")` —— 拿版本号里有没有 `+` 当开关,与后台配置完全脱节。改成直接用后台的 `isforceupdate` 字段。 (和 App 版本更新那边踩过的土规则是同一个毛病。) - `isNewVersionAvailable` 用 `int.parse` 逐段解析,遇到 `1.0.0-beta` / `1.2.3+4` 直接抛, 被外层 catch 吞成 false —— 表现是「明明有新版本却说已是最新」。改成取每段前导数字, 与服务端 `comm.CompareVersion` 同口径。 ⚠️ CLAUDE.md 上文提到的 `VersionUpdateService.compareVersion` 与 `test/version_compare_test.dart` **在当前工作区里并不存在**(version_update_service.dart 有大量未提交改动),所以 OTA 这条没有复用它,版本比较是本地实现的。 ### ⚠️ 私有协议命令号会随固件版本变(2026-09-02 实测) **下面那张命令表是在 Echo-one 固件 0.0.2 上扫出来的,固件 0.0.3 上 `AA 09` 已经不回 `BB 0A` 了**——连 `BB 89` ack 都没有,静默无回包,表现就是「电量那块不显示」。 同一次连接里 `AA 06`(固件版本)仍然正常回 `BB 86`,所以不是链路问题,是命令号变了。 结论:**换固件就要重扫命令**,别把这张表当稳定契约。重扫用 `BesBluetoothService.to.probeCommands(from: 0x01, to: 0x20)`(当初 `AA 09` 就是在这段扫出来的; `0x21~0x7F` 已确认整段无回包,2026-09-01 扫过 69 个命令,不用再浪费 28 秒)。 `0x5x`/`0x6x` 会切工作模式(进通话/录音/AI),任何情况下都别扫。 ### 恒玄耳机电量:`AA 09` 请求 / `BB 0A` 上报(2026-09-01 真机实测定稿,**仅适用固件 0.0.2**) 厂商协议 3.1.10「耳机响应 APP 获取状态(响应命令 20)或者耳机状态变化主动上报」。 响应帧 `BB 0A` 与文档一致,但**请求码不是文档写的那个**——实测是 **`AA 09`**。 ⚠️ **请求码只能靠实测,别照文档改**。文档正文那句「响应命令 **20**」不带 `0x` 前缀 (而表格里 `0xbb`/`0x0a` 都带),两种进制都试过、都**完全无回包**: | 试过的请求码 | 依据 | 结果 | |---|---|---| | `AA 20` | 把「20」当十六进制 | 无回包 | | `AA 14` | 把「20」当十进制 | 无回包 | | `AA 0A` | 按「响应=请求」推 | 无回包 | | **`AA 09`** | **扫 `0x01~0x20` 扫出来的** | ✅ `BB 0A` + `BB 89` ack | ⚠️ **`0x09` → `0x0A` 是差 1,不是这台固件其它命令那种「响应 = 请求 \| 0x80」** (`AA 06`→`BB 86`、`AA 0E`→`BB 8E`)。按 `|0x80` 反推出的 `AA 0A` 恰恰是错的, 早期实现就栽在这上面。`AA 09` 另附一个 `BB 89 04 00` 的 ack,已在 `case 0x89` 吞掉。 扫描时顺带发现的其它有回包命令(含义未知,备查): `AA 0B`→`BB 8B 04 00`、`AA 0D`→`BB 8D 07 00 02 01 02`、`AA 0F`→`BB 8F 04 00`、`AA 19`→`BB 99 04 01`。 命令扫描用 `BesBluetoothService.to.probeCommands(from:to:)`,**只扫低位查询段**, `0x5x`/`0x6x` 会切工作模式(进通话/录音/AI),别碰。 帧格式 `BB | 0A | CMD Length(1) | 块… | checksum`,块是 `[len, 属性, 数据…]`, **len 计的是「属性 + 数据」的字节数**(`07 07 20 FF 36 6B A4 F3` → len=7 = 属性 1 + MAC 6)。 | 属性 | 含义 | 备注 | |---|---|---| | 1 / 2 / 3 | 左耳 / 右耳 / 充电盒电量 | **最高位是充电指示位**,取值要 `& 0x7F` | | 4 | 耳机位置(是否在盒内) | 值常 >100,不是电量 | | 5 | 连接状态 | 同上 | | 6 | 预留 | 12 字节,含本机/对端 MAC | | 7 | 耳机蓝牙地址 | 6 字节 | 文档样例 `bb 0a 28 02 01 2B 02 02 18 02 03 64 …` 解出左耳 43% / 右耳 24% / 盒 100%。 真机实测帧(Echo-one 固件 0.0.2)`BB 0A 25 | 02 01 45 | 02 02 56 | 02 04 49 | 02 05 6C | 0D 06 … | 07 07 11 11 22 33 33 A4` 解出 **L=69% R=86%**,且**帧尾 MAC 与该耳机地址逐字节吻合**——这是校验块偏移算对了的关键证据, 不是凑巧解出两个合理数字。三分钟后复测右耳 86%→85%、左耳不变,确认是真实电量在掉。 ⚠️ **属性 3(充电盒电量)是可选的**:真机不在盒内时这帧只有 `[1,2,4,5,6,7]`,**没有 3**。 所以 `batteryCase` 停在 -1、UI 不显示充电盒那行是**正常行为**,不是解析失败。 耳机放回盒后属性 3 出现、解出 26%,这条已实测。 ⚠️ **充电指示位(`& 0x80`)至今没有实测样本**——只是照文档实现的。耳机在盒内时 三个电量的原字节都 <128,`charging*` 全是 false。这有两种可能且目前分不出来: ① 盒子当时确实没插电(那实现是对的);② 这版固件根本不用最高位表示充电, 充电状态得从别处读(那 `& 0x7F` 这条就是错的,且会在充电时把电量算错)。 **给充电盒插电、看 L/R 原字节会不会 >127** 就能定论,做之前别把这条当已验证事实。 ⚠️ 此前电量一直不显示,是 [bes_bluetooth_service.dart](apps/client/lib/devices/bes/bes_bluetooth_service.dart) 里**四个错叠加**,且每一个都不报错、只表现为「界面上那块干脆不出现」: 1. **请求码写成了响应码**:`queryBattery()` 发 `AA 0A`,把耳机的响应号当成了请求号。 固件不认,静默无回包。正确请求码是 `AA 09`(见上表),与响应码 `0x0A` 是两个不同的号。 2. **解析起点差一格**:`_parseBattery` 照抄 `_parseFirmwareVersion` 的 `p = 4`, 但固件版本帧长度字段占 **2** 字节、状态帧只占 **1** 字节。从 4 开始会把属性号 当块长度读成 `0x01 < 2` → 直接 break,**一个值都解不出**。 3. **充电盒属性认成 0x04**(实际是 `0x03`)。`0x04` 是耳机位置,值常 >100, 当电量读会被 0~100 的范围判丢。 4. **没做 `& 0x7F`**:充电时最高位置 1,原字节会变成 128~228 的假电量。 ⚠️ 曾据此把 `BB 0A` 判成「链路质量/信号强度」只记日志——理由是「值每秒乱跳、还超过 100」。 那是错位解析出的垃圾值造成的误判。真机那帧按正确规则解出的是 99% / 96% / 70%, 超过 100 的 `0x89`/`0x6E` 本来就是「耳机位置」和「连接状态」两个非电量字段。 `0x18` 帧(`[len,attr,value] × 2`,attr 01/02)文档里没有,**仍未确认含义**,目前只记日志。 实测 `BB 18 09 02 01 01 02 02 00` → 左 1 / 右 0,**会变**(早先记的「值恒为 1」不准), 但 0/1 这种取值不像百分比,更像入耳或在盒标志。`AA 18` 查不出东西。 别看它结构像电量就接上去——真电量已有 `BB 0A` 这个确定来源。 UI 在 [device_tab.dart](apps/client/lib/modules/main_tab/views/tabs/device_tab.dart) 的 `_buildBatteryInfo`:左右耳都有值就分开显示 L/R,只有单侧退回一个无标电池, 盒电量有就补一行 C,充电位驱动 `BatteryIndicator` 的充电图标。 ### 设备管理:恒玄连上要向服务端登记绑定(2026-09-01 补) 服务端这套**早就齐全**:`user_binddevice` / `user_unbinddevice` / `user_getdevices` ([modules/user/](apps/services/modules/user/),表 `userdevice`),带授权码校验、 绑定奖励、绑定/激活埋点。客户端设备管理页 `my_devices` 也一直在调 `getdevices` / `unbinddevice`。**中间缺的只有恒玄的上报那一步**: | 链路 | 何时登记 | 位置 | |---|---|---| | 杰理 | 点「绑定」按钮,pid/code 从 BLE 广播解析 | `devices_controller.bindAndConnect` | | 蓝讯支架 | connect 时自动 | `HolderDeviceService.bindToServer` | | **恒玄** | **原先没有** → 现在连上即登记(且当场确权,见下节) | `BesDeviceAuth.verifyConnected` | 所以在此之前,只用恒玄耳机的账号打开设备管理页**永远是空列表**, 看着像「接口没打通」,其实是两端都通、中间没人调。 挂点选在 `BesBluetoothService._listen()`(现在在 `lib/devices/bes/`)的 `event == 2` 分支,**不是扫描页**—— 被动回连(原生 ACL 广播触发)不经过任何页面,那条状态流才是所有连接路径的必经之地。 ⚠️ **服务端对经典蓝牙耳机是「按 MAC 反查授权码」**:`api_binddevice.go` 里 `Devicetype == Classic_bluetooth_Headset` 走 `getFactoryDeviceformac(product.Id, devicemac)`, 即**这台耳机的 MAC 必须事先由后台登记在该产品下**,否则 `AuthorizeNoCanUse`。 绑不上先去后台查 MAC 在不在,别在客户端找原因。code 传空是对的(恒玄没有 杰理那套广播授权码)。 ⚠️ **iOS 拿不到真 MAC**:`BesBluetoothService.connectedDeviceId` 在 Android 上是蓝牙 MAC,在 iOS 上是 CoreBluetooth 的 peripheral UUID(bluetooth_manager 的 `BluetoothManager.swift` 里 `p.identifier.uuidString`),**每台手机都不一样**, 不可能命中后台登记的 MAC。所以 `_deviceMacOrNull` 在非 Android 直接返回 null、 不上报——宁可 iOS 暂时没有记录,也不要往库里灌一堆匹配不上的假 MAC 把绑定统计打歪。 耳机固件的 `BB 0A` 遥测帧里带了本机 MAC(`0D 06 .. <本机MAC> .. <对端MAC>`, 见 `_handleRawFrame`),解出来就能补上 iOS,但那个 TLV 布局没实测过,要真机抓帧。 ⚠️ pid 从 `AppConfig.config.products` 里挑 `devicetype==1` 的产品,名字对不上就取第一个 (本项目只有恒玄耳机一种设备,退化是安全的)。**别用 `AppConfig.getproduct()`**—— 它内部 `firstWhere` **没有 orElse**,找不到直接抛 StateError。 其它两处一并修的: - 「先连耳机后登录」这个常见顺序下,连接事件里的登记会因未登录跳过。 `MyDevicesController.loadDevices` 进页面时补报一次,否则要等下次重连才出现。 - `MyDevicesController.isConnected` 原来只查 `BleManager`(**杰理**链路), 本项目用恒玄,于是恒为 false、列表永远显示「未连接」。现在先看恒玄再看杰理。 连接状态**只看本地蓝牙**,不问服务端——连接是此刻的链路状态, 服务端存的是绑定关系,两回事。 - 换账号要 `BesDeviceAuth.reset()`(挂在 `AccountService.clearUserData`), 否则「本进程已登记过」这个 set 会把新账号的绑定吞掉。 ### 固件升级页「版本不对」的排查口径(2026-09-02) 排查前先看这一行日志(`BesOtaUpgradeController`,成功时也打): ``` 产品配置: name=Echo-one pid=45091 服务端最新=0.0.2 → 解析后=0.0.2 设备当前=0.0.3 产品表共1个 ``` 四个值一起看就能定位,不用猜: | 现象 | 结论 | |---|---| | `产品表共0个` | `user_getappconfig` 没下发 products,问题在服务端 | | 有产品但 `name` 对不上 | `_findProduct` 匹配问题(名字互相包含 → 唯一 devicetype==1 → null) | | `服务端最新` 与库里不符 | 缓存问题,见下 | | **`设备当前` > `服务端最新`** | **不是 bug**:耳机固件比后台挂的还新,显示「已是最新」是对的 | 最后一条 2026-09-02 真实发生过:耳机已刷到 0.0.3,后台产品表还是 0.0.2,看着像「拉取的版本不对」, 其实客户端拉到的一字不差。**要测升级流程得先去后台传一个更高版本的固件并设为当前使用版本。** ⚠️ **products 走 Redis 缓存,TTL 24 小时**(`comm.Cache_Product`,见 `modules/user/model_cache.go`)。 刷新只有两个时机:服务启动预热、后台改数据触发 `Rpc_ModifyAppConifg` 事件。 后台改了版本但客户端还是旧值时,先查 Redis 里的实际内容再怀疑代码: ```bash redis-cli -a -n 1 HGET ':cache:product' '' ``` ⚠️ 客户端 `AppConfig` 是**纯内存**、每次启动重拉,不存在本地旧缓存,别往这个方向查。 ### 设备确权:连上就校验 MAC,不通过直接断(2026-09-02 加) 上节那次「登记」的返回值原先是**丢掉的**——绑不上只在日志里留一行,设备照用不误。 现在把同一次调用升级成了闸门:[bes_device_auth.dart](apps/client/lib/devices/bes/bes_device_auth.dart) 的 `BesDeviceAuth`(由 `bes_device_binding.dart` 改名而来,旧类名已不存在)。 **服务端没动,也不需要动**——`user_binddevice` 对经典耳机本来就是按 MAC 校验的 (`getFactoryDeviceformac(product.Id, devicemac)`,见上节)。「MAC 要和应用、产品对得上」 这三个条件是这样凑齐的:应用 → 产品表由 `user_getappconfig` 按当前应用下发, pid 只能从这张表里挑,服务端 `GetProduct(pid)` 同样是应用作用域;产品 → pid 由设备广播名 匹配产品名得出;MAC → 必须在该产品的 license 表里查得到。 **第一次连才走服务端,之后读本地白名单**(GetStorage 的 `bes_authorized_macs`)。 命中就同步放行、不打接口,所以回连和离线都不受影响,代价只有首连那一次往返 (带 10s 超时——确权排在握手前面,dio 那边 connect/receive 各 30s,弱网时不限制会让 首连的固件版本/电量查询干等一分钟)。 结论是三态,`unknown` **放行**: | 结论 | 什么情况 | 动作 | |---|---|---| | `authorized` | 白名单命中 / 服务端确认 / 已在 `User.devices` 里 | 继续握手 | | `denied` | 服务端明确拒绝(这台 MAC 没登记在该产品下) | 断开 + 移出 pairedDevices + 弹「设备连接受限」 | | `unknown` | 未登录 / 断网 / 产品表没下发 / iOS | **放行** | ⚠️ **`unknown` 放行是刻意选的失败方向**:判严的代价是**合法用户一断网就用不了自己的 耳机**,判宽只是未登记设备离线时能用一会儿、联网第一次确权就被拦下。前者严重得多。 ⚠️ **`null` 和抛异常含义完全相反,绝不能合并处理**:`AuthInterceptor` 在业务码 != 0 时是把 data 置 null 后 **resolve(不抛)**,网络层出问题才抛 `DioException`。所以 `resp == null` = 服务端明确拒绝 → `denied`;`catch` = 没连上服务端 → `unknown`。 写成 `try { if (resp == null) ... } catch { 同样处理 }` 就等于断网即锁死设备。 ⚠️ **denied 时必须把设备移出 `pairedDevices`**,不然 `tryAutoConnect` 和原生 ACL 广播 立刻把它连回来,变成「连上→弹窗→断开→连上」的死循环。传给 `removePairedDevice` 的要是 **原始 `connectedDeviceId`** 而不是大写化后的 MAC——存进去的是 `connectToDevice` 当时那个 串,大小写得逐字对上才删得掉。 ⚠️ **iOS 上这道闸门等于没有**:拿到的是 CoreBluetooth peripheral UUID 不是 MAC (理由见上节),一律 `unknown` 放行。别以为它两端都生效了。 ⚠️ **白名单不按账号分**:确权说的是「这台硬件是不是正品」,是设备属性不是账号属性, 换账号不该让同一台耳机重新变可疑。所以登出的 `reset()` 只清进程内的上报去重集合、 **不动白名单**;`clearAllUserData` 的 `_storage.erase()` 会连白名单一起清,那是整机重置,符合预期。 ⚠️ **解绑时必须 `BesDeviceAuth.forget(mac)`**(挂在 `MyDevicesController.unbindDevice`): 白名单命中会跳过接口,不清的话解绑之后再连上不会重新登记,**设备管理页就永远空着**。 副作用是解绑完再连一次又会自动绑回来——这是「不需要手动点绑定」的必然结果,不是 bug。 ⚠️ `user_binddevice` 的业务错误已加进 `AuthInterceptor._shouldSuppressBusinessErrorSnackbar`, 否则用户先看到的是后端的英文内部状态词 `AuthorizeNoCanUse`。杰理的手动绑定按钮 (`DevicesController.bindAndConnect`)本来就自己弹 `deviceUnauthorized`,不受影响。 **已知体验瑕疵(未修)**:扫描页的 `_finish()` 走 `ever(deviceStatus)`,比确权的网络往返快得多, 所以未授权设备会先弹「连接成功」、跳到主页,几秒后才被断开并弹「受限」。逻辑是对的 (设备确实被断开移除),要消掉得改扫描页等确权结果。 ### 设备表合表:license_ 分表 → 全局 device_mac(2026-09-04) **设备的身份是它自己的 MAC,本来就全局唯一,所以定位一台设备不需要 pid。** 原先按产品切分表(`license_`),pid 既是查询键、又得由**客户端猜**出来, 猜错就报「这台 MAC 没登记在该产品下」——提示指向数据缺失,实际数据好好的。 触发这次改造的事故:一台已正确导入的 DEEPVOICE 耳机(MAC 在 `license_b022`, 产品 45090,status=0 未绑过,数据完全正常)连不上,弹「设备连接受限」。根因是 **产品 45090 的 `devicetype` 被配成了 2(BLE双端)而不是 1(经典蓝牙)**,于是: 1. 客户端 `_resolveProductId` 第一步就 `where(devicetype == 1)`,把 DEEPVOICE 整个产品滤掉; 2. 候选只剩 Echo-one,名字「DEEPVOICE」vs「ECHO-ONE」互不包含 → 走兜底 `headsets.first.id` = **45091**; 3. 服务端拿 45091 去 `license_b023` 查那个 MAC → 查不到 → `AuthorizeNoCanUse` → denied → 断开。 而且**就算 pid 猜对了也过不去**:`api_binddevice.go` 只有 `Devicetype == Classic_bluetooth_Headset` 才走 MAC 反查,devicetype=2 会走 else 分支从 `code` 反解 pid,恒玄链路 code 传空 → 反解失败 → 回退写死的 45058 → 照样查不到。 #### 改成了什么 | | 之前 | 现在 | |---|---|---| | 表 | `license_`,一产品一张 | **`device_mac`,全局一张**(`comm.TableDeviceMac`) | | 定位设备 | `(客户端猜的 pid, MAC)` 查对应分表 | 先 `code` 后 `devicemac`,**全局查**,命中即可 | | pid | 客户端传上来的**查询条件** | 从命中行的 `productid` 列**读出来的结果** | | 客户端 | 查本地产品表挑 pid、按名字匹配 | **只报 MAC**,不传 pid | | 应用隔离 | pid 隐含(产品表按应用下发) | **不隔离**——MAC 存在就放行(明确取舍) | - 迁移在 [registry.go](apps/services/modules/console/registry.go) 的 `migrateLicenseToDeviceMac`, 每次启动跑、幂等:① `code := devicemac` 修存量 → ② 按**两表列名交集**做 `INSERT ... ON CONFLICT DO NOTHING`(不能 `SELECT *`:各分表建于不同时期, 有的多 `factoryid`/`probatch` 两列)→ ③ `productid` 空的用表名里的 pid 补上。 孤儿分表(产品已删但表还在)里也是真实发出去的设备,一并搬。 - ⚠️ **搬完刻意不删旧表**。这是不可逆数据(已发到用户手上的设备),旧表是出问题时 唯一的回退依据。确认新表跑稳后由人手工 DROP。删产品时会顺手 DROP 掉它那张空的旧分表。 #### ⚠️ 合表后每个「按产品」的读写都必须显式带 productid 分表时代,`license_` 这张表**隐含**了产品作用域;合表后这层隔离没有了, 漏一处就跨产品操作——查询多列出别人的设备只是难看,**批量禁用 / 按批次删除漏掉 就是直接删别的产品的数据**。所以统一走 [comm.ScopeToProduct](apps/services/comm/devicemac.go) 把 `productid=?` 拼进条件(`pid==0` = 不限产品,后台「全部产品」视图用)。 它会把调用方传来的条件整体括起来——传 `"a=? or b=?"` 时不括会让 `or` 把 productid 短路掉。 按主键 `code` 保存/查询的地方不需要(主键全局唯一),签名里的 pid 已改成 `_`。 ⚠️ **导入查重改成全局**(`dvAuthCodesByCodes`):同一个 MAC 出现在两个产品名下, 会让「按 MAC 反查设备」出现歧义——绑定时算哪个产品说不清。所以跨产品重复也要在导入时挡住。 ⚠️ 后台页面上那句「MAC 前 2 字节即 PID」只对**系统生成**的 MAC 成立。 **导入**模式的 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 收紧) 额度有两个来源,**判据完全不同,别混**: | | 触发 | 判据 | 位置 | |---|---|---|---| | **新用户开户礼** | 注册即到账 | 「用户是新建的」 | [newuser_gift.go](apps/services/modules/user/newuser_gift.go),30天VIP + 翻译/会议各100分钟 + AI 100次 | | **设备绑定礼** | 绑定设备 | 「**这台设备**此前没被绑过」(`codestate == 0`) | `api_binddevice.go`,额度按生产批次配(`resolveBindRewards`) | ⚠️ **绑定礼的条件 2026-09-02 从 `!user.Isgiveaway || codestate == 0` 收紧成 `codestate == 0`**。原来多出来的 `!user.Isgiveaway` 是个口子:只要这个**用户**没领过 绑定礼,哪怕设备是二手的(已被别人绑过、权益也已经发出去过一次),照样再发一整份—— 一台设备转手几次就能送出几份 VIP/翻译/会议时长。现在的语义是**设备权益只给第一个 绑定它的人**。 `codestate` 是可靠判据,刷不了:解绑处的 `model.Status = 0` 是**注释掉的** ([api_unbinddevice.go](apps/services/modules/user/api_unbinddevice.go),只清 `Uid`), status 一旦 0→1 就回不去,所以「绑了解、解了绑」拿不到第二份。 ⚠️ **`user.Isgiveaway = true` 那行仍然要写,但理由已经变了**:它原先是厂家公码 (`api_bindauthcode.go`)的防重领标记,而**厂家公码已于 2026-09-12 整体下线** (页面、接口、`factory_public_code` 表全删),这个字段因此**没有任何读取方**了。 保留写入是因为它是 `DBUser` 上的存量列、老客户端仍会解析它,删字段会让已发布的 App 整个 `fromJson` 抛 TypeError(同 `factoryid`/`devicetype` 那个坑)。 它**不**参与绑定礼的发放判断。 ⚠️ 赠送额度三个桶**不同构**:`Tradeintegral`/`Meetintegral` 是**秒**(配置填的是分钟, 要 ×60),`Aichatintegral` 是**次数**,分钟在这个桶里没意义。 ### 录音页退不出去(2026-08-31 修) 通话录音 / 现场录音共用的 [call_recording_view.dart](apps/client/lib/modules/call_recording/views/call_recording_view.dart) 是 `PopScope(canPop: false)`,**返回回调是这个页面唯一的出口**,所以它里面任何 一次卡住或抛异常都会直接表现为「进了界面出不来」。三个已修的坑: 1. **「结束录音并保存」原先 `await toggleRecording()` 之后才 `Get.back()`**。 这条链末端是归档:`echomeet_addrecord`(Dio 30s connect + 30s receive), 而通话中数据网络本来就可能不通 —— 用户点完按钮后页面能纹丝不动一分钟。 现改成**先 `Get.back()` 再让停止/落盘/归档在后台跑**:这些活儿由全局 `BesCallRecordingService` / `HolderRecordingService`(或 controller 自己持有的 麦克风/杰理通路)完成,跟页面在不在没关系。 2. **controller 的 tag 原先在每次 build / 每次回调里现取 `Get.arguments`**。 GetX 4.7 的 `Get.arguments` 是「全局当前路由」的参数,**任何 dialog/bottomSheet push 都会把它冲掉**(`GetObserver.didPush` 里 `value.args = route.settings.arguments`)。 退出确认弹窗一弹它就变 null,tag 退化成默认的 `call_recordings`,现场录音页 当场 `Get.find` 不到自己的 controller,返回键连带失效。现在改成 StatefulWidget,在 `initState` 里把 tag / arguments / 路由名一次性定死。 3. **退出时的 400ms 延时 `Get.delete` 会误杀刚重新进来的页面**(binding 见 controller 还注册着就复用,随后旧页面那颗延时炸弹把它删了)。现在按 tag 记活跃页面数,还有人用就不删。 另外返回回调整体裹了 try/catch,异常时兜底直接 `Get.back()` —— 宁可少弹一次 确认框,也不能让返回键变成永久空操作。 ### Android 渠道:国内五家共用一个包(2026-08-29 起) 华为/荣耀/小米/OPPO/vivo **发同一个 APK**,不再按渠道分别打包(`build_channels.sh` 只剩 google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel` 区分的逻辑,现在一律 **运行时按设备品牌推导**: - `getAndroidStoreInfoByBrand()`([version_update_service.dart](apps/client/lib/data/services/version_update_service.dart)) 把 `_channel` 设成品牌推导出的渠道,`.env` 的 `Channel` **只作兜底**(品牌认不出的三星/魅族等才用)。 渠道统计、游客登录/跳过设备绑定开关、版本更新地址都走这一个值。 - ⚠️ `GLOBAL_GOOGLE` 与 `OFFICIAL` **不参与品牌推导**:前者装在华为手机上仍须连 `HW_SERVER_URL` (`dio_manager` 的 baseUrl 只认这个值),后者是官方/内测包才显示的"工具打断"菜单开关。 国内包的兜底值别填这两个。 - ⚠️ `.env` 的 `Channel` 必须写 `UserChannel.fromName` 认识的全名(`CHINA_XIAOMI`), 写成 `xiaomi` 会被解析成 `UNKNOWN`。 - 华为/荣耀商店要求"每次启动重新确认隐私政策",这条判断在 [login_controller.dart](apps/client/lib/modules/login/controllers/login_controller.dart) 的 `_initDeviceInfo()` 里,用 `DeviceBrand.isHuaweiOrHonor(brand, manufacturer)` ([device_brand.dart](apps/client/lib/core/utils/device_brand.dart),两个字段任一命中即算, 老荣耀机型 brand=HONOR/厂商=HUAWEI)。**别改回读 `versionService.cachedChannel`**——那个值在版本 检查跑完前会回退成 `GLOBAL_GOOGLE`,无网启动时合规逻辑直接失效。 `_hasAcceptedPrivacy` 初值 `false`,品牌读出来之前默认"未同意",失败方向偏向合规。 - 口径偏差是已知的取舍:判断依据从"从哪家商店下载"变成了"用哪家的手机"。审核实测都在对应 品牌真机上做,能过审;反向(小米商店的包装在华为手机上)只是多弹一次。 ### 版本更新与强更判定(2026-08-31 改) 冷启动一定会查版本:splash 的 `_performParallelTasks` 顺序是 **网络检查 → `_performVersionCheckAsync` → `_validateTokenAsync`**,强更弹窗不可关闭, 会把 splash 卡住、走不到 token 校验,这是预期行为。`checkForUpdate` 的 10 分钟缓存是 静态字段,杀进程即失效,所以「大退重进」必然是一次真实检查。 **强更判定的唯一依据是后台「服务配置 → 版本控制」的 `forceupdate` + `minversion`** (随 `user_getchannelapps` 下发)。早先客户端自己拍了两条土规则——「主版本号第一位不同」 和「版本串里有两个 `+` 号」——与后台配置完全脱节:运营勾了强更拦不住人, 一次大版本升级又会把所有人无差别强更。别改回去。 - `AppInfo` 早先根本没解析 `minversion`/`forceupdate` 这两个字段,是这次一并补的。 同一处的 `paychannels` 键名曾写成大写 `Paychannels`(服务端 json tag 是全小写, 见 db.proto 的 `DBChannelApp`),也就是说支付渠道一直解析不到;现已两种键名都认。 - 版本号比较统一走 `VersionUpdateService.compareVersion`,与服务端 `comm.CompareVersion` 逐段按整数比 + 取前导数字的口径**逐行对齐** (`test/version_compare_test.dart` 与 `comm/channelapp_test.go` 同一组用例守着)。 原实现用 `int.parse` 解析,遇到 `1.0.0-beta` 直接抛,被外层 catch 吞成「无更新」。 - 服务端另有 `user_getchannelapp`(**单数**)会把 `needupdate/forceupdate/reviewing` 三个结论算好下发,客户端目前没用它,仍走复数接口自己算——所以两边口径必须一致。 ### 配置分家:channel_app 与 app_release(2026-08-31) 后台的发版类配置拆成两张表,边界是「**这一项会不会因渠道而异**」: | 表 | 维度 | 装什么 | 后台入口 | |---|---|---|---| | `app_release` | 一应用一行 | 线上版本、最低支持版本、强更开关、更新日志、**默认**下载地址、送审版本、游客登录开关+生效版本 | 服务配置 → 版本控制 / 游客显隐控制 | | `channel_app` | (应用, 渠道) | **本渠道**下载地址、上架状态、支付渠道、是否允许跳过设备绑定 | SaaS 管理 → 渠道分发 | 拆分的理由是踩过的坑:版本号和游客开关本来就是整个应用一套,按渠道各存一份意味着 同一个开关有 8 处能改而客户端只读一处 —— 出过「后台明明开了游客登录、App 就是不显示」的事故。 - 下发时由 [model_user.go](apps/services/modules/user/model_user.go) 的 `toPbChannelApp` 把两张表拼成一条 `pb.DBChannelApp`,**客户端协议没变**,不需要发新包。 - **下载地址刻意保留渠道级覆盖**:客户端是直接把 `address` 当 URL 打开的 ([version_update_service.dart](apps/client/lib/data/services/version_update_service.dart) 的 `performUpdate`), 华为/小米/App Store 链接各不相同,一套地址会把小米用户引到别家商店。 渠道填了用渠道的,留空回退 `app_release.address`,见 `AppRelease.DownloadAddress`。 - `comm.ChannelApp` 上搬走的那 7 个字段**已从 struct 删除**,判定方法 (`NeedForceUpdate`/`NeedUpdate`/`IsReviewing`/`ShowTourists`) 一并移到 `AppRelease`。 gorm 不会删列,旧数据静静留在 `channel_app` 表里但不再读写。 - ⚠️ **没有做数据迁移**:升级后要在新页面把版本与游客配置重填一次 (改动时两个环境的 `channel_app` 都是空表,故未写 backfill)。 ### 默认主题是浅色,不跟随系统(2026-08-31 改) `ThemeService.getThemeMode()` 在没有保存偏好时返回 `ThemeMode.light`。 **刻意不跟随系统**:设置页只有一个「暗黑模式」开关,读的是 `isSavedDarkMode()`(默认 false), 跟随系统会让深色手机上装完就是暗的、开关却显示「关」。要恢复跟随系统, 得先把设置页改成三档(浅色/深色/跟随系统)——存储层的 `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` 同时被 `pubspec.yaml` 声明为 asset —— 文件不存在**编译期就报 asset 缺失**。已提供 `.env.example`(key 与真实 `.env` 逐项对齐)。`.env` **在 .gitignore 里,不入库**(源仓库 `eaimar_client-main` 的 .gitignore 同样有 `.env*`,本来就从不进 git —— 所以**换机器/重新拉代码后必须手工补 `.env`**,这是搬仓时最容易漏的一步)。 - 真实 `.env` 里 `VOLCANO_*` / `OPENAI_*` **全是空值**:这些第三方能力已改由后端 `AppConfig.env()` 下发, 本机只在绕过后端直连调试时才填。实际必填的只有 `SERVER_URL` 和 `Channel`。 - `.mcp.json` 同样被 pubspec 声明为 asset(`open_ai_service_adapter.dart` 里的加载代码虽已注释, 删文件仍会构建失败)。里面是两个真实 MCP server(`mcp.ideapsound.com` / `api.deapsound.com`),会入库。 ### 仓库卫生(重要) `apps/client/.gitignore` 挡掉的构建产物占 5.8G 中的 5.6G:`build/`(5.2G)、`ios/Pods/`(311M)、 `android/.gradle/`(120M)。实际入库约 **118MB / 3200 文件**,其中 ~40MB 是构建必需的预编译 SDK (`local_plugins/azure_speech` 的 MicrosoftCognitiveServicesSpeech.xcframework、`device_jieli` 的 JL_BLEKit 等)。 **`build/` 与 `.dart_tool/` 的规则故意不带前导斜杠**,这样才能一并覆盖 `local_plugins/*/android/build` 等嵌套产物。 ⚠️ `android/app/sign/eaimar.keystore`(Android 发版签名库)目前**会入库** —— 与本仓库"私有仓库下接受真实凭据" 的既有取舍一致,且不入库会导致其他人打不出可升级的正式包。仓库若要公开,这个必须先摘出去。 ### 服务器域名(2026-08-28 统一) 自有服务器域名**一律 `ymaikj.com`**,上一代的 `voitrans.net` / `voitrans.com` / `ideapsound.com` / `deapsound.com` 已全部清除(含 41 个语言文件里用户可见的 `web.voitrans.com`)。现役清单: | 用途 | 域名 | 位置 | |---|---|---| | 业务后端 | `ym-dev.ymaikj.com` | `.env` 的 `SERVER_URL`/`HW_SERVER_URL` | | 支付回调 | `pay.ymaikj.com` | `pay_service.dart`、`goods_base_controller.dart` | | 会议分享 | `share.ymaikj.com` | `share_bottom_sheet.dart` | | Web 站点 | `web.ymaikj.com` | `member_bottom_sheet.dart`、`meeting_mine_view.dart`、各语言文案 | | 微信 Universal Link | `ymaikj.com` | `login_service.dart`、`pay_service.dart`、`ios/Runner/Runner.entitlements` | | MCP(死配置) | `mcp.ymaikj.com`、`api.ymaikj.com` | `.mcp.json` | ⚠️ **微信 Universal Link 改了代码还不够**,三处必须完全一致,否则微信登录与微信支付**静默失败**: ① `universalLink: 'https://ymaikj.com/app/'`(两个 dart 文件) ② `Runner.entitlements` 的 `applinks:ymaikj.com` ③ **微信开放平台后台登记的 Universal Link** + `https://ymaikj.com/.well-known/apple-app-site-association` 代码侧(①②)已改完,**③ 属于外部系统,发版前务必确认已同步**。 ⚠️ 另外两处含 `voitrans` 但**不是域名、不要改**: - `lib/firebase_options.dart` 的 `projectId: 'voitrans'` / `'voitrans-e0e3d'` —— Firebase 项目标识, 改它要在 Firebase 控制台另建项目并换掉 `google-services.json` / `GoogleService-Info.plist`。 - `lib/core/translations/` 里的 key 名(`copyrightVoitrans`、`voitransPrivateCloud`…)—— 是标识符不是文案, 用户不可见,改了要 41 个语言文件连同引用处一起动。 `share_bottom_sheet.dart` 的分享链接格式是 `share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{token}`, **路径第二段是品牌标识、不是要请求的域名**,由 share 端解析,别当成域名误改。 ### 其他 - 状态管理 GetX;目录分层见 `apps/client/README.md`(`core/` `data/` `modules/` `routes/`)。 - `local_plugins/` 下 25 个本地插件用 `path:` 依赖,蓝牙/语音/音乐等 native 能力都在这里。 `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` 表的同名键 | | 应用环境配置 | 先并入「服务与环境配置 → 应用参数」标签页;**2026-09-14 连标签页也下线**(见下文) | 凭据搬走后只剩应用自己的业务参数,而这些也搬进了 svc_config | | 第三方服务配置 | 改名「服务与环境配置」,成为凭据唯一出处;2026-09-14 菜单改回「第三方服务配置」 | — | | Agent 配置 / 通话翻译配置 | **删除**(页面 + console 接口) | `agent_config`、`call_translate_rule` 在测试机与正式机上**都是 0 行**;通话翻译选路全在端侧 `language_manager.findBestMatchingProvider` | | 会议记录服务 | **删除页面**,⚠️ 数据与运行时保留;2026-09-17 以「会议配置」的形式回来(见下文) | 见下方警告 | ⚠️ **两张扁平 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 = svc_config 各服务字段按 env_key 平铺(2026-09-14 起;9-12 ~ 9-14 之间还有业务库 config 表打底) ``` - `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;`。 ### 「应用参数」标签页下线:业务库 config 表不再下发(2026-09-14) 后台只剩「第三方服务配置」一个页面。原第二个标签页是业务库 `config` 表的裸 key/value 编辑器, 而 `user_getappconfig` 把这张表**整张**塞进 `env`——COS 密钥、灵犀产品密钥、连「会员与算力」页写进去的 `COMPUTE_RATE_*` / `COMPUTE_GATE` 都明文发到每个客户端。逐项 grep 过服务端/客户端/原生插件/后台后, 两台机上完全相同的 22 行处置如下(迁移在 [migrate_appparams.go](apps/services/modules/console/migrate_appparams.go), console 启动跑、幂等、排在 `migrateGlobalScopeToApps` 之后): | config 表的键 | 去向 | |---|---| | `AGENT_TYPE` / `MOBILE_ELF_AGENT_ID` / `MOBILE_ELF_PRODUCT_ID` / `MOBILE_ELF_PRODUCT_KEY`(加密) / `YIDONG_PID` | 服务 **`llm_mobile_elf`**(中国移动 灵犀 / 移动精灵,类别「文本大模型」只作分组),字段带同名 `env_key` | | `iapCustomerServiceQQ` | 服务 **`app_params`**(内置类别 12「应用参数」),字段 `iap_customer_service_qq`;要加新的应用参数就往这个模板加字段 | | `COMPUTE_*` / `NEWUSER_GIFT_*` / `VIP_WARN_DAYS` / `COMPUTE_WARN` / `COMPUTE_LEGACY_MIGRATED` | 留在 `config` 表,由「会员与算力」页维护,**不下发** | | 音乐 ×5 / 公码 ×2 / 导航 / `COS_*` ×5 / `MeetServers` | 无人读,删 | | 其它不认识的键 | 保留 + 启动日志告警(已无后台入口也不下发,要么加进落点表要么手工删) | - 客户端四处读取改成 `AppConfig.cred('llm_mobile_elf','agent_type','AGENT_TYPE')` 这种形状,老键名作兜底。 `AppConfig.env(` 在 lib 里已是 **0 处**调用;新代码别再用它,凭据/参数都走 `cred()`。 - 图片翻译的 `ALIBABA_VL_MODEL` / `ALIBABA_VL_ENDPOINT` 原先哪张表都没有(客户端一直用写死的默认值),现在是 `ast_alibaba` 上的 `vl_model` / `vl_endpoint` 两个明文字段(留空=客户端默认)。**没挂到 `llmv_qwen`**: 那条是服务端会议总结用的 `qwen-plus`,`base_url` 是不带路径的根地址,与客户端要的视觉模型/完整地址对不上。 - `llm_mobile_elf` **停用 = 不下发 `AGENT_TYPE`** = 客户端 AI 球一律进 EMAI,这是预期行为。 - console 的 `appcfg/*` 四个接口、api 模块的 `api_getconfig/addconfig/updateconfig/delconfig` 已删(后台与客户端都没有调用方)。 - 顺手修了 [model_config.go](apps/services/modules/user/model_config.go) 的 `getmcpservers`:作用域归一后它还在查 `app_name=''` 的全局层,MCP 类服务永远读不到、且不报错。现按 `comm.AppName()` 读本应用。 ### 服务配置按应用隔离 + 类别可增删改(2026-09-14) **没有「全局默认」层了。** 原模型是 `app_name=''` 全局默认 + 应用覆盖(应用没配就回退全局行), 现在每个应用一套、**互不干涉**:应用 A 删掉某个服务,不会悄悄回退到别人那份。涉及五张表, 作用域键都是 `app_name`:`svc_config` / `svc_region_override` / `echomeet_orch` / `echomeet_orch_setting` / `echomeet_template`(公共模板也按应用分了,后台「会议模板」页多了应用选择)。 - 迁移在 [migrate_scope.go](apps/services/modules/console/migrate_scope.go),console 启动跑、幂等: **首个应用(`app_registry.id` 最小)拿原行改名**(id/row_id 保住,`echomeet_record` 里存的模板 id 不断), 其余应用各拷一份。跑完全局层为空;运行时那 16 处 `OR app_name=''` 回退分支再也匹配不到行,代码没删。 - ⚠️ **作用域键是 `app_registry.app_name`(应用中心的应用名,如 `EAIMAR` / `deepGlass`),不是 `app_registry.name`(部署名 `deepglass`)**。运行时 `ANALYZE_APP_NAME` 实测等于 `app_name`; 后台下拉用的也是它。用错列会让 deepGlass 一个服务都读不到,且不报错。 - ⚠️ `migrateEnvToSvcConfig` 在作用域分配之后**必须跳过**(已加守卫:`svc_config` 有非空 `app_name` 行即返回)。 否则它会从 `global_config` 把服务重新建回全局层,再被分配时与首个应用撞 `(app_name,id)` 唯一键。 新应用要凭据走后台「从其它应用复制」(`api_copysvcconfig`,密文原样搬不经前端),不再从 `global_config` 长出来。 - 后台所有写接口 `app_name` 必填(`svcScopeOK` / `meetTplScopeOK`),空值直接拒绝——前端漏传就会在谁也看不见的作用域里建出服务。 - 后台老账号的 `requireAppNameScope` 现在真的被调了(删 Agent 页之后它一度没有调用方)。 **类别落库**(`svc_category`,[model_svccategory.go](apps/services/modules/console/model_svccategory.go)),后台「类别管理」可增删改(内置 12 个,第 12 个「应用参数」是 9-14 加的)。 两种东西要分清:**展示属性**(名称/角标/颜色/排序/启用)任何类别都能改;**运行时语义**只挂在 `comm.SvcCat*`(MT=3 `user_translate`、AST=4 VIP 过期停发、MCP=10、实名=11 永不下发)和模板/会议编排引用的 内置 id(1/2/5/8/9)上——**内置 11 个 id 不可改不可删**,只能停用。自定义类别(id ≥ 100)只作分组: 叫「实名」也不会获得「不下发」的保护,`server_only` 是从 comm 读出来的只读属性,刻意不落库。 删自定义类别前查 `svc_config.categories` 与 `third_svc_template.category` 的引用,有就拒绝。 前端 `SCAT` 现在是从 `api_getsvccategories` 加载的 computed,形状与原写死数组一致;`VOICE_CATS`/`SERVER_ONLY_SCATS` 改为读类别的 `voice`/`server_only` 标记。 ⚠️ **未升级到本版的应用服务会看到两份会议模板**:老代码查 `source='public'` 不带 `app_name`, 迁移后两个应用各 287 条 → 老 deepglass-a11 会拿到 574 条(重复)。升级到本版即消失。 ### 服务商模板 ≠ 已接入:科大讯飞 AIUI(2026-09-14 加) 「服务商模板」只是**后台建服务时的字段清单**——加一条模板,运营就能在「第三方服务」里建出这个服务、 填凭据、按区域分叉,凭据也会随 `user_getthirdsvcs_v2` / `user_getappconfig_v3` 的 `thirdsvcs` 下发。 但**端侧有没有代码去用它是另一回事**。 `sts_iflytek_aiui`(科大讯飞 AIUI,类别 STS=7)当前就是这个状态:**模板有了,全项目没有任何一处调用** (搜 `aiui` 零命中)。所以配完不会有任何效果,也不会报错——要真正用起来,客户端得按 `AppConfig.cred('sts_iflytek_aiui', '<字段>', '')` 取值并实现那条 WebSocket 链路。 - 归 STS 不是 STT:AIUI 是「唤醒→识别→语义→内容服务→合成」一整条,吐的是**答复**不是转写文本。 归错类别会被会议记录/通话翻译那些按类别选路的地方选中,然后拿不到期望的结果。 - 鉴权自成一套,别照别家套 key/secret:请求头 `X-Appid` / `X-CurTime` / `X-Param`(业务参数 base64) / `X-CheckSum = MD5(api_key + X-CurTime + X-Param)`。必填凭据只有 `app_id` 与 `api_key` 两个。 - ⚠️ `auth_id` 是**每用户/每设备一个**的标识(32 位小写字母+数字),AIUI 拿它存个性化数据与多轮上下文。 模板里默认留空(由端侧按设备生成),**填死等于全部用户共用一份上下文**,A 说的话会进 B 的对话历史。 - `languages` 预填为空是正常的:内置音色表只覆盖 azure / alibaba(`comm.TTSProviders()`), 讯飞不在表里,和 `sts_google` 同一情况,由运营自己填;巡检会提示「没配语言」。 - 契约由 `TestIflytekAIUITemplate` 守着(类别、必填字段、api_key 必须加密、默认 ws 地址)。 ### svc_config 的四个坑(2026-09-12 阿龙测试环境实测踩出来的,都不报错) 这四条对**任何**改第三方服务配置的人都成立,不限于那次迁移: 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)。 **转写段与总结段的后台入口已于 2026-09-17 在「会议配置」页恢复(见下节);翻译段仍只能改库。** ⚠️ 存量后台账号 access CSV 里的 `globalconfig`/`appconfig`/`agents`/`meetingsvc`/`calltranslate` 不再对应任何菜单项,权限编辑器里也勾不到了。只勾过 `appconfig` 的账号由 [auth.ts](apps/admin/app/stores/auth.ts) 的 `hasAccess` 兼容到 `svcconfig`,不会因为一次合并丢入口。 ## 「会议模板」改成「会议配置」:一页管转写/总结/模板(2026-09-17) 后台菜单项 `meettemplate` 的**标签与路由**变了(`/meettemplates` → `/meetconfig`,旧地址留重定向), **菜单 id 没变**——存量后台账号的 access 清单里存的是这个串,改 id 等于让这些账号丢入口。 **一页全展开,不分标签页**:上半区两栏(左「会议转写」、右「会议总结」,1100px 以下叠成一列), 下半区「会议模板」占满剩余空间、内容多了整页往下滚(**刻意不给模板区单独开滚动条**—— 模板卡片网格本来就高,嵌套滚动会出现两条滚动条、还会把筛选条挤在半空)。 顶部**一个**应用选择器三块共用(都按应用隔离,各拉各的会出现「转写配的是 A、模板改的是 B」 这种对不上的状态): | 区块 | 配什么 | 存哪 | |---|---|---| | 会议转写 | 有序选若干个「录音文件识别」(类别 8) 服务 | `echomeet_orch` kind=1,顺序 = `priority` 升序 | | 会议总结 | 单选一个「文本/多媒体大模型」(类别 5/9) 服务 | `echomeet_orch` kind=3,选中的那条 `is_default=true` | | 会议模板 | 原「会议模板」页原样搬来 | `echomeet_template`,`source='public'` | 模板页本体已拆成组件 [MeetTemplatePanel.vue](apps/admin/app/components/MeetTemplatePanel.vue)(应用名由父页面以 prop 下发), 页面壳是 [meetconfig.vue](apps/admin/app/pages/meetconfig.vue),后端是重新写的 [api_echomeet_orch.go](apps/services/modules/console/api_echomeet_orch.go)(`api_getechoorch` / `api_saveechoorch`)。 - **「选一个就用一个、选两个第二个兜底」是运行时本来就有的行为**,这一页只是把它的入口补回来: `PickASR` 按 priority 升序取第一个「已启用 + 支持源语言」的,提交失败由 `NextASR` 顺延下一家 (2026-09-15 线上 `asrfile_alibaba` 的 key 失效、排后面的 `asrfile_azure` 完好却一次没被碰, 就是因为当时还没有 `NextASR`)。**总结段没有兜底**:`PickLLM` 只认 `is_default`,选中的不可用就直接失败。 - ⚠️ **保存是声明式的,两段各自独立**:请求里 `asr` / `llm` 都是**指针**,缺哪个就不动哪段。 写成值类型的话「保存会议总结」会带一个空的 `asr` 数组过来,把转写编排整段清空。 - ⚠️ **翻译段 (kind=2) 本页不编辑,保存时必须原样保留**:它同样是语音纪要流水线的一环, 跟着一起 reconcile 会让纪要能转写、**译文整体消失**。 - ⚠️ **服务池查询是 `app_name=?`,不带 `OR app_name=''`**:2026-09-14 作用域归一后没有全局默认层了, 照旧版写回退查询会让一个应用读到另一个应用(或迁移前残留的全局行)的服务选型,且不报任何错。 - 本页只编辑 `region=0`(全区域)那一层。区域分叉(`region>0`)优先级更高、仍由运行时消费, 接口把它们的行数回报给前端并在页面上明说——后台显示的若与线上跑的不是一回事,排查会被带偏。 - 段 → 服务类别的映射在 `echoKindSvcCats`(转写 8、翻译 3、总结 5+9), `TestEchoKindSvcCats` 钉着它:类别编号是三处同写的(comm / console / admin 前端 `SCAT`), 漏改一处的表现是**某一段的下拉空空如也且不报任何错**,很容易去怀疑数据。 ## 第三方服务配置:服务端专用类别(2026-08-28 新增) `svc_config`(后台「服务与环境配置 → 第三方服务」)默认是**面向客户端**的:[svcresolve.go](apps/services/modules/user/svcresolve.go) 的 `resolveThirdSvcs` 会把 `encrypted` 字段**解密成明文**塞进 `user_getthirdsvcs_v2` / `user_getappconfig_v3` 的响应下发。 但有些第三方能力只能由服务端调用,凭据是**云账号主 AK/SK**(拿到就能操作整个云账号)。 这类服务放进 `svc_config` 是为了统一后台管理入口,**必须在下发口拦掉**: - `comm.SvcCatIdVerify = 11`(身份证实名核验)登记在 [comm/svcpool.go](apps/services/comm/svcpool.go) 的 `serverOnlySvcCats` 里;`resolveThirdSvcs` 用 `comm.IsServerOnlySvc` 整条跳过。 - ⚠️ **`svcresolve.go` 里那三行过滤是唯一拦截点**,删掉等于把主账号密钥明文发给每个 App 用户。 `comm/svcpool_serveronly_test.go` 守着这条底线。 - 新增服务端专用类别:`serverOnlySvcCats` 加一行 + admin `serviceconfig.vue` 的 `SERVER_ONLY_SCATS` 加一项 (前端那份只管界面提示,**不产生拦截效果**)。 ### 身份证实名核验 - 服务商:阿里云 `Id2MetaVerify`(`cloudauth.aliyuncs.com`,2019-03-07,`ResultObject.BizCode` 1=一致/2=不一致) 与腾讯云 `IdCardVerification`(`faceid.tencentcloudapi.com`,2018-03-01,`Result` "0"=一致/"-1"=不一致)。 实现在 [sys/idverify/](apps/services/sys/idverify/),**无状态工厂**(不同于其它 sys 子系统的启动单例)—— 因为配置在 svc_config 里按应用作用域存,调用时才解析。 - **调用失败 ≠ 不一致**:两个 provider 都只在服务商明确判定时返回 `Matched`,其余(网络/鉴权/额度耗尽/ 未知返回码)一律返回 error。把它们当成"不一致"会导致服务商欠费时全体用户看到"您填的信息有误"。 - 客户端接口:`user_idverify`(提交核验)、`user_getidverify`(查状态),见 [api_idverify.go](apps/services/modules/user/api_idverify.go)。 - **合规口径**:身份证号**全文不落库**,只存掩码(`4403**********1234`)与加盐 SHA-256 指纹。 盐来自 `ID_HASH_SALT` 环境变量,**留空则不写指纹**(绝不退化成裸哈希——身份证号空间有限, 裸哈希可被彻底反查)。盐一旦启用不要再改,否则历史指纹全部对不上。 - **一证可绑多号**:不拦截,但按指纹回查,后台用户页显示「同证件账号数」。 - **认证通过后 UI 要立刻跟着变**:卡片上的「未认证」角标走 `IdVerifyGuard.builder` (内部是 `Obx`),直接写 `if (IdVerifyGuard.required)` 读到的确实是 Rx 但不会被收集, 角标要等下次进页面才消失。性别回写统一在 `IdVerifyService._applyGender`: 改 `User.instance` + 落本地 `user_info` + `User.refreshTick++` 三件事缺一不可 (只改字段不触发任何 Rx;不落本地会被缓存盖回旧值)。ProfileController 挂了 `ever(User.refreshTick)` 把 `userInfo` 刷一遍。 - 限流:同账号 10 分钟内失败 5 次即拒(`idVerifyFailWindow`/`idVerifyFailLimit`)——服务商按次计费。 - 本地先校验身份证格式(GB 11643 校验位),把明显无效的输入挡在计费调用之前。 #### 账号 ↔ 实名记录的绑定口径(2026-09-19 梳理并修) 纯逻辑集中在 [idverify_binding.go](apps/services/modules/user/idverify_binding.go),落库在 `model_idverify.go`。 - **`useridverify` 明细是权威,`user.idverified/idverifiedtime` 只是派生缓存**(后台列表筛选用)。 客户端闸门 `user_getidverify` 只读明细。两边是两次独立写,所以 home 启动时 `reconcileUserFlags` 双向校正(明细通过而标识没打 → 补;标识打了而明细没通过 → 清),幂等。 - ⚠️ **已实名账号「修改认证」失败(不一致 / 服务商报错)时,明细上的身份字段一律不动** (`applyIdVerifyAttempt`)。原实现先无条件覆盖姓名/掩码/指纹再看结果,一次填错就把账号上的 实名身份换成了没核验通过的人,`verifytime` 却还是通过状态;再提交回原来那个人又被当换人再计费。 - **注销必须同时清 `idverified / idverifiedtime / gender`**(`scrubCancelledUserIdVerify`)。 原先只删明细行,账号在后台仍显示「已实名」、详情却没有姓名证件;复活分支不碰这三个字段,只能在注销时清。 - **换人 / 注销留痕到 `useridverify_log`**(本地 gorm 结构,不进 pb):只存掩码 + 指纹,**不存姓名** (注销要清 PII)。留痕失败只告警不阻断注销。用户投诉「身份证被别的账号用过」时按 `oldhash/newhash` 查。 - ⚠️ **`user` 表是 `utf8mb4_general_ci`(老库导入),gorm 新建的表是 `utf8mb4_0900_ai_ci`**, 两表联查直接报 `1267 Illegal mix of collations`。`alignCollation` 启动时把实名两张表 CONVERT 到 与 `user` 表一致;手写联表 SQL 时记得加 `COLLATE`。其它 gorm 新建表与 `user` 联查同样会踩这个。 - **未修、属产品决策**:① 实名跟 uid 走,游客号实名后换手机号登录是另一个 uid,要再实名一次(再计费), 没有账号合并/转正接口——这是「同证多号」最自然的来源,后台「同证件账号数」分不清是一人多号还是多人共证; ② 实名按应用库隔离,同一个人在 ym / deepGlass 要各实名一次。 ## 文件上传:服务端签发预签名 URL,客户端直传阿里云 OSS(2026-09-04 改) **客户端不再持有对象存储的长期密钥。** | | 之前 | 现在 | |---|---|---| | 存储 | 腾讯云 COS `deepsound-1253517901`(广州) | 阿里云 OSS `ymaioss`(上海),与 console 后台传固件/产品图**同一个桶** | | 凭据 | `COS_SECRET_ID/KEY` 随 `user_getappconfig` 的 env **明文下发给每个用户** | 密钥只留服务端,客户端拿 15 分钟有效的预签名 PUT URL | | object key | 客户端自己拼 `User///<日期>/<随机>` | 服务端按会话 uid 拼死 `EAIMAR/User////<12位随机>.` | | SDK | `tencentcloud_cos_sdk_plugin` | 无,dio 直接 PUT | 旧方案的问题:那对 AK/SK 抓包或反编译就能拿到,而它对整个桶有读写权限—— 不只是自己那部分,`User/` 下所有人的录音都能被拉走或删掉。 链路:客户端 `UploadOss.upload()` → `user_getuploadurl` ([api_getuploadurl.go](apps/services/modules/user/api_getuploadurl.go))→ 拿到预签名 URL → dio 流式 PUT → 回写 `audiourl`。 - **OSS 配置的唯一读取点是 [comm/ossconf.go](apps/services/comm/ossconf.go)**, console 后台直传与 App 上传共用。取值顺序:【系统配置 → 对象存储】(`sys_service_config`) 优先,回退【第三方服务配置 → 存储】(`svc_config`, provider=`aliyun_oss`)。 ⚠️ 别再各写一份——两边落到不同的桶,运维就要盯两套存储。 - 解密用 `${FIELD_ENCRYPT_KEY}`,console 与各业务服务本就必须配同一把。 ⚠️ **scene 是白名单**(`uploadScenes`):`LocalAudio` / `TranslatAudio` / `ExternalAudio` / `ChatImages`。加新场景前把客户端 `addUpload` 与 `UploadOss.upload` 的调用点**全捞一遍**——少一个值就是那条上传链路整条报 「不支持的上传用途」,而且只在真机跑到那个场景才发现。 (本次就差点漏掉 meeting_home_controller 里的 `TranslatAudio` / `ExternalAudio`。) ⚠️ 扩展名也是白名单,不在表里直接拒,**不回退成 `application/octet-stream`**—— 那等于把桶变成任人上传的网盘。 ⚠️ 客户端 PUT 必须带**服务端回带的 Content-Type**:它计入了预签名,写别的值 OSS 判 403。 ⚠️ 上传用的是**独立的 Dio 实例**,不能复用项目那个:它挂着 `AuthInterceptor`, 会加 token 与业务签名头、还会把响应按业务 JSON 解析,直传 OSS 时这些都多余, 签名头还可能与预签名冲突。 ⚠️ `rootDir` 参数现在是 scene,**不要再自己拼 `User//` 前缀**(agent 图片那处 原来拼了),前缀由服务端加,客户端再拼一层就是重复路径。 ⚠️ **新老客户端并存**:老包仍读 `COS_*` 直传腾讯云,所以业务库 `config` 表里那 5 个 `COS_*` 键**先别删**。历史录音的 `audiourl` 是 COS 的绝对地址,不会自动搬家。 ⚠️ **录音目前仍是公共可读的**:bucket 公共读(实测匿名 GET 返回 206,不存在的 key 返回 404 而非 403),谁拿到 URL 谁就能听,只靠 12 位随机 key 不可猜测来保护—— 所以随机名用的是 `crypto/rand` 不是 `math/rand`。要真正锁住得把 bucket 转私有 + 播放时也签临时读 URL,客户端所有播放入口都要改,还没做。 ## 配置与密钥约定(重要) - 各 `conf/*.yaml` 与 `deploy//deploy.sh` 中**写死了真实的 DSN / 密钥 / 服务器 IP / 仓库密码,且纳入 git**——这是用户在私有仓库下**明确接受**的取舍。修改这些文件时注意它们会随仓库提交。 - ⚠️ **本仓库若要公开,必须先清理** 各 `*-deploy.sh` 的 `DEPLOY_HOST`、`deploy/registry-profiles.sh` 的 `REGISTRY_PASS`,以及各 `*.yaml` 里的真实凭据。 - 早期那套 `deploy.sh [action]` 已删除(只指向灵谱、生产位留空),统一走 `dev-deploy.sh` / `prod-build.sh` / `prod-deploy.sh`。 ### 启动日志里的「未配置」是设计,ERROR 才要查(2026-09-14 清过一轮) home/api 的各 `sys` 子系统都是「配不上就降级」:缺凭据只留一条 `init sys.xxx err: …(xx未配置,该功能不可用)` 的 **WARN**,包级函数返回 `ErrNotInited` 而不是 panic (会 panic 的只有 `wordfilter` 与 `ip2region`,见 deploy/app/README)。阿龙测试机现在常态缺 4 项: Google 语音、Google 登录、微信支付、支付宝、微软翻译——都是没放对应 json/pem 或没填 key,**不是故障**。 这轮修掉的两条真 ERROR: - `firebase/auth.go:36 [sys.tavily] cannot read credentials file` —— `sys/auth/firebase` **整个包已删**。 它是 `google_auth` 的重复实现,`Auth()` 一处调用方都没有(登录类型里没有 Firebase;客户端拿的 Firebase ID Token 走 `stype=Google`)。它还在 `newSys` 里 `Log.Errorln` 一次、又把同一个 error 返回给 调用方再打一条 WARN,于是同一件事刷两行、其中一行是红的。 - `Error 1062 Duplicate entry 'admin' for key 'adnim.PRIMARY'` —— `modules/api/model.go` 每次启动无条件 `Insert` 一次默认超管、返回值还没接。改成**只在账号不存在时**插入。 ⚠️ 别图省事改用 `Save`/Upsert:那会把运营改过的密码在每次重启时刷回配置文件里的初始值。 顺带两处:`sys/auth/*` 五个包的 logger 名全被复制成了 `sys.tavily`(所以 firebase 的错误显示成 `[sys.tavily]`,查的时候会被带偏),已各改各名;`google_auth` 原先 `newSys` 无条件返回成功、日志打 `init sys.google_auth success!`,而配置指的 json 根本不存在,直到有人点 Google 登录才失败—— 现在启动时就判「配得上没有」(空配置 / 写的是路径但文件不在),`ApiKeyFile` 以 `{` 开头则视为内嵌 JSON 放行。 ## 迁移上下文 源项目在 `/Users/liwei/work/go/yunyan/deep_server_up/`(及 `go_earphone/server/deep_server/`)。从旧代码移植接口时,注意 import 路径由 `earphone/...` 改为 `yunyan/...`,并核对接口签名是否符合上文反射注册约定。