# 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.ymaikj.com/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 个参数选公司,目前只剩 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 历史取回。** ⚠️ **2026-09-23:云雁/灵谱的镜像仓库档案也删了**(此前刻意保留的那条已推翻)。 `deploy/registry-profiles.sh` 的 `COMPANIES` 只剩 `along`,`prod-build.sh` 不再能推到 `registry.voitrans.net` / `registry.lingpu.net`;三个 `docker-compose.yml` 与 `build.sh` 里 写死的兜底默认值也从 `registry.voitrans.net` 改成了阿龙仓库。要找回:`git log -- deploy/registry-profiles.sh`。 **镜像仓库**地址/账号统一在 [deploy/registry-profiles.sh](deploy/registry-profiles.sh)(换仓库只改这一处,各脚本 source 它): - `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 同传延迟,那是模型为语义完整做的等待,配不掉。文本接上流式之后 提前的是「开始出字」的时间,音频总延迟不变。 ### 通话状态:`BB 8E` 是权威,`BB 02/03/04` 是噪声(2026-09-21 实测,**尚未据此改代码**) 真机抓到的通话状态帧里,只有 `BB 8E` 是干净的,整份日志只有两种形态、末字节即状态: ``` bb 8e 07 00 02 01 02 → 通话中 (29 次) bb 8e 07 00 02 01 03 → 已结束 (18 次) ``` 它是 `queryCallState` 的应答,我们问一次它答一次,与真实状态始终一致。 而 `BB 02` / `BB 03` 会**成对、高频**地刷(间隔 68~260ms),带的是同一个电话号码 (`bb 02 16 0f 01 2b 38 36 31 33…` 与 `bb 03 13 0f 01 2b 38 36 31 33…`,`2b 38 36…` 是 ASCII `+8613…`)。更像是**通话列表的周期性上报**(类似 HFP 的 `+CLCC` 轮询), 而原生 `case 0x02 / 0x03` 把它们当成了通话起止事件。 实测后果:一次挂断之后 30 秒内 `DeviceHub.inCall` 翻转 10 多次,主界面那道 「通话中禁用同传/面对面/音视频」的闸门跟着反复开关,用户点的时候正好落在某个 true 的瞬间就被拦,表现是「电话挂了,同传要等好几秒、点好几次才进得去」。 ⚠️ **对照时间线可以确认锅在 `BB 02/03` 而不在 `BB 8E`**:22:21:37~22:22:52 与 22:23:54~22:24:27 两段震荡期间,`BB 8E` 一直报「通话中」,直到 22:22:52 / 22:24:27 才报结束——也就是说电话确实还没挂断完,是 `BB 02/03` 在乱报。 ⚠️ 还有一个**不带号码**的短帧 `bb 02 08 01 01 02 03 02`,同样被判成 `startMakeCall`, 总是紧跟在挂断序列(`bb 04 03` / `bb d2` 的 stopCall ACK)之后冒出来。 **想改的话**(2026-09-21 讨论后决定暂不动,先留记录):通话状态只认 `BB 8E`; `BB 02` 可以立即置 true(宁可早开门,通话翻译本就只在通话中用), `BB 03/04` **不直接置 false**、只触发一次 `queryCallState()` 由 `BB 8E` 定论, 再加 3 秒兜底防止哪天 `BB 8E` 也换了命令号(见上文「命令号是 (产品, 固件版本) 的函数」)。 ⚠️ 这条链路是通话翻译的**准入判据**,判严的后果(通话中进不去)比现在的 「挂断后要多等几秒」严重得多,动之前先想清楚失败方向。 ### 排查这条链路时日志怎么拿(2026-09-21) ⚠️ **`idevicesyslog` 会自己断**,而且断了之后进程还在、只是不再有输出(今晚断过两次), `idevice_id -l` 返回空但 `devicectl` 仍能看到进程 —— 那是 libimobiledevice 与 macOS 26+ 的 usbmuxd 不对付,杀掉残留的 `iproxy` 也救不回来。 **更可靠的路子是直接拉 App 自己写的日志文件**,完全不依赖 usbmuxd: ```bash # 先列出来看哪个文件在更新(Logger.initFileLog 写在 Documents/logs/) xcrun devicectl device info files --device \ --domain-type appDataContainer --domain-identifier com.yimai.eaimar | grep logs xcrun devicectl device copy from --device \ --domain-type appDataContainer --domain-identifier com.yimai.eaimar \ --source Documents/logs/app_2026-09-21_221314.log --destination /tmp/latest.log ``` ⚠️ `copy from` **没有 `--username` 参数**(`info files` 有),带上会直接报 Unknown option。 文件日志的级别门槛与 syslog 不同,`Logger.i` 也会落盘,比 syslog 那条路看得更全。 ### iOS 上恒玄走的是 **GATT over BR/EDR**,不是 BLE(2026-09-21 真机定性) ⚠️ **先记住这条,否则后面每一步推理都会跑偏。** iOS 侧 `bluetooth_manager` 用 CoreBluetooth 的 API(`CBCentralManager`/`CBPeripheral`),但底层链路是**经典蓝牙 BR/EDR 上的 GATT**: 连接靠 `registerForConnectionEvents` + `.peerConnected` 接管系统已配对的设备, `retrieveConnectedPeripherals` 枚举的也是 BR/EDR GATT 设备。代码里 `enableBREDRConnectionEvents` 这个开关就是为它存在的。 **别把 BLE 那套模型套上来**,两处结论会直接错: | BLE 的说法 | 在 BR/EDR GATT 上 | |---|---| | connection interval 15~30ms,一个 interval 只发一包 | 不适用。BR/EDR 是 625µs 时隙轮询 | | 84B × 50 包/秒 ≈ 34 kbps 已接近链路上限 | 差得远。ACL 带宽几百 kbps ~ 2Mbps | 下行 84 字节 / 16~20ms 一包(50~62 包/秒)这个量级,**对这条链路完全不构成压力**—— 2026-09-21 压测实测 10164 包、`dropped: 0`。排查「音频不流畅」时不要往带宽方向猜。 ### GATT 写入:控制命令与实时音频的三条铁律(2026-09-21 真机定性) 通话翻译下行全部经 `BluetoothManager.write` 递给 CoreBluetooth,这条路上踩全了三个坑, **每一个都不报错**: 1. **写类型必须优先 `.withoutResponse`。** 原实现是 `contains(.write) ? .withResponse : .withoutResponse`, 而特征通常两种写都支持(真机实测 `props: write=Y writeNoResp=Y`),于是恒选带应答。 **ATT 是请求-响应协议**(这一点与底层是 BLE 还是 BR/EDR 无关),带应答写每包都要等对端 ACK 才能发下一包,50+ 包/秒的实时流必然追不上,表现是音频越听越滞后、断断续续。 2. **`canSendWriteWithoutResponse` 只能作观测,绝不能作门槛。** 它是 CoreBluetooth **为 BLE 设计**的流控提示,在 BR/EDR GATT 上不对应真实链路能力——压测实测它 **100% 为 false,同时 10164 包全部发出、零丢弃**。 ⚠️ 拿它当闸门会**死锁**:为 false 时我们一个字节都不写,而 `peripheralIsReady(toSendWriteWithoutResponse:)` 的触发前提恰恰是「你写了、被限流了」—— 完全不写,回调就永远不来,canSend 再也翻不回 true。真机表现:整场通话 `sent` 恒定停在 10、 `blocked` 停在 1(队列一次都没排空过),548 包只出去 11 个,**远端完全听不到译文**。 同一版里走无条件直发的控制命令(`AA 06`/`AA 09`/`queryCallState`)却全部成功——这组对比就是判据。 3. **实时音频不需要在这一层再做队列。** 下行本来就是 `CallTranslationDownlink` 用 20ms 定时器 **匀速**产出的,不存在突发。直接 `writeValue` 交给 CoreBluetooth 即可。 ⚠️ **控制命令绝不能排队**。`AA 09`(查电量)/`AA 06`(查版本)/`queryCallState` 一旦排到积压的音频 后面就再也发不出去,表象是「耳机连着但电量、版本全查不到」「耳机在通话中但 App 里通话翻译 不让点」,**极易误判成固件换了命令号**(上文那条警告就是这么来的)。 现在两个入口都是无条件直发,`writeRealtimeAudio` 仅用于区分统计口径。 另外 `CallTranslationDownlink.stop()` 只清它自己那两条腿的队列,必要时还得调 `BluetoothManager.flushRealtimeQueue()` 清 GATT 层的遗留积压。 ⚠️ **排查这条链路时 `NSLog` 一个字都看不到**:release / profile 包里 `idevicesyslog` 只收得到 `os_log`。`BesCallDownlink` / `BesCallPcmBridge` 原来全用 NSLog,于是下行是整条链路唯一的盲区, 只能靠猜。现在关键计数都走 `os_log`(`com.eaimar.bluetooth` 的 `write`/`downlink` 两个 category), 每 100 包一条队列长度 + 定时器真实间隔,会话结束一条收尾总账。 ⚠️ **别去碰标准电池服务 `0x180F`**:Echo-one 上它是空壳(值恒为 `0%`,同一时刻私有 `BB 0A` 解出的是 L=90 R=86),而对它 `discoverCharacteristics` + `readValue` + `setNotifyValue` 之后, **ATT 事务串行排队会把整条通道堵死**——实测下行 548 包只发出去 11 个,远端完全听不到译文。 这台耳机的完整服务清单(2026-09-21 一次性枚举,常态下只发现私有服务那一个): `180A`(设备信息)、`180F`(空壳电池)、`00001100-D102-11E1-9B23-00025B00AFAE`(私有,在用)、 `66666666-…`、`86868686-…`。 ### Android 的 SPP 控制命令会被协议栈合帧(2026-09-22 ALN-AL80 真机定性,已修) **这是 iOS 上 GATT 那三条铁律在 Android 上的对应物,坑的形状完全不同。** Android 的 `BluetoothSocket` 写入走的是到蓝牙进程的本地管道,协议栈只在链路有信用(耳机回 credit)时 才从管道取数据,而且一次把**管道里攒着的全部字节合成一个 RFCOMM 帧**发出去。恒玄固件 一帧只解开头那一条命令,排在后面的**静默丢失**。协议栈日志(`adb logcat` 里 `bt_rfcomm: PORT_WriteData p_len=N`)能直接看到: - 通话翻译期间下行音频包(84B)经常被合成 168/252/…/1512 字节的帧,即管道里最多攒过 18 包 ≈ 360ms; - 8 次收尾有 3 次 `call.stop` 丢失:`p_len=4`(asr.stopped + call.stop 合成一帧)、`p_len=256` (3 包音频 + 两条命令)、`p_len=172`(2 包音频 + 两条命令)。耳机既不回 `BB D2` 也不退通话模式、 **继续推音频**,下一次 `call.start` 的应答变成 `BB D1 07 01 02 03 02`(已在通话模式)—— 这就是「点结束没反应 / 耳机没收到结束指令」,偶现是因为取决于点结束那一刻管道里有没有东西。 - 握手里 `AA 06` 紧跟 `AA 09` 的「`AA 09` 无回包」多半也是它(见上文命令号那节的误判记录)。 修法在 [BluetoothManager.kt](apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt): 控制命令(`writeBytes`)走单线程队列,距上一条命令 ≥30ms、**距上一个实时音频包 ≥500ms** 才写 (后者是为了让管道里攒着的音频先发完,命令才能落在一帧的开头),最多等 5s 兜底;下行音频改走 `writeRealtime` 直发。修后 12 轮 0 丢,边播译文边点结束也在 0.5s 内收到 D2。 ⚠️ 判据是 `Logger.w` 的 `call.stop 已发出` 之后 ~0.5s 内有没有 `BB D2`——再出现「已在通话模式」 的 `D1 07 01` 应答,先怀疑这里的间隔不够,别去查 Dart。 ⚠️ `sendData` 在 Android 上**本来就不等真正写出**(`result.success` 先返回),排队没改变任何调用方语义; 但 Dart 若在发完命令后立刻 `disconnect`,排着队的命令会随链路一起没了。 ### 通话翻译 AST 就绪状态:同页第二场必聋(2026-09-22 Android 定性,两端同一个坑) 原生直连桥(`BesCallPcmBridge`)的上行帧只有 `ready==true` 才推给 AST,否则扣在 3 秒环形缓冲里丢掉; `ready` 只由 `initialize` 末尾的 `markReady()` 置真。**同一页面第二次点开始不会再 `initialize`** (只走 `startContinuousTranslation`),而 `disable()` 又把 `ready` 清了 → 之后每一场都 `ready:false / downlinkFrames:0`,上行帧照常到(framesA/B 几百上千、丢帧 0),就是不进 AST。 真机六场:进页面后第一场好、同页之后每一场都聋,退出重进又好一场。iOS 9-21 那次只修了 「桥比 initialize 晚建」的竞态(`astSessionReady` 建桥时继承),**没覆盖「桥已存在、重新 enable」**。 现在 Android 两处都修了:就绪状态记在插件上,建桥 / 重新 enable 时都继承,`disable()` 不再清 `ready`。 ⚠️ iOS 的 `BesCallPcmBridge.swift` `disable()` 仍会清 `ready`,同页第二场理论上一样聋—— iPhone 上每次都退出重进测就碰不到,要复现就同页连开两场看 `ready`。 ### 译文积压追赶:下行裁静音 + WSOLA 变速(2026-09-22,Android 实测,iOS 已同步代码未验) 译文音频是模型**突发**产出的(每 3~6 秒一段文本,音频一次性到),播放只能 1 倍速;说话人不停顿时 译文时长 ≈ 原话时长(实测 A 路 133 秒会话产出 129 秒音频),下行队列只涨不缩——A 路积压到 **6.2 秒**, 用户说的"越说越慢"就是它。**上行没有积压**(桥峰值 8~12 帧),瓶颈只在 `CallTranslationDownlink`。 处理两层,都不丢内容:积压 >0.5s 裁掉超过 150ms 的静音;>1s 起 WSOLA 变速不变调,2.5s 达 1.3x 上限 (2026-09-22 晚由 1s/2s/4s 收紧而来:同一份到达轨迹回放对比,>3s 积压的累计时长减半、中位降 25%; 峰值不再随参数下降——峰值由云端单次响应的音频长度决定,一句不换气说 28 秒就会砸下 8 秒音频, 再往下只能从切句下手,如 `turn_detection`。1.5x 上限能再减 40% 的 >2s 时长,但语速发急,作为口味项未采用)。 同量压测:最大积压 6.2s → 3.2s,绝大部分时间 <1.5s,**光裁静音就省了 10%**(TTS 句间停顿), 变速最高只用到 1.11x。兜底整段丢弃(`HARD_CAP_SEC`)默认关。 - 参数与算法两端一份(`CallTranslationDownlink.kt` / `.swift`),改一端要同改另一端; WSOLA 的数学用 Python 复刻验证过(rate=1.0 逐样本恒等,1.3x 时长 0.773≈1/1.3,无跳变)。 - 收尾一条 `下行积压总账 A{源=..s 播出=..s 最大积压=..s 裁静音=..s 变速追回=..s}`,每 100 包一条 `积压 A=..s B=..s 速率 A=.. B=..`——排查"慢"先看这两行,再决定是模型切句还是播放积压。 - ⚠️ 同一房间用两台手机测试时,B 路(对端通话音)吃到的就是机主自己的声音,`en->zh` 那条腿会把 中文原话原样吐回来并合成中文 TTS 灌回耳朵,听感像"慢了 3 秒又念了一遍",那是测试条件不是 bug。 ### 通话翻译耳机里的"偶尔杂音":六轮真机定位,四个独立原因(2026-09-23,Android 定性,iOS 同步未验) 杂音不是一个原因,是四个叠在一起,每修掉一个概率就降一档。逐条都有日志证据,别再从"耳机坏了"猜起: 1. **发包定时器系统性漂移**:`scheduleWithFixedDelay` 从上一次任务**结束**起算,WSOLA + G.722 + 写 socket 的 1~3 ms 全叠进周期,"20 ms"实测 21~23 ms、只有 0.9 倍速;耳机缓冲一直往下掉,62% 的时间在要 16 ms, 两边每 80 ms 拉锯一次。判据是协议栈 `PORT_WriteData p_len=84` 行的时间间隔分布。改成 `scheduleAtFixedRate` 固定节拍且**永不重排**(iOS 的 `DispatchSourceTimer` 本来就是固定节拍)。8-29 最初版和 deepvoice 都有这个 bug。 2. **起播缓冲在句中重新生效**(杂音落在句中的真凶):队列空了就要求再攒 120 ms 才恢复,而变速追赶时消耗比 云端到达快、队列中途归零很常见,等于句中硬插 120 ms 静音。现在 `playing` 标志让起播缓冲只在句首用一次, 句中有一帧就播;真正的断粮(云端没跟上)单独计数并打 `句中空洞` 日志,修后实测 0 次。 3. **恢复发包时从空缓冲起播**(杂音落在句首):两路都空我们就停发,耳机缓冲被榨干,下一句第一包直接起播。 耳机每次都回 `bb d6 07 00 02 01 00`(data[6]=00 = 缓冲空)。现在先塞 4 包**编码静音**垫底再发真音频, 两路都空后再续 500 ms 静音才停。 4. **24k→16k 重采样**是逐块独立的线性插值、无抗混叠、每块相位从零重算。换成 64 阶低通 + 跨块连续 (`Resampler24kTo16k.kt/.swift`,Python 复刻校验 1k/3k/6k 正弦)。 顺带定性的几条: - 耳机 `0xD6` 帧只有 data[6] 一个字节有信息(`bb d6 07 00 02 01 XX`),**01 一次只持续 20 ms 就回 02**, 它的语义是"缓冲低一包,现在补一包",不是"把速率改成 1.25x"。现在 01 立刻补一包(并按 16 ms 配额跟上持续要求)、 00 补两包、03 扣一包(`onHeadsetPace`)。三轮压测里它**一直在发**,从没要过 03——9-22 那句"耳机从未发过调速"是 grep 错了字样(日志文本是 `下行节奏调整为 Nms`)。 - **SPP 上只能 1 帧/84B**:试过 deepvoice iOS 那种 3 帧/244B,耳机完全不认、全是杂音。 - RFCOMM 是信用流控,耳机一次只放 1~3 个信用;信用停顿时协议栈把攒下的 6~11 包合成一帧突发,中间 100 多 ms 空档——这一层我们控制不了,能做的只有让耳机缓冲别贴着底走(上面第 3、5 条)。华为手机上系统的 「查找网络」/Nearby 会周期性 BLE 扫描(`scan_int=960 scan_win=96`),也会挤经典蓝牙时隙,排查时可临时关掉对照。 - 空闲那一路的静音帧改为用该路自己的编码器编零(G.722 自适应差分,固定码流会让解码器状态跳); 裁静音加回滞(300 进 / 600 出)+ 接缝 3 ms 淡入;速率每帧最多变 0.03;WSOLA 搜索窗 ±10 ms; 一句话没结束(150 ms 内有新数据)不补零收尾。`HARD_CAP_SEC` 补回 30 s 纯兜底。 - `DEEPVOICE_PARITY` 是打对照包用的开关(不裁不变速、4096 帧丢最旧、不打日志),**必须为 false 提交**。 ### 通话翻译"德语/意大利语一开就卡死、只有中英正常":并发初始化 + 默认参数兜底(2026-09-23 Redmi K90 定性并修) 不是语种问题,是**竞态**。原生阿里初始化天然 ≥1.2 s(A 路 init → `delay(1200)` → B 路 init),期间只要再来一次 initialize / dispose,"代数"变了,前一次**返回 false**;Dart `initialize()` 拿到 false 把 `_isInitialized` 清掉; 接着 `recognizeCallback()` 看到"未初始化"就调**无参** `initialize()`——默认值 `provider=azure` + `[zh-CN, en-US]`, 把刚握手成功的阿里 zh→de 会话整个换成 Azure 中英三段拼装。此后原生 provider 停在 azure,Azure 识别器一直在跑, 每次 dispose 都慢几秒,下一轮两条初始化更容易撞进 1.2 s 窗口,**退出重进也不会好**。中英"正常"只是因为默认值恰好是中英。 触发源:切语言的 `stopAll`+`_reinitializeAsrService`、`startRecognition` 等 `_modeInitFuture` 3 s 超时后硬往下开桥、 退出页面 `onClose` 与重进 `onInit` 叠上再点开始。判据:日志里 `[STS] initialize: provider=azure (native=azure)` 紧跟在 阿里的 `[TRANS-INIT] 命中` 之后,或 `pushAstAudioToA: provider=azure`。 修在三层,**缺一层都会复发**: - `azure_ast_service.dart`:`initialize()` 串行化(`_initInFlight`);记住最近一次 provider/语言表;`recognizeCallback` / `startContinuousTranslation` 走 `_ensureInitialized`——先等在途初始化,仍没成就**按上次参数**补一次,**永远不用默认值**。 - `translation_controller.dart`:`_initializeCallModeTranslationService` 串行链 `_callInitChain`;`isPreparing` 期间忽略重复点开始; `_attachBesCallTranslationBridge` 开桥前 `await _callInitChain`(10 s 上限)。 - `AzureSpeechPlugin.kt`:被新一代取代时打 `AST 初始化 gen=N … 被新一代取代,本次作废`,A/B 任一失败打结果——排查先找这两行。 修后 K90 连开 9 场(it/de/en/ja/fr/ko×2/en)全部阿里 3.5、初始化各 1.2 s、87 句译文、0 错误。 ### 通话翻译的 PCM 已下沉到原生:bluetooth_manager ⇄ azure_speech 直连(2026-09-20) 苹果测试反馈「WiFi 下断断续续、长句基本不行」正是此前记录的技术债触发条件(Dart 链路每秒约 200 次跨界, event loop 一被拖住原生就报 `AudioSendSlow 1011`)。现在通话翻译的双路 PCM **不再经过 Dart**: | 端 | 落点 | 传输 | |---|---|---| | 耳机插件 → 翻译插件(上行 A=本端麦 / B=对端) | `bluetooth_manager` 的 `CallPcmBridge.swift` / `BluetoothManager.callPcmTap`(kt) → `azure_speech` 的 `BesCallPcmBridge.swift/.kt` | iOS `NotificationCenter`(两插件一个是 CocoaPods 一个是 SwiftPM,**不能互相 import**);Android `compileOnly project(":bluetooth_manager")` 直接赋值静态槽 | | 翻译插件 → 耳机插件(下行译文 PCM) | `pushAstTtsFrame`/`emitAstTtsFrame` 直接喂 `CallTranslationDownlink` | 同上 | - **按会话开关**:`TranslationController._tryEnableNativeCallBridge` 先开翻译端(`AstService.setNativeCallBridge`) 再开设备端(`DeviceFeatures.callPcmNativeBridge`),任一端不支持就退回原来的 Dart 流路径(`openMic`/`pushExternalAudio`), 功能不受影响。关的顺序相反。桥开着时原生不再把 micData/spkData 推给 Dart 的 audio EventChannel, TTS 也只回 `final` 标记;`[CALL-BRIDGE] 原生直连桥统计` 这行 warn 日志在每次会话结束时打, `dropped>0` 表示上行拥塞(原生那边 2s 积压即丢帧,取代原来整个会话被 1011 掀掉)。 - **B 路说话人门限 `PeerSpeechGate` 已移植成 Swift/Kotlin**(`azure_speech` 内),阈值三处(Dart/Swift/Kotlin)相同,调要同调; 门限开启时原生发 `peerGateOpened` 事件,Dart 以 warn 打 `[CALL-BRIDGE] B 路门限开启(原生)`——真机调阈值仍看这行。 - **重连期间不丢音频**:AST helper 重建时帧留在桥的环形缓冲(A 路 3s,B 路因门限复位丢弃),`markReady` 后整段补推。 ⚠️ iOS 通知名/键、Android 槽名两边各写一份,**改一处必须同改另一处**,改漏了不报错、只表现为「桥开着但一帧都到不了」。 - 顺带改的:AST 重连预算由「3 次」改成「首次掉线起 60s 窗口内不限次、退避封顶 8s」(`_astReconnectWindow`), 终止时按错误原文分类弹「网络连接失败」或「服务连接失败」(`_classifyAstFailure`),通话翻译页顶栏多了链路状态灯(`astLinkState`)。 ### 音频链路继续下沉:录音落盘与 EMAI 会话(2026-09-21) **判据不是「业务逻辑 vs 展示逻辑」,是「这条链路有没有实时截止期」。** Dart 侧的 CPU 开销基本不是瓶颈(一帧 640 字节算个 RMS 是微秒级);真正的代价有三条,只在特定链路上成立: 平台通道每条消息要序列化 + 跳一次主线程;Dart 只有一个 isolate,音频回调与页面布局、GC 排同一个 event loop(`AudioSendSlow 1011` 就是这么来的);以及后台存活要求。 没有实时截止期的状态机、接口编排、列表渲染搬原生只会把一份代码写成 Swift + Kotlin 两份。 ⚠️ **「搬到原生就能扛住 iOS 锁屏」是错的**:iOS 挂起的是**整个进程**,原生和 Dart 一起停。 锁屏后能不能活着只取决于 `UIBackgroundModes=audio` + 确实有音频在播 (`BackgroundKeepalive` 干的事),与代码在哪一层无关。排查「锁屏断连」别往下沉的方向查。 #### 设备录音直接在原生落盘(`PcmWavRecorder`) | | 之前 | 现在 | |---|---|---| | 路径 | 原生解码 → EventChannel → Dart → `WavWriter`(RandomAccessFile 排队) | 原生解码完**就地写 WAV** | | 跨界 | 单路 50 帧/s、通话双路 100 帧/s | 只剩 100ms 一条 `{bytes, level}` | | 入口 | `session.openMic(...)` + 自己写盘 | `session.openFileRecorder(path:, routes:)` | - 契约在 `device_plugin_interface`:`DeviceFileRecorder` / `DeviceRecordStats`, `DeviceSession.openFileRecorder` **默认返回 null**——其它厂商零改动,老原生自动退回 Dart 落盘那条路(三个服务都保留了完整的退路分支)。 - 实现是 `bluetooth_manager` 的 `PcmWavRecorder.swift` / `.kt`:**实例化,不是单例**。 azure_speech 里那个 `RecordFile` 是单例且已被通话翻译的立体声录音占着,复用会互相踩。 - **录音是额外的 sink,不拦截原有回调**(与 `CallPcmBridge` 不同):通话翻译开着桥时 micData/spkData 不再上抛 Dart,但用户可能同时在录这通电话,拦了就录出空文件。 - `StereoPairer` 的配对逻辑三处(Dart / Swift / Kotlin)各一份,金测试 `test/stereo_pairer_test.dart` 定的是同一个行为。**写成「只留最后一帧对端」会把右声道 断续抽掉,转写出来只剩自己这一方**。 - 顺带修掉现场录音的**相位问题**:Dart 原来用 `_alternate(audioStream)` 隔帧取主麦, 靠的是「订阅时恰好对上相位」,中途订阅就永久取到前馈麦。原生早把两路标成了 `mainMicData` / `ffMicData`,按类型取即可。映射由 `test/devices/bes_recorder_sources_test.dart` 钉死——那几个串是跨语言契约,写错不报任何错,只表现为「录音起来了但一个字节都没写进去」。 - 断连兜底:设备断开时原生自己收尾回填 WAV 头。不做这一步、进程随后被杀,就留下一个 `data` 长度为 0 的文件——有几十 MB,播放器却认为它是空的。 #### EMAI 耳机唤醒整条会话下沉(`BailianDialogClient`) 耳机按键唤醒那条链路的百炼 WebSocket 与音频收发都搬到了原生,**Dart 只收文本与状态**: ``` bluetooth_manager(AiPcmBridge) → azure_speech(BesAiPcmBridge 攒 100ms 包) → BailianDialogClient(WS) → 合成 PCM 原路回 AiAudioDownlink ``` - 按会话开关,**两端都要开**:`BailianNativeDialog.start()`(azure_speech 端)+ `DeviceFeatures.aiPcmNativeBridge`(bluetooth_manager 端)。任一端不支持就整条退回 Dart 的 `BailianMultimodalService`。**不能只开一半**:只开翻译端一帧都收不到, 只开设备端 PCM 被摘走没人接,两种都表现为「唤醒了但 AI 不说话」。 - **手机麦那条链路不动**(EMAI 页按住说话),音频本来就在 Dart 侧采集,没有下沉的意义。 - **指令解析刻意留在 Dart**:`extra_info.tool_calls` 那些坑有一批真实 payload 的金测试守着 (`test/assistant_directive_test.dart`),解析结果又要落进 GetStorage 和「代办」页。 原生把 `extra_info` 原样转成字符串带上来,走同一个解析器。 - **门控(AI 播报时把上行换成等长静音)只有逐帧替换在原生**,什么时候开关门仍在 `DeviceAiSessionService`:那套有兜底计时、非致命错误要关门、纯指令帧收尾也要关门的分支 是来之不易的(门卡开着 = 会话活着但是聋的)。 - Dart 拿不到下行 PCM,但它要靠「首包到达」开门控、靠累计字节数估播完时刻, 所以原生按 100ms 报一次累计 `bytes`;设备上行有声按 500ms 报一次 `micActivity` 续空闲计时。 ⚠️ `micActivity` **不能当成 `speechStarted`**:那个事件在会话服务里会丢弃待播队列 (「真人插话」的语义),混用会让 AI 每说一句就被自己的回声打断一次。 - ⚠️ **`AiAudioDownlink.flush()` 的语义是「清空队列」不是「把尾包发出去」** (`outQueue.clear()`)。名字容易读反,在「本轮播报结束」处调它等于把每句回复的尾音切掉。 真正要丢弃待播的只有真人插话,走 `_spk?.discardPending()`。 - 握手参数、事件名映射、心跳间隔在 Dart / Swift / Kotlin 三份实现里逐条对齐; 配置解析(apiKey / appId / workspaceId)与 MCP 工具名**只在 Dart 维护一份**, 由 `start` 的参数传给原生(`test/bailian_native_parity_test.dart` 守着)。 对不上不报任何错,只表现为「用耳机问和在 App 里问,助手的能力不一样」。 #### EMAI 下行播放两端都走原生(`PcmSpeakerPlayer`) Dart 的 `PcmStreamPlayer` 现在**默认走原生播放器**(`azure_speech/pcm_player`, iOS `AVAudioEngine` / Android `AudioTrack`,方法名 start/feed/end/stop/dispose 逐字一致), 只有老包没有这条通道时才退回 just_audio。 - iOS 早就是原生的,那边是**被迫**:AVPlayer 放不了 just_audio 代理服务给的 「长度未知的 chunked WAV」,只发一次 `Range: bytes=0-1` 的嗅探就判定不可播、 回 `-11850 Operation Stopped`,一次都没真正起播。 - Android 是**为了少绕一圈**:ExoPlayer 不发那个嗅探请求,所以老路一直能用,但一条 实时语音流要先经过 just_audio 内部起的**本地代理 HTTP 服务**(chunked 编码 → localhost TCP → ExoPlayer)。直接喂 AudioTrack 更短、起播更快。 - ⚠️ **播完的判定不能是「队列排空」**:Android 侧按 `playbackHeadPosition` 对比已写入 帧数(iOS 用 `.dataPlayedBack` 而不是 `.dataRendered`,同一个道理)。按排空报完成, 最后一两百毫秒还在缓冲里没出声,调用方据此回 `LocalRespondingEnded`,尾音被切掉。 - ⚠️ AudioTrack 的 `AudioAttributes` 与 `emai_audio_session.dart` 里声明的 `AndroidAudioAttributes` 对齐(`USAGE_MEDIA` + `CONTENT_TYPE_SPEECH`)。 改这两行会改变输出路由(比如 A2DP 还走不走)。 - 原生路径**只支持单声道**(两端都是 mono 链路),`channels != 1` 时自动退回 just_audio, 而不是把交织 PCM 当单声道播成两倍语速。当前两个调用点(EMAI 24k、车载香薰 16k)都是单声道。 #### 顺带清掉的每帧 Rx 电平原来是**每帧** `level.value = rms(pcm)`(50~100 次/s),而读它的只有 100ms 一次的 采样定时器——每 5 次里有 4 次是唤醒了 Obx 又被下一帧覆盖。现在一律:每帧只更新**纯字段的 窗口峰值**,由采样定时器发布。涉及 `device_recording_service` / `device_call_recording_service` / `pcm_stream_player` / `mic_pcm_streamer` / `meeting_record_controller`。 `meeting_record_controller.rms` 在 meeting 模块里**一个 widget 消费方都没有**。 ⚠️ `WavWriter.write` 的 `onWritten` 回调**已删除**:它把 UI 挂在文件写入队列的尾巴上, 队列一堵界面跟着停、写失败波形整段消失。电平在调用方直接从那块 PCM 上算,与落盘无关。 ⚠️ **CocoaPods 的 `source_files` 是在 `pod install` 时展开通配的**:给 `bluetooth_manager`(CocoaPods 包)新增 .swift 文件后必须 `cd ios && pod install`, 否则报 `Cannot find 'XXX' in scope`,看着像 import 问题。azure_speech 是 SwiftPM 包,不受影响。 ### 苹果测试那一轮一并修掉的四处(2026-09-20) - **iOS 字幕小窗被别的 App 的画中画顶掉后再也不回来**:iOS 全系统只允许一个画中画。`SubtitlePipController` 现在区分 「我们主动停」和「被系统停」,后者退避重试(3/6/12s,5 次)并在 App 下次 `willResignActive` 时再拉一次; 状态经 `native_plugin` 通道的 `subtitlePipStateChanged` 回传(`started`/`stopped`/`stoppedBySystem`/`failed`), `isFloatingWindowEnabled` 以此为准(原来只在进页面查一次,被顶掉后点开关是「关」,要点两下)。 ⚠️ **两条真机实测出来的前置条件,都会让画中画"静默失败"**(2026-09-21,`SubtitlePip.diagnostics()` 是为此加的, 起不来时先问它,别靠猜): 1. **音频会话类别必须支持后台音频**(`.playback` / `.playAndRecord` / `.multiRoute`)。类别是 `.ambient` / `.soloAmbient` 时 `startPictureInPicture()` **连 `failedToStartPictureInPicture` 都不回调**,表现就是"点了什么都没发生"。 所以 `subtitlePipStart` 仍要配会话,但**只在当前类别撑不起画中画时才动**(`prepareAudioSessionForPip`)—— 无条件改成 `.playback` 会把通话/多媒体翻译正在跑的 `.playAndRecord` 录音链路踩掉,而 `.playAndRecord` 本身就满足要求。 2. **App 在前台时不会真正进小窗**:`possible=true` 但 `active` 恒为 false,并回调一次 `failed`。 要等 App 切到后台才由 `canStartPictureInPictureAutomaticallyFromInline` / 我们的 `willResignActive` 重试拉起来。 这是 sample-buffer 画中画的正常行为,**不是 bug**;真机实测:前台 `failed` → 切后台 `started` → 小窗浮在别的 App 上。 - **语音纪要「列表显示总结完成、第一次点进去是空的」**:`OverviewTab` 原来在 `initState` 快照一次,`SummaryTab` 只听 `personnelNumberModifications`; 现在两个 tab 都跟着 `meetingDetails` / `summaryNumberModifications` 重画。`MeetingDetailsController` 的 `ever(tasktype)` 只取 service 里**非空**的值(原来会用空值把刚拉回来的总结抹掉),`_everType` 门槛删除;进行中状态每 6s 自己拉一次 `refreshFromServer()`、切后台回来补拉一次、占位图上有「刷新」按钮——不再依赖那个会被首页退出清空的全局轮询。 `MeetingTaskService.addTask` 改成返回是否受理,没受理不再把页面切成「等待转写」。 - **Ask AI 抽屉的 X 点不到**:`SafeArea` 挪到受约束的 Container 里面、顶部留 `viewPadding.top`、不再重复减 `viewInsets.bottom` (GetX 已经减过一次)。原来抽屉比屏幕只矮 18pt,标题行整个埋在状态栏里。 - **EMAI 耳机对话锁屏易断、蜂窝易断连**:`DeviceAiSessionService` 的空闲超时 5s→15s 且设备上行有声(RMS≥600)也算活动; 百炼 WS 掉线先重连 2 次(1s/3s)再拆会话;设备断连给 3s 观察期;iOS 会话期间用 `BackgroundKeepalive` (just_audio 循环 `assets/audio/silence_1s.wav`,`playback+mixWithOthers`)撑住 `audio` 后台模式——Android 有前台服务,iOS 原来什么都没有。 ### 面对面 / 同声翻译:延迟构成与已做的优化(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 这条没有复用它,版本比较是本地实现的。 ### ⚠️ 私有协议命令号是 (产品, 固件版本) 的函数,不是全局常量 **每个产品有自己的固件线,指令集也各是各的**,下面那张表只属于 **Echo-one**。 换产品要重新拿厂商文档 + 实测,换固件版本要重扫;任何情况下都别把这张表 当成「恒玄耳机通用协议」跨产品套用。 同一个 `AA 09`(查电量)在 Echo-one 自己的固件线上就反复变过: | Echo-one 固件 | `AA 09` → `BB 0A` | 实测日期 | |---|---|---| | 0.0.2 | ✅ 有效(这张表就是在它上面扫出来的) | 2026-09-01 | | 0.0.3 | ❌ 静默无回包,连 `BB 89` ack 都没有 | 2026-09-02 | | 0.0.8 | ✅ 又有效了,解出 L=90 R=86 | 2026-09-21 | 重扫用 `BesBluetoothService.to.probeCommands(from: 0x01, to: 0x20)`(当初 `AA 09` 就是在这段 扫出来的;`0x21~0x7F` 已确认整段无回包,2026-09-01 扫过 69 个命令,不用再浪费 28 秒)。 `0x5x`/`0x6x` 会切工作模式(进通话/录音/AI),任何情况下都别扫。 ⚠️ **「`AA 06` 成功、`AA 09` 无回包」不足以判定命令号失效**——2026-09-21 就这么误判过一次。 握手里这两条是**紧挨着连发**的(中间没有延时),而当时 iOS 的 BLE 写入刚加了无应答写的 流控队列:第一条赶上 `canSendWriteWithoutResponse` 为真、直发成功;第二条紧随其后, CoreBluetooth 刚吃下一个写请求、`canSend` 转假,于是进了队列等 `peripheralIsReady` 回调—— 而那个回调在队列满住后不再触发(见下文「控制命令不能和实时音频共用发送队列」), `AA 09` 就永远卡在队列里。**表象与「固件不认这个命令号」一模一样。** 判定命令号之前,先确认同一条链路上**后发**的命令也能出去。 ### 恒玄耳机电量:`AA 09` 请求 / `BB 0A` 上报(**仅适用 Echo-one**,2026-09-01 定稿于固件 0.0.2,2026-09-21 在 0.0.8 复测仍成立) 厂商协议 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-09-23 口径:按平台分版本、最低版本留空=线上版本、渠道未上架不推、地址回退链 在下一节(8-31)的基础上改的,判定仍全在服务端 `evalChannelUpgrade`([api_getchannelapp.go](apps/services/modules/user/api_getchannelapp.go)), 客户端只拿结论。规则: 1. **Android / iOS 各一套版本号**:`app_release` 新增 `ios_version / ios_min_version`(启动 `ALTER ... ADD COLUMN IF NOT EXISTS` 幂等), `version / min_version` 从此只管 Android。平台由渠道号推导(`comm.PlatformOfChannel`:只有 GlobalApple=101 是 iOS)。 ⚠️ **iOS 没填时不回退 Android 的**——回退等于又回到共用一个版本号的老问题(Android 先发 1.0.3、iOS 商店还是 1.0.2, iOS 用户被指向一个商店里没有的版本)。iOS 那栏留空 = iOS 不提示任何更新。 2. **最低支持版本留空 = 线上版本**(`MinVersionFor`):勾上强更不填最低版本,就是"所有低版本都强更";填了则只强更低于它的, 介于两者之间的仍是推荐。后台不再要求"开强更必填最低版本",改为"至少填一个平台的线上版本"。 3. **渠道行状态不是「已上架」(审核中/已下架)时不推送推荐更新**:商店里还是旧包。强更不受此限。 4. **下载地址回退链**(`comm.AppRelease.ResolveDownloadAddress`):Android 渠道 = 本渠道(须已上架)→ 官方渠道(100) → 默认下载地址; **iOS / Google 只认本渠道**,没有就空——那两处如今放的是 apk 直链。 5. **算不出任何地址时两种更新都不提示**,强更也不例外:一个点了没反应又关不掉的弹窗等于把用户锁死。 客户端三处配套(`version_update_service.dart`):推荐更新的「稍后提醒」记版本号,同一版本 24 小时内冷启动不再弹(`snoozeVersion / isSnoozed`, 强更与设置页手动检查不受影响);设置页手动检查 `checkForUpdate(bypassCache: true)` 绕过 10 分钟缓存;Android 上服务端地址打不开时 回退网页下载页 `dl.ymaikj.com`(iOS 不兜底)。判定的金测试:`comm/apprelease_test.go`、`modules/user/module_test.go` 的 `TestEvalChannelUpgradePlatformStatusAddress` / `TestToPbChannelAppAddressFallbackChain`。 ### 版本更新与强更判定(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`(单数)**,2026-09-23 核实:全仓唯一调用点是 [version_update_service.dart:144](apps/client/lib/data/services/version_update_service.dart#L144); `Api.getChannelApps`(复数)**定义了但零调用**。此前这里记反了。 单数接口把 `needupdate/forceupdate/reviewing` 三个结论在服务端算好下发,客户端直接用, 不再自己比版本号。复数接口目前只有下载落地页(dl.ymaikj.com)在用。 ### 后台入口合并:「版本与分发」(2026-09-23) 「服务配置 → 版本控制」与「SaaS 管理 → 渠道分发」合成了一页 `/appdist` ([appdist.vue](apps/admin/app/pages/appdist.vue)),上半区发版配置、下半区渠道分发, 顶部一个应用选择器共用。页面本体拆成 `components/AppReleasePanel.vue` 与 `components/ChannelAppPanel.vue`;`/appversion`、`/channels` 只留重定向。 ⚠️ **合的是入口,不是表**。`app_release` 与 `channel_app` 维度不同(见下一节), 两块各读各的接口、各自提交。**绝不能做成一个大表单整包保存**——`app_release` 那一行 还被「游客显隐控制」页共用,后端是「读整行 → 只覆盖本组 → 整行写回」, 整包提交会在本页保存时清空游客登录配置。 ⚠️ **菜单 id 仍是 `appversion`**(只改了 label 与 to)。存量后台账号的 access 清单里存的是 这个串,改 id 等于让这些账号丢入口。只勾过旧 id `channels` 的账号由 [auth.ts](apps/admin/app/stores/auth.ts) 的 `hasAccess` 兼容放行——同 「应用环境配置 → 服务与环境配置」那次的处理。 ### 配置分家: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。 - **2026-09-22 Dart 侧整体改名 Smartcar → EaiCar**(目录 `lib/devices/eaicar/`、`lib/modules/eaicar/`、类名/文案键/路由)。 **刻意不改的**:原生插件名 `device_smartcar`(Kotlin 包 `com.yimai.device_smartcar`、Swift 包)、持久化的厂商键 `DeviceVendors.eaicar = 'smartcar'`、已铺进装机编排的模块 id `AgentModules.eaicarChat = 'smartcarChat'`、 GetStorage 键 `smartcar_authorized_macs` / `smartcar_remembered_devices`。改这些值会让老装机的编排/白名单/记住的设备全部丢失。 - 链路在原生插件 `apps/client/local_plugins/device_smartcar`(自持 GATT,同一条连接复用 AE00 + ABC0), Dart 侧 `lib/devices/eaicar/` 只做组帧/广播解析/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 布局这台固件没发。 ### EaiCar 设备设置面板:按能力出现、按设备回的 items 渲染(2026-09-22) 「设备 → 设备管理」里 EaiCar 那一行整块可点(解绑按钮除外),弹底部面板拉 `0x15` 显示并可改: 启停 `0x0D` / 音量 `0x0B` / 亮度 `0x0A` / 行车感应 `0x11` / 摇摆 `0x14` / 随机表情频次 `0x13` / 随机播一个表情 `0x06`。 设计与指令对照见 [docs/EaiCar设备控制面板-落地文档.md](docs/EaiCar设备控制面板-落地文档.md)。 - **面板是厂商无关的**(`lib/modules/my_devices/views/widgets/device_settings_sheet.dart`):只认 `DeviceCapability.settingsPanel` 与 `DeviceFeatures.settings*` 通用键,按 `settings.query` 返回的 `items` 画控件,缺哪项不画哪项。EaiCar 插件把通用键映射成字节(`EaiCarSession.settingsCommand`,纯函数, `test/devices/eaicar_settings_features_test.dart` 逐字节钉死)。以后耳机/支架要露设置,各自插件声明即可。 - **状态只有 `0x15` 一个来源**:六条写命令的应答只回 `0x64` 成功码,不回显新值,也没有主动上报。 插件写成功后**本地回填**并派 `settings.changed`;面板从不自己推断状态。原先 `_applySummary` 解完 14 字节只留固件和电量、其余全丢,这次整份留下(`EaiCarSession.settings`)。 - `0x11` 双向语义不同:`AA 11` 是行车感应开关,`CC 11` 是设备上报 AI 唤醒,按帧头分发,别把后者当开关状态。 - 「随机表情」设备只有**频次**(秒数原值,9 档);"现在随机播一个"没有原生命令,由 app 在 0x00~0x09 里挑一个发 `0x06`(单向)。 - 未连接的 EaiCar 行也可点(按设备名猜品类 `DeviceCategories.categoryIdForName`),进去是「未连接 / 去连接」; 其它品类的行 onTap 为 null,行为与以前一字不差。 - ⚠️ **`0x0D` 真机验证口径**:lumi 常量叫"自动开关机",第一次发之前先在 `0x15` 看当前值、发同值确认应答,再翻转, 别一上来发 0。`0x14`/`0x11` 翻转后回读 `0x15` 对应字节确认。 ### 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`)。现役清单: **已通的服务器域名**(NPM 反代,两台阿龙机): | 用途 | 测试机 8.133.166.29 | 正式机 47.116.104.181 | |---|---|---| | 业务网关 gateway HTTP(7100) | `ym-dev.ymaikj.com` | `ym.ymaikj.com` | | 管理后台 admin(3000) | `console-dev.ymaikj.com` | `console.ymaikj.com` | | MCP HTTP/SSE(7300) | `mcp-dev.ymaikj.com` | `mcp.ymaikj.com` —— DNS 就绪,**反代与证书仍未建** | | 镜像仓库 | `registry.ymaikj.com`(实体在测试机上) | 同一个 | **客户端辅助域名**——⚠️ 四个全部解析到测试机,且测试机 NPM 上**没有它们的反代规则**, 也就是现在一个都打不开(2026-09-11 实测,见 [上线落地文档 §8.4](docs/2026-09-11-阿龙测试服上线正式服-落地文档.md)): | 域名 | 用途 | 什么时候必须处理 | |---|---|---| | `pay.ymaikj.com` | 支付回调。硬编码在 `pay_service.dart`(3 处)、`goods_base_controller.dart`(2 处)。⚠️ `notify_url` 是**客户端下单时传给服务端的**,不是服务端配置 | 正式收款前 | | `share.ymaikj.com` | 会议分享落地页。**2026-09-23 已落地**(`starpivot-app:0.2.5`):页面 `go:embed` 在 gateway 里,路由 `/share/*any`。DNS 已指向正式机、白名单已加,**只差 NPM 反代那一条规则** | 配完 NPM 即可用 | | `web.ymaikj.com` | EAIMAR 用户侧 Web。`member_bottom_sheet.dart` 有真实入口,41 个语言文案里都印了 | 做 Web 版时 | | `dl.ymaikj.com` | 下载落地页。页面已写好([deploy/dl/](deploy/dl/),含 OSS 部署教程),**桶与域名尚未建**。版本号/更新日志/各商店链接实时取自后台 `user_getchannelapps`,图标与 App 同一份素材 | 对外发链接前 | **非服务器域名**:`oss.ymaikj.com` → 阿里云 OSS 自定义域名(桶 `ymaioss`,上海,测试/正式共用, 不指向任何业务机);`mcp.ymaikj.com:8000` / `api.ymaikj.com` 在 `.mcp.json` 里是死配置。 ⚠️ **微信 Universal Link 用的是 `yimai.games`,不是 `ymaikj.com`**(这一行以前记错过)。 三处必须完全一致,否则微信登录与微信支付**静默失败**: ① `universalLink: 'https://yimai.games/app/'`(`login_service.dart`、`pay_service.dart`) ② `Runner.entitlements` 的 `applinks:yimai.games` ③ **微信开放平台后台登记的 Universal Link** + `https://yimai.games/.well-known/apple-app-site-association` 代码侧(①②)是对的,**③ 属于外部系统,发版前务必确认已同步**。 ### `com.saitong.voitrans` 是错值,不只是遗留串(2026-09-23 修) iOS 工程的真实 bundle id 是 **`com.yimai.eaimar`**(`Runner.xcodeproj` 的 `PRODUCT_BUNDLE_IDENTIFIER`),而 `home.yaml` 的 `appleiap.AppStoreBundleID` 一直写着 上一代的 `com.saitong.voitrans`。[appleiap.go](apps/services/sys/pay/appleiap/appleiap.go) 拿它做两件事:签 App Store Server API 的 JWT(载荷 `bid`)、校验交易归属 (`info.BundleID` 不相等就拒)。**对不上 = 所有 iOS 内购校验都失败**,报 「交易归属的 bundleId 不匹配」。模板与三处测试已改,⚠️ **服务器上的实际值要单独确认**—— 这个字段是 `comm.ModuleCatalog` 托管的(`Required: true`),后台「支付 → Apple 内购」可改, 库里那行很可能是当初从模板 seed 进去的错值。 ⚠️ **`GoogleService-Info.plist` 里的 `BUNDLE_ID` 同样是 `com.saitong.voitrans`**(三份副本: `apps/client/`、`ios/Runner/`、`assets/config/`,内容完全一致)。**这个不能直接改**—— plist 是从 Firebase 控制台下载的,BUNDLE_ID 要与那边登记的 iOS 应用一致;本地改字符串而 不在 Firebase 里注册 `com.yimai.eaimar`,只会从「登记的是旧 id」变成「根本对不上」。 要正的话得在 Firebase 控制台加一个 iOS 应用再重新下载三份。`PROJECT_ID: voitrans` 同理。 ⚠️ Redis 的 `KeyPrefix` 兜底默认值已从 `Voitrans` 改成 `EAIMAR`(与 `env.example` 一致)。 **不能删成 `${REDIS_KEY_PREFIX:-}`**:`lego/sys/redis` 的 `RKey` 在前缀为空时**直接返回裸 key**, 而阿龙两台机上同一个 Redis 跑着多个应用,无前缀 = 互相踩。 ⚠️ 另外两处含 `voitrans` 但**不是域名、不要改**: - `lib/firebase_options.dart` 的 `projectId: 'voitrans'` / `'voitrans-e0e3d'` —— Firebase 项目标识, 改它要在 Firebase 控制台另建项目并换掉 `google-services.json` / `GoogleService-Info.plist`。 - `lib/core/translations/` 里的 key 名(`copyrightVoitrans`、`voitransPrivateCloud`…)—— 是标识符不是文案, 用户不可见,改了要 41 个语言文件连同引用处一起动。 ### 会议纪要分享(2026-09-23 落地,详见 [上线步骤](docs/会议纪要分享-落地与上线步骤.md)) 链接格式 `share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{ticket}`。 `serverHost` 取自 `DioManager().baseUrl`,告诉落地页去哪个后端取数据; **第二段 `ymaikj.com` 是品牌标识、不是要请求的域名**,别当成域名误改。 ⚠️ **最后一段以前放的是 `logintoken`(完整登录 JWT)**,谁拿到链接就拿到了整个账号且无法失效。 现在是服务端签发的分享票据([api_sharelink.go](apps/services/modules/echomeet/api_sharelink.go)): HMAC 签名、绑定单条记录、只读、默认 7 天过期,密钥由 `FIELD_ENCRYPT_KEY` 派生(上线不需要新配置项)。 **别改回去。** 三处各自的坑: - `echomeet_getsharedrecord` **必须在 gateway `WhiteList` 里**——打开链接的人本来就没登录。 漏了是 `code:18`,表现为「分享链接打开一片空白」。签发端 `echomeet_createsharelink` 反之, **不要**加进白名单。 - 落地页嵌在 gateway 二进制里(`modules/gateway/share_page.html` + `go:embed`), 路由 `GET /share/*any` **必须是 catch-all**:路径里的段是数据不是文件路径, `gin.Static` 会去磁盘找同名文件、一律 404。 - 落地页里的 `ALLOWED_HOSTS` 白名单不能删:`serverHost` 来自 URL,不校验就等于 「构造一条链接让别人的浏览器带着票据去请求任意域名」。新增环境要加一行。 ⚠️ 落地页那个极简 Markdown 渲染器**先 `esc()` 再套正则**,所以块级语法要匹配转义后的形态—— 引用块匹配的是 `>` 不是 `>`。写成 `>` 不会报错,只会让引用块静默退化成普通段落(踩过)。 ### 其他 - 状态管理 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 图片那处 原来拼了),前缀由服务端加,客户端再拼一层就是重复路径。 ### 更新入口:一个「网页下载页」兜住所有渠道(2026-09-23) **App 自己不做品牌路由,下载页做。** 链路: ``` 客户端按品牌推导渠道(getAndroidStoreInfoByBrand) → user_getchannelapp{channel, version} → 服务端:更新判定只看 app_release(与渠道无关);地址 = 渠道填了用渠道的, 没填回退 app_release.address(AppRelease.DownloadAddress) → performUpdate 打开这个地址 ``` 所以后台「版本控制 → 默认下载地址」填 `https://dl.ymaikj.com` 之后,**任何渠道没单独配地址 都会落到网页下载页**,再由那个页面按品牌跳商店 / 认不出就给安装包。要让某家商店直达, 才去「渠道分发」给那条填商店链接。 - 这套**不需要新增字段**:`app_release.address` 的语义本来就是「渠道没配时的兜底」, 而下载页正是兜底入口。 - 渠道行不存在**不影响更新提示**:`evalChannelUpgrade` 只读 `app_release`。 ⚠️ 客户端只有在 `app_release` 也没配时才会拿到 `app:null` 并按「无更新」处理。 - 后台「上传 APK」传完写进**渠道分发 → 官方渠道(100)** 的 address(网页下载页的安装包直链取它), **同时把直链填进「默认下载地址」输入框**(2026-09-23 用户要求:传完要在框里看到链接,且这个地址 就作为官方包的下载地址;填进表单后仍要点「保存」才落库)。要让默认地址回到网页下载页,手动改回即可。 ⚠️ `savechannelapp` 是**整行覆盖**,所以那段必须「读整行 → 只改 address → 写回」; 而且 `getchannelapps` 回的是 `{items:[...]}` **不是裸数组**,当成数组取会永远走新建分支, 把官方渠道已有的上架状态/支付渠道清零,且不报任何错。 - ⚠️ **超管传安装包报「必须先选择应用(请求缺少 X-App-Id)」,而页面上明明选了**(2026-09-23 测试服踩到): `X-App-Id` 头来自登录态的 `currentAppId`,只有绑定了应用的运营/代理账号才有;超管在「版本与分发」页顶部 选的应用只是页面里的一个 `app_name` 字符串,从不进请求头。现在 `uploadFile` 把页面选的应用名放进 `getcostoken` 请求体的 `app_name`,服务端没有 `X-App-Id` 时按 name / app_name 反查注册表补作用域 (`getAppByNameOrAppName`)。主桶那条(固件/产品图)不受影响,它不按应用分目录。 ### 分发桶:App 安装包与录音/固件分开放(2026-09-23) `comm.AliyunOSSConf` 多了 `DistBucket` / `DistDomain`(对应【第三方服务配置 → 存储】的 `dist_bucket` / `dist_domain`,留空用 `comm.DefaultDistBucket`/`DefaultDistDomain` = `ymai-dl` / `https://dl.ymaikj.com`)。凭据与主桶共用,`conf.ForDist()` 取副本。 后台「版本控制」页的 **上传 APK** 按钮走 `api_getcostoken` 带 `target:"dist"`: - key **不加 `EAIMAR/` 根前缀**(分发桶直接挂在 `dl.ymaikj.com` 上,加了会让下载地址多一段), 目录只允许 `comm.DistUploadDirs` 白名单里的(当前只有 `android`)。 边界由 `TestBuildDistObjectKey` 钉死——这个桶是公共读的,key 拼错等于公开网盘。 - ⚠️ **Content-Type 由服务端按扩展名强制**(`distContentTypes`),并**回带给前端**: 浏览器对 `.apk` 给出的 `File.type` 往往是空字符串,前端照它发 PUT 会与预签名不符, OSS 回 403 SignatureDoesNotMatch,而报错里完全看不出是类型对不上。 - ⚠️ **分发桶必须单独配 CORS**(来源 `https://console.ymaikj.com`、方法 PUT)。 浏览器直传打的是桶的 OSS endpoint 而不是自定义域名。没配时 XHR 只报一句没有细节的 「网络错误」(浏览器刻意不暴露跨域失败详情),所以 `uploadFile` 的 onerror 文案 直接点名了这个原因。主桶 `ymaioss` 早已配好,`ymai-dl` 2026-09-23 实测还是 403。 ⚠️ **新老客户端并存**:老包仍读 `COS_*` 直传腾讯云,所以业务库 `config` 表里那 5 个 `COS_*` 键**先别删**。历史录音的 `audiourl` 是 COS 的绝对地址,不会自动搬家。 ⚠️ **录音目前仍是公共可读的**:bucket 公共读(实测匿名 GET 返回 206,不存在的 key 返回 404 而非 403),谁拿到 URL 谁就能听,只靠 12 位随机 key 不可猜测来保护—— 所以随机名用的是 `crypto/rand` 不是 `math/rand`。要真正锁住得把 bucket 转私有 + 播放时也签临时读 URL,客户端所有播放入口都要改,还没做。 ### 腾讯云 COS 服务端整体下线(2026-09-23) 服务端**不再有任何腾讯云 COS 代码**:`sys/tencentyun/` 整个删除,home / api 两个服务的 `cos.OnInit` 与 `Sys["cos"]` 配置段一并去掉,`comm.svcRuntimeSections` 也不再托管 `cos`。 - **头像上传改走 OSS**:`modules/user/api_setting.go` 的 `cos.Put` → 本文件里的 `putAvatar`, 配置复用 `comm.LoadAliyunOSSConf`(与 user_getuploadurl / console 后台直传同一个桶)。 object key 是 `EAIMAR/User//Avatar/`,**不带随机段**,即换头像覆盖同一对象 (与 COS 时期行为一致,代价是 URL 不变、客户端可能吃缓存)。扩展名过 `uploadExtWhitelist` 且必须是 `image/*`。 - `modules/api/api_getcostoken.go`(腾讯云 STS)**已删**——它是孤儿:后台 admin 调的 `getcostoken` 其实落在 console 的 [api_upload.go](apps/services/modules/console/api_upload.go) (签阿里云 OSS 预签名),路由名只是沿用;客户端从不调它。 - 后台「系统配置 → 对象存储」与「第三方服务配置 → 存储」里的**腾讯云 COS 卡片/模板已删**, 巡检的 `probeTencentCOS` 也删了。理由:服务端读存储配置的唯一入口 `comm.LoadAliyunOSSConf` 只认 `provider=aliyun_oss`,留着 COS 这一项是「配得上、却不生效」的陷阱。 **存量 `tencent_cos` 行不会被自动删除**,只是不再有读取方。 ⚠️ **老客户端仍读业务库 `config` 表里的 `COS_*` 直传腾讯云,那 5 个键还是别删**—— 服务端下线的是自己这一侧,桶还在、老包的上传路径没断。历史录音的 `audiourl` 是 COS 绝对地址,同样不会自动搬家。 ### 音乐服务整体下线(2026-09-23) `sys/music`、`sys/musicobj`、`modules/music`、`modules/mcp/tool_music.go` 全部删除, home 的模块列表与 `musicobj` 初始化、home/mcp 两份 yaml 模板里的 `musicobj` 段一并去掉。 起因是它的 `ApiBaseUrl` 指向上一代品牌域名(`hw.music.voitrans.net` / `hw.music.ideapsound.com`)。 客户端的音乐模块本来就早已删除。 ⚠️ **咪咕音乐(`tool_migu_music` + `migu` 服务)没有动**,别一起当成音乐链路删了。 `pb` 里的 Music* 消息保留(生成物,改要动 .proto 重生成),现在没有读取方。 ## 配置与密钥约定(重要) - 各 `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/...`,并核对接口签名是否符合上文反射注册约定。