You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

176 KiB

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/<service>/        # 每服务一份 deploy.sh + docker-compose.yml + env/

常用命令

Go 后端(在 apps/services/ 下执行)

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/<svc>.yaml。

Admin 前端(在 apps/admin/ 下执行)

pnpm install
pnpm dev        # 开发,默认 :3000
pnpm build      # 产出 .output/(SSR)

本地开发需同时起 console 后端(默认代理到 127.0.0.1:8080,见下文 proxy)。超管账号见各 console.yaml 的 AdminAccount/AdminPassword。

Client 移动端(在 apps/client/ 下执行)

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。

构建镜像与部署

./build.sh console          # 构建 registry.voitrans.net/starpivot-console:latest(PUSH=0 只构建不推)
./build.sh admin

# 测试环境发布(在 deploy/<服务>/ 下,服务 = app | console | admin)
cd deploy/console && ./dev-deploy.sh along       # 阿龙测试环境(阿里云 8.133.166.29);目前只有这一个环境

# 生产:先构建带版本号的镜像,再部署
cd deploy/console && ./prod-build.sh 0.2.1 along    # 第 2 个参数选公司(yunyan|lingpu|along),省略则交互选
cd deploy/console && ./prod-deploy.sh 0.2.1 along   # 第 2 个参数选生产环境(目前只有 along)
cd deploy/app     && ./prod-deploy.sh ym along 0.2.1   # app 按「应用 区域 版本」部署

⚠️ 本机跑部署要带 SSH 私钥:脚本里 along 段默认写的是 /Users/liwei1dao/…(另一台开发机的路径), 本机(yunyanzhineng)实际那把是 ~/Documents/keys/loginscre.pem。写成了 ${DEV_KEY:-…} / ${DEPLOY_KEY:-…} 可被环境变量覆盖,不用改脚本:

cd deploy/console && DEV_KEY=$HOME/Documents/keys/loginscre.pem ./dev-deploy.sh along
cd deploy/console && DEPLOY_KEY=$HOME/Documents/keys/loginscre.pem ./prod-deploy.sh 0.2.1 along

⚠️ 2026-09-13:云雁(yunyan)/灵谱(lingpu) 的部署目标已从所有脚本移除,ENVS/PROD_ENVS/REGIONS 都只剩 along。那 6 台(云雁测试/上海/日本/新加坡、灵谱测试/正式)的 SSH 私钥路径指向另一台开发机, 本机一台都连不上,留在菜单里只会误选。IP 与私钥路径不再记在这里,需要时从 git 历史取回。 镜像仓库没有动:prod-build.sh 仍可选 yunyan / lingpu 推镜像,三家仓库本机都能读写。

镜像仓库按公司区分,地址/账号统一在 deploy/registry-profiles.sh(换仓库只改这一处,各脚本 source 它):

  • yunyan → registry.voitrans.net(云雁自有仓库;只用于推镜像,本仓库已无云雁的部署目标)
  • lingpu → registry.lingpu.net(灵谱自有仓库;同上)
  • along → registry.ymaikj.com(仓库实体就在阿龙测试机 8.133.166.29 上,账号是 admin 不是 liwei);阿龙测试机与正式机 47.116.104.181 都从这里拉镜像

测试环境档案在各 dev-deploy.sh 的 env_profile(),只有 along 一个:

  • along → 8.133.166.29(阿里云,x86_64/Docker 24.0.9),目录 /home/work/starpivot/{app,admin,console} 该机已自带 postgres:16 / redis:7 / mysql / NPM / gitea / docker-registry,配 .env 时优先复用,别另起一套。 另外还跑着 deepglass-a11 与另一产品 keemir-{server,admin},别误当成本项目的容器。

生产镜像名统一为 starpivot-<服务>(admin/console/app 三个服务、各家公司都一样),映射在 registry-profiles.sh 的 prod_image_name()——build 与 deploy 共用这一份,别在各脚本里写死。 compose 的 image 由脚本注入 IMAGE_NAME 变量,同样不写死。

⚠️ app 的旧名 voitrans-a11 已于 2026-08-10 废弃,仓库里该名下的旧 tag(如 0.1.2)不会自动搬到新名, 换名后首次上线要重新 ./prod-build.sh <version> 推一版;服务器上手工维护的 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 的 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)依次 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。
  • HTTP 路由靠反射自动注册(modules/comp_httpgate.go):继承 modules.MCompHttpGate 的组件,其每个导出方法只要签名是
    func (c *apiComp) MethodName(session comm.IUserSession, req *pb.XxxReq) (*pb.XxxResp, *pb.ErrorData)
    

    就会被注册成路由 <module>_<methodname小写>(如 api_getfactorys)。新增接口 = 新建 modules/<mod>/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 把 /web/api/* 和 /console/api/* 代理到 CONSOLE_BACKEND(容器内由 env 注入,本地默认 http://127.0.0.1:8080)。
  • useApi().webApi(method, data)(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。 ⚠️ 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)。两个页面各自只提交自己那组字段, 后端 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/<vendor>/ 或出现 BesBluetoothService/HolderDeviceService/package:bluetooth_manager 的文件。
  • 新接一种芯片:① lib/devices/<vendor>/ 写一个 DevicePlugin + DeviceSession(协议按厂商文档/真机实测, 加金测试);② device_bootstrap.dart 加一行 _register(hub, XxxPlugin());③ device_vendors.dart 加 key; ④ agent_module_defaults.dart 的 _vendorCategory 加品类映射;⑤ 连接页按品类进 device_category_view。业务层零改动。 Smartcar 的接入设计见 docs/Smartcar车载香薰-接入设计与落地文档.md(它是杰理方案,jl_opus 已从工程删除, 接入时从 git 历史恢复或改用 recorder_holder 那套 Opus 解码)。
  • 杰理链路已整体删除(BleManager / device_jieli 的 Dart 适配 / 杰理扫描页 / 杰理 OTA / jieli_call_recorder)。 device_jieli、classic_bluetooth、test、chat_api、ble_service、bytedance_speech、swift_sdk_local 这几个插件 Dart 侧零引用但原生侧被别的插件依赖(recorder_holder→device_jieli 的 JLAudioUnitKit、 agent_service→test/ble_service/chat_api/bytedance_speech、azure_speech→classic_bluetooth、chat_api→swift_sdk_local), 所以目录和 pubspec 条目都保留着;device_jieli 改成了 iOS-only 插件。
  • ⚠️ iOS 本地插件的相对路径依赖:这些插件的 Package.swift 用 ../../../<plugin>/ios/<plugin> 引用兄弟插件, 而 Flutter 把插件软链在 ios/Flutter/ephemeral/Packages/.packages/ 下,SPM 不解析软链,rm -rf ios/Flutter/ephemeral 之后必然报 "the package at 'ephemeral//…' cannot be accessed"。flutter pub get 之后跑一次 ./ios/sync_spm_platform.sh,它会在 ephemeral/ 下补同名软链(幂等),顺便对齐平台版本。

EaiRec 支架(Echomeet)的 BLE 现状 —— 2026-09-10 真机实测

协议文档 local_plugins/recorder_holder/EaiRec_protocol.md。核对结论:UUID、帧头 (AA 请求 / BB 响应 / CC 上报)、CRC8(poly 0x31)、通知使能顺序 (ae02 → abc2 → FDA6)、CCCD 串行队列、MTU 延时 1s 后 requestMtu(512) —— 代码与文档逐条一致。 唯一出入是「连接」那一步,已修(见下)。

⚠️ 在用的这台支架广播名是 Echomeet,不是 EaiRec。切换名字的唯一入口是 EchoMeetDeviceSdk.defaultNameFilters(sdk/lib/src/client.dart),协议 §2.1 规定 App 不按 Service UUID 过滤、只按设备名前缀筛。名字对不上的表现是 扫描页一台设备都不出现且不报任何错。现在 Echomeet / EaiRec 两个前缀都留着, 设备刷成新名也不用改代码。

⚠️ Android 上「连接」以前根本不发 GATT 连接(已修): EchoMeetDeviceSdk.connect() 只调 registerBackgroundScan(mac),而它仅仅是 savePairedMacAddress + 注册一个 SCAN_MODE_LOW_POWER 的 PendingIntent 扫描, 一次 connectGatt 都不发;真正连接要等系统扫到广播 → BleWakeReceiver(还带 10s 去抖) → 拉起前台服务。于是「点连接」立刻返回 true、waitConnected() 干等 25s 超时 —— 扫得到但永远连不上。原生早就暴露了 connectToDevice → BleService.connect(mac) → device.connectGatt,只是 Dart 侧没调。现在注册完后台回连立刻再直连一次。

⚠️ 这台固件整个 GATT 只有一个服务(2026-09-10 真机枚举,日志在 BleService.onServicesDiscovered 的「🔍 服务发现完成」那几行):

🔍 服务发现完成,共 1 个服务
  └─ service 0000fda5 : 0000fda6, 0000fda7      ← 只有通道 B(TF 卡文件)

没有通道 A(0000abc0/abc1/abc2),也没有 ae00 音频服务、没有 0x180A。 后果是链式的,且每一条的表象都不指向真因:

想做的事 协议里在哪条通道 这台固件上的结果
查电量(0x04) 通道 A 做不到
查固件版本 通道 A / 0x2A26 做不到,固件升级页「当前版本」恒为 --
实时音频上行 通道 A'(ae00) 做不到,录音 0 帧
TF 卡列表/下载/删除 通道 B(FDA5) ✅ 唯一可用的

⚠️ 原生那句 发送命令失败: 设备未连接 是误导:链路好好的,checkConn() 挂在 writeChar != null 上,而 writeChar 来自 abc0 服务 —— 服务不存在, 它就恒为 null。排查前先看服务清单,别去查连接。 HolderDeviceService._kickBatteryQuery() 连上后带重试地问 4 次电量(服务发现 比 connected 回调晚约 700ms,头两次必然失败,这个重试是必要的), 问不到就打一条指名道姓的日志,不再假装是时序问题。

⚠️ DeviceCapability.battery 仍然声明着,因为这是协议能力不是这台样机的能力; 设备页按「快照里有什么显示什么」,拿不到就不显示那一行,行为是对的。

⚠️ 这台设备没有实时音频上行通道(协议 §1 的通道 A' 0000ae00/0000ae02)。 真机服务发现稳定打出:

📁 发现通道 B(TF 卡文件)服务 0xFDA5      ← 有
⚠️ 音频服务特征未找到                      ← ae00/ae02 根本不存在
⚠️ 未找到下行音频特征
📦 未发现 0x180A 服务 → 旧固件,音频规格 LEGACY (16kbps/40B)
✅ MTU 协商成功: 512

所以 DeviceSession.openMic(AudioRoute.mic)(走 audioFrames 实时流)在这台设备上 必然 0 帧,DeviceRecordingService 会如实报「本次没有收到音频数据,删除空文件」。 这台支架的录音是「录到 TF 卡、事后经通道 B 下载」,不是边录边传。

设备端录音同步进语音纪要(2026-09-10 打通,真机验证)

上面那条已经落地了 —— 不是改 openMic,而是新加了一条厂商无关的通用链路:

DeviceCapability.fileStore                      设备契约新增的能力
DeviceSession.fileStore() → DeviceFileStore     list / download / delete / listPushes / isDeviceRecording
lib/devices/holder/holder_file_store.dart       支架实现(包 RecClient 的通道 B)
lib/data/services/device_file_import_service.dart  业务层导入服务(厂商无关,常驻)
lib/data/utils/ogg_opus_duration.dart           时长解析(granulepos)
RecordingArchive.archive()                      复用的归档入口(挪盘→建记录→写库→上传)

新接一种「设备自己录、事后同步」的硬件:实现 DeviceFileStore + 会话声明 fileStore 能力即可,导入服务零改动。

导入触发三处:设备上线后 5s、设备推送文件列表(录完自动推,「录完立刻出现在 列表里」靠这个)、importNow() 手动。进度显示在语音纪要首页顶部一条( meeting_home_view._buildDeviceSyncBanner,复用已有的 importing 文案键)。

⚠️ 不删设备上的文件,只在本地记「已导入」指纹(设备id|文件名|字节数)。 指纹带字节数是因为 8.3 短名会回绕复用(MIC0001.OGG 删了下次可能又叫这名), 只按名字记会把新录音当成已导入永远跳过。

⚠️ 原生 writeRecCommand 曾经用 checkConn() 判前置条件,而它要求 writeChar != null —— 那是通道 A(abc1)的写特征。 支架没有通道 A,于是 通道 B 明明齐全(0xFDA5 发现了、CCCD 也写成功了)却一个字节都发不出去, 日志只有一句误导性的「通道 B 写入失败:未连接或未发现 0xFDA7 特征」。 已改用 isRecChannelReady()(只看它自己那两个特征)。iOS 侧本来就是对的。

⚠️ _initRecChannel() 必须重试(现在 6 次 × 800ms)。原来一次探不到就 return,而原生的 connected 回调比服务发现早约 700ms,于是必然第一次就失败 —— 后果不只是「这次没握上手」,而是对时没做(文件 FAT 时间全错)、文件列表推送 没订阅(录完不会自动同步)、设备状态没拉;偏偏 ensureRecChannel() 是每次 重新探的,手动拉列表反而能成,形成「列表能拉、录完却不会自动出现」的半死状态。

⚠️ 时长不能用播放器读,也不能按码率估。文件是标准 Ogg Opus(OggS+OpusHead, 16kHz 单声道),但 just_audio.setFilePath 在 Android/iOS 两端都报 Source error; 按 16kbps 估会差一倍(真机 38400 字节那条实际 9.16s,估成 19.2s)。 正解是读最后一个 Ogg page 的 granulepos(Opus 恒以 48kHz 计样本,RFC 7845 §4, 除数写成 16000 会算成 3 倍),减去 OpusHead 的 pre-skip。 金测试 test/ogg_opus_duration_test.dart 用的是真机那条录音的原始字节。

⚠️ 列表上的日期目前是「导入时刻」不是「录制时刻」,这是服务端限制: echomeet_addrecord 把 Creationtime 写死成 time.Now().Unix() (EchomeetAddRecordReq 里没这个字段),而 Synchrodata._synchrodata() 会拿 服务端的值整行 update 覆盖本地库。客户端已经按「服务端接受」的形状把设备时间 送上去了(见 lib/data/utils/recording_time.dart),那段不是死代码, 服务端加上字段就自动生效。录制时间当前只活在标题里 (holder_20260907_181224_MIC0049.ogg)。

iOS 上支架扫描起不来:进程里第二个申请状态恢复的 central 拿不到电源状态(2026-09-11 iPhone 12 / iOS 26 真机定性)

表象:扫描页永远「正在搜索」、一台设备都不出现,syslog 里原生每次都打 ❌ 蓝牙未就绪,无法开始扫描 (central.state=4)(4=poweredOff),而同一进程、 同一个插件里另一个不带 restore identifier 的 central(ClassicBluetoothHelper)报 5=poweredOn。

根因:local_plugins/ble_service(Dart 侧零引用的僵尸插件,只因 agent_service 的原生 依赖还链在工程里)在 GeneratedPluginRegistrant 里排在 recorder_holder 前面,注册时就 建了一个带 CBCentralManagerOptionRestoreIdentifierKey 的 central。同一进程里第二个 申请状态恢复的 central 恒为 poweredOff —— 与 id 是否同名无关(两边原来都叫 "BLEAssistantCentral",改成不同名之后依然是 4,改掉 ble_service 那边的 restore 选项 之后支架立刻变 5)。修法是把僵尸插件的 restore identifier 去掉,名额留给真正在用的支架 (agent_service 只调它的 closeCodec(),不依赖状态恢复)。 支架自己的 id 顺手改成了 "com.recorder.holder.central",startScan 失败日志带 central.state=N, 状态跳变日志提到 .default 级别。

⚠️ 别按上一轮的记录去查「两个 CBCentralManager」本身——多个 central 并存是合法的, 问题只在「带 restore identifier 的」只能有一个真正工作。也别去查权限: requestBluetoothPermission 在 iOS 插件里虽是 FlutterMethodNotImplemented, 但 HolderDeviceService.requestPermission() 在非 Android 上直接放行,走不到那条。

iOS 上 TF 卡导入这条链与 Android 共用同一套 Dart(DeviceFileImportService / HolderFileStore);原生侧 writeRecCommand 本来就只看通道 B 自己的两个特征, FDA6 的 notify 走 discoverDescriptors → setNotifyValue,recData 事件形状与 Android 一致。 自动回连在 iOS 是原生对已知外设发 pending connect(设备一广播就接上,不需要扫描)。

⚠️ 设备 FAT 时间戳在 RTC 对时之前是垃圾值(真机那张卡上有一批 2018-08-10), 所以只采信「2020 年之后且不在未来」的值,其余退回当前时刻 —— 显示成「刚导入」 好过让记录沉到列表最底下再也找不到。

⚠️ 反复热重装会留下僵尸 GATT 客户端:现象是连上了但服务发现不回调、 checkConn() 因 writeChar == null 失败、所有命令返回 发送命令失败: 设备未连接, 约 30 秒后断开重连,如此循环;bt_stack 日志里能看到另一个 gatt_if 每秒在收 这台设备的 notification。adb shell am force-stop 后冷启动即恢复。排查支架问题时 先冷启动一次,别对着这种状态查代码。

⚠️ 绑定接口不要传 pid:服务端 UserBindDeviceReq.pid 是 int32,而支架能填的 advertisementHex 是十六进制字符串(常常还是空串),传上去必然 readUint32: unexpected character → code:22 整个请求被拒。恒玄那条链路就是不传 pid 的。 改对之后服务端正常受理,返回 {"code":2005,"msg":"设备未登记"} —— 那是数据问题: 这台的 MAC 要先由后台导入 device_mac 表(协议 §2.3)。绑定失败不阻断连接与录音。 MAC 导入后已实测打通(2026-09-10):user_binddevice 返回 {"code":0,...,"device":{"id":32,"productid":45601,...}},设备管理页正常列出。

⚠️ 绑定登记不能只挂在 connect() 上:那是扫描页手动连接才走的路径, 自动回连不经过它。设备一旦被记住,之后每次都是原生回连上线,绑定接口 一次都不会打 —— 换账号、或后台补录完 MAC 之后,设备管理页会一直空着。 现在连接状态变 connected 时也会调一次(bindConnectedToServer()), 成功的 MAC 记进进程内 _bound 去重,失败的不记,下次连上还能重试。

⚠️ 固件升级页按名字找不到产品:支架广播名是 Echomeet,产品表里叫 EaiRec (pid 45601),两者互不包含,DeviceOtaController._findProduct 的「名字互相包含」 匹配必然落空 → 恒报「未找到设备对应的产品配置」,一次服务端读取都不发生。 现在先用绑定关系里的 productid(服务端从 device_mac 命中行读出来的权威值), 名字匹配退为兜底;User.instance.devices 为空时页面会自己补拉一次 user_getdevices。

Agent 模块注册表 lib/modules/module_registry.dart

  • 一个能力模块 = lib/modules/<name>/module.dart 导出的一个 AgentModuleDescriptor(lib/modules/module_descriptor.dart): 卡片元数据 + pages(自带路由)+ entryRoute/onLaunch(卡片点击)+ requiredCapabilities(依赖的设备能力)
    • directives(能落地的端侧指令)+ translations(模块自带文案,与全局语言表合并)+ registerServices。
  • 新增一个模块只做两件事:建目录写 module.dart;在 ModuleRegistry.all 加一行。 路由(AppPages.pages 末尾 ...ModuleRegistry.pages)、卡片跳转(module_card.dart 的 launchModule 走 desc.launch())、指令(AssistantDirectiveService.registerHandlers)、文案(AppTranslations.keys 走 ModuleRegistry.mergeTranslations)都不用再改。test/module_registry_test.dart 守着 id 唯一 / 有跳转 / 路由已注册 / 文案有落点。
  • AgentModules.all(data 层)由 ModuleRegistry.install() 在 InitialBinding 注入,data 层不反向依赖 modules 层。
  • 路由表现在只放核心页面;test/routes_test.dart 要求每条 Routes 常量都已注册且有跳转方(死路由会红)。 Routes.OTA_UPGRADE 已改名 Routes.otaUpgrade,Routes.devices(杰理扫描页)已删,用 Routes.deviceCategory。

本轮一并删掉的东西(别去找)

modules/agent(老 Azure→火山对话页,9000 行,路由早已不可达)、modules/realtime|timbre|usage|ble|eq_settings|speech_test|opus_test|music|qqmusic、 data/services/{kouzi_ai_service,open_ai_service_adapter,ble_manager,bluetooth_manager,face_to_face_ble_service}、 data/providers/agent_provider.dart、local_plugins/{jl_opus,realtime,open_ai_service,volcano_speech,asr_tr_tts_manager}、 根目录的 test.json/test2.json/*.py。auth_interceptor.dart 不再把签名原文 print 出来,签名 key 可由 .env 的 API_SIGN_KEY 覆盖。 analysis_options.yaml 把 unused_* / dead_code / avoid_print 提成 warning,CI 用 dart analyze --fatal-warnings lib。

Client 移动端约定(Flutter,2026-08-28 并入 monorepo)

apps/client 从 /Users/yunyanzhineng/work/eaimar_client-main 搬入,未带原 .git,历史留在原仓库。

⚠️ 搬仓时源仓库 .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)接住,转发到对应服务里 反射注册出来的同名方法。所以后端加一个 api_xxx.go 方法 = 客户端立刻多一个可调接口, 两边都不用改路由表,只需在 lib/data/services/network/api.dart 补一个静态方法。
  • SERVER_URL 在 apps/client/.env 里,走 flutter_dotenv; 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)。 真机调试别写 127.0.0.1 —— 那是手机自己。
  • 两套 "env" 别混:
    • dotenv.env['X'] = apps/client/.env,本机/渠道级,编译进包;
    • AppConfig.env('X')(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)的, 没有代码生成把两边绑在一起。改 proto 字段后要手动同步 appconfig_model.dart 并重跑 dart run build_runner build(生成 *.g.dart)。服务端已为此留了兼容层, 见 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 的 ApiDecryptInterceptor,必须排在 AuthInterceptor 之前(它一看到不是业务 JSON 就当失败)
模型 UserGetAppConfigResp.thirdsvcs: List<ThirdSvc>(id/name/provider/categories/fields),缺键 → 空列表
取值 AppConfig.cred(svcId, fieldKey, envKey):thirdsvcs 的 (服务 id, 字段) 优先,退回 env[老键名],两边都没有 → null
  • 服务 id / 字段名与服务端 migrate_envtosvc.go 的落点表一一对应(stt_azure.subscription_key、 ast_alibaba.app_key、llm_doubao.api_key…),改哪边都要同改;老键名只作兜底。
  • 加密口径(lego/utils/crypto/aes/cbc.go):AES-CBC,IV 固定 16 个 '0',PKCS5/7,密文 base64, 响应头 X-Encrypted: 1、Content-Type: text/plain。test/api_crypto_test.dart 的密文是 openssl 按 Go 口径 生成的金向量——用同一套 Dart 加密再解密只能证明自洽,证明不了与 Go 端一致。
  • ⚠️ GATEWAY_ENCRYPT_KEY 要进客户端 .env(gitignore,值与服务器 deploy/app 的 .env 一致,测试/正式同一把); 没配就静默回退 v1,thirdsvcs 为空、AppConfig.usingThirdSvcs == false——排查「明明配了服务却读不到」先看这个。 密钥不一致的表现是解密后 PKCS7 去填充失败 → 拦截器 reject,报错文案指向密钥,别去查网络。
  • ⚠️ v3 必须在网关白名单(deploy/app/confs/gateway.yaml.example 与各机 confs/gateway.yaml):splash 在登录前 就取配置,少这一行返回 code:18 然后整个启动流程走兜底。测试机已加;正式机 ym-a11 的 gateway.yaml 尚未加(要重启容器), 新客户端上正式前必须补。
  • 未登录时 v3 的 filterSvcsByVip 拿不到 uid → 按有 VIP 放行;实名(11) 类照旧永不下发。
  • cred() 返回 null 而不是空串:空串会把调用方的 ?? 默认值 顶掉(AZURE_TRANSLATION_ENDPOINT 栽过)。

游客登录与功能闸门(2026-08-29 改)

游客登录走服务端 api_sgin 的 Tourists 分支(stype=6 + phonemac=设备id),拿真实雪花 uid 与 JWT。 早先是 _guestLoginLocal() 本地伪造会话(token 形如 guest-xxx),网关解不出 uid, 游客一个后端请求都发不出去——实名认证接口自然也调不通。

  • 服务端 findtouristformac(model_user.go)按设备找回游客账号。 ⚠️ 不能改用现成的 findformac:phonemac 是所有登录方式建号时都会写的字段, 同一台设备先用手机号登录过、再点游客登录会串进那个正式账号。该函数额外要求 phone/mail/account/*openid 全为空,只有游客账号满足。
  • ⚠️ 找回逻辑原先是注释掉的,恒定走新建账号分支,后果是游客每次登录都是新账号: 实名记录留不住,且新账号分支紧接着发新用户开户礼,等于可以无限白嫖。
  • 功能闸门不再按游客身份拦,一律按实名状态拦:GuestGuard 已删除,所有调用点改用 IdVerifyGuard.blocked()(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,同 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), 入口根本不读服务端配置。所以「其他版本全部隐藏」只对装了本次改动及以后版本的客户端成立, 存量包无论后台怎么配都仍会显示入口。
  • 入口所在的 _buildSocialLoginSection 必须是 Obx 不能是 Builder:配置是异步拿到的, Builder 只渲染 onInit 时的初值 false,入口再也不会出现。
  • 拿不到配置(无网/接口失败)时按隐藏处理。反过来默认显示会在送审期把不该露的入口露给审核员。 代价是断网启动后 10 分钟内(checkForUpdate 的缓存窗口)入口不出现。
  • ⚠️ user_getchannelapps 必须在网关白名单里(gateway.yaml.example)。 游客入口的显隐结论就在这个响应里,而入口恰恰只在未登录时才需要显示 —— 少了这一行 会返回 code:18 NoLogin,客户端静默按「隐藏」处理,表现为「后台开了但 App 里没有」, 且不报任何错。白名单里原本只有单数的 user_getchannelapp(客户端从不调它), 这个洞让游客入口在任何未登录设备上都不可能出现。
  • ⚠️ 一次失败会把入口锁死 10 分钟:checkForUpdate 在发请求之前就写了 _lastCheckTime (version_update_service.dart), 请求失败后缓存时间戳已落下,而缓存命中分支不重算 _isShowTourists。 splash 那次失败之后,登录页再怎么重进都是隐藏的。

EMAI 助手的端侧指令(2026-08-29 接入)

百炼多模态会在 RespondingContent 这一帧里下发端侧函数调用,客户端原先整个丢掉了 (只取了 event/text/finished/state 四个字段),所以助手嘴上说"闹钟已经定好",实际什么都没做。

链路:协议层解析 → 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 的 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。

  • 性别口径与 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:

# 先列出来看哪个文件在更新(Logger.initFileLog 写在 Documents/logs/)
xcrun devicectl device info files --device <UDID> \
  --domain-type appDataContainer --domain-identifier com.yimai.eaimar | grep logs

xcrun devicectl device copy from --device <UDID> \
  --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: 控制命令(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。 处理两层,都不丢内容:积压 >1s 裁掉超过 150ms 的静音;>2s 起 WSOLA 变速不变调,4s 达 1.3x 上限。 同量压测:最大积压 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。

通话翻译的 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 的 showVersions/addVersion/useVersion
后台接口 api_addproductversion / api_getproductversions / api_delproductversion(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<BleManager>(),复用它等于把杰理依赖又拖回来。

⚠️ 产品匹配用 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 里四个错叠加,且每一个都不报错、只表现为「界面上那块干脆不出现」:

  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 的 _buildBatteryInfo:左右耳都有值就分开显示 L/R,只有单侧退回一个无标电池, 盒电量有就补一行 C,充电位驱动 BatteryIndicator 的充电图标。

设备管理:恒玄连上要向服务端登记绑定(2026-09-01 补)

服务端这套早就齐全:user_binddevice / user_unbinddevice / user_getdevices (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 里的实际内容再怀疑代码:

redis-cli -a <pw> -n 1 HGET '<REDIS_KEY_PREFIX>:cache:product' '<pid>'

⚠️ 客户端 AppConfig 是纯内存、每次启动重拉,不存在本地旧缓存,别往这个方向查。

设备确权:连上就校验 MAC,不通过直接断(2026-09-02 加)

上节那次「登记」的返回值原先是丢掉的——绑不上只在日志里留一行,设备照用不误。 现在把同一次调用升级成了闸门: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十六进制>),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_<pid十六进制>,一产品一张 device_mac,全局一张(comm.TableDeviceMac)
定位设备 (客户端猜的 pid, MAC) 查对应分表 先 code 后 devicemac,全局查,命中即可
pid 客户端传上来的查询条件 从命中行的 productid 列读出来的结果
客户端 查本地产品表挑 pid、按名字匹配 只报 MAC,不传 pid
应用隔离 pid 隐含(产品表按应用下发) 不隔离——MAC 存在就放行(明确取舍)
  • 迁移在 registry.go 的 migrateLicenseToDeviceMac, 每次启动跑、幂等:① code := devicemac 修存量 → ② 按两表列名交集做 INSERT ... ON CONFLICT DO NOTHING(不能 SELECT *:各分表建于不同时期, 有的多 factoryid/probatch 两列)→ ③ productid 空的用表名里的 pid 补上。 孤儿分表(产品已删但表还在)里也是真实发出去的设备,一并搬。
  • ⚠️ 搬完刻意不删旧表。这是不可逆数据(已发到用户手上的设备),旧表是出问题时 唯一的回退依据。确认新表跑稳后由人手工 DROP。删产品时会顺手 DROP 掉它那张空的旧分表。

⚠️ 合表后每个「按产品」的读写都必须显式带 productid

分表时代,license_<pid> 这张表隐含了产品作用域;合表后这层隔离没有了, 漏一处就跨产品操作——查询多列出别人的设备只是难看,批量禁用 / 按批次删除漏掉 就是直接删别的产品的数据。所以统一走 comm.ScopeToProduct 把 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。 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。两层权益各管一件事, 所有判定与记账走 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,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,只清 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 是 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) 把 _channel 设成品牌推导出的渠道,.env 的 Channel 只作兜底(品牌认不出的三星/魅族等才用)。 渠道统计、游客登录/跳过设备绑定开关、版本更新地址都走这一个值。
  • ⚠️ GLOBAL_GOOGLE 与 OFFICIAL 不参与品牌推导:前者装在华为手机上仍须连 HW_SERVER_URL (dio_manager 的 baseUrl 只认这个值),后者是官方/内测包才显示的"工具打断"菜单开关。 国内包的兜底值别填这两个。
  • ⚠️ .env 的 Channel 必须写 UserChannel.fromName 认识的全名(CHINA_XIAOMI), 写成 xiaomi 会被解析成 UNKNOWN。
  • 华为/荣耀商店要求"每次启动重新确认隐私政策",这条判断在 login_controller.dart 的 _initDeviceInfo() 里,用 DeviceBrand.isHuaweiOrHonor(brand, manufacturer) (device_brand.dart,两个字段任一命中即算, 老荣耀机型 brand=HONOR/厂商=HUAWEI)。别改回读 versionService.cachedChannel——那个值在版本 检查跑完前会回退成 GLOBAL_GOOGLE,无网启动时合规逻辑直接失效。 _hasAcceptedPrivacy 初值 false,品牌读出来之前默认"未同意",失败方向偏向合规。
  • 口径偏差是已知的取舍:判断依据从"从哪家商店下载"变成了"用哪家的手机"。审核实测都在对应 品牌真机上做,能过审;反向(小米商店的包装在华为手机上)只是多弹一次。

版本更新与强更判定(2026-08-31 改)

冷启动一定会查版本:splash 的 _performParallelTasks 顺序是 网络检查 → _performVersionCheckAsync → _validateTokenAsync,强更弹窗不可关闭, 会把 splash 卡住、走不到 token 校验,这是预期行为。checkForUpdate 的 10 分钟缓存是 静态字段,杀进程即失效,所以「大退重进」必然是一次真实检查。

强更判定的唯一依据是后台「服务配置 → 版本控制」的 forceupdate + minversion (随 user_getchannelapps 下发)。早先客户端自己拍了两条土规则——「主版本号第一位不同」 和「版本串里有两个 + 号」——与后台配置完全脱节:运营勾了强更拦不住人, 一次大版本升级又会把所有人无差别强更。别改回去。

  • AppInfo 早先根本没解析 minversion/forceupdate 这两个字段,是这次一并补的。 同一处的 paychannels 键名曾写成大写 Paychannels(服务端 json tag 是全小写, 见 db.proto 的 DBChannelApp),也就是说支付渠道一直解析不到;现已两种键名都认。
  • 版本号比较统一走 VersionUpdateService.compareVersion,与服务端 comm.CompareVersion 逐段按整数比 + 取前导数字的口径逐行对齐 (test/version_compare_test.dart 与 comm/channelapp_test.go 同一组用例守着)。 原实现用 int.parse 解析,遇到 1.0.0-beta 直接抛,被外层 catch 吞成「无更新」。
  • 服务端另有 user_getchannelapp(单数)会把 needupdate/forceupdate/reviewing 三个结论算好下发,客户端目前没用它,仍走复数接口自己算——所以两边口径必须一致。

配置分家:channel_app 与 app_release(2026-08-31)

后台的发版类配置拆成两张表,边界是「这一项会不会因渠道而异」:

表 维度 装什么 后台入口
app_release 一应用一行 线上版本、最低支持版本、强更开关、更新日志、默认下载地址、送审版本、游客登录开关+生效版本 服务配置 → 版本控制 / 游客显隐控制
channel_app (应用, 渠道) 本渠道下载地址、上架状态、支付渠道、是否允许跳过设备绑定 SaaS 管理 → 渠道分发

拆分的理由是踩过的坑:版本号和游客开关本来就是整个应用一套,按渠道各存一份意味着 同一个开关有 8 处能改而客户端只读一处 —— 出过「后台明明开了游客登录、App 就是不显示」的事故。

  • 下发时由 model_user.go 的 toPbChannelApp 把两张表拼成一条 pb.DBChannelApp,客户端协议没变,不需要发新包。
  • 下载地址刻意保留渠道级覆盖:客户端是直接把 address 当 URL 打开的 (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 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 布局这台固件没发。

iOS 在 Xcode 里构建报「requires minimum platform version 16.0 ... but this target supports 13.0」

不是代码问题,是 Flutter 的 SPM 集成和 Xcode 的构建顺序对不上。跑 apps/client/ios/sync_spm_platform.sh 即可,几毫秒。

Flutter 3.29+ 给 iOS 插件生成一个中间包 ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift,所有插件挂在它下面。 它生成时的平台版本写死是 Flutter 自己的最低版本 iOS 13.0,而本项目有 13 个本地插件声明 .iOS("16.0")(agent_service / azure_speech / ble_service / chat_api / chat_storage / classic_bluetooth / jl_opus / location_service / music_service / realtime / recorder_holder / swift_sdk_local / user_portrait)。SPM 不允许依赖的平台版本高于上层包,于是报错。 报哪个插件名取决于扫描顺序,换一个插件名不代表换了问题。

Flutter 自己有补救 —— SwiftPackageManager.updateMinimumDeployment() 读 Runner 的 IPHONEOS_DEPLOYMENT_TARGET(16.0) 把生成包改写上去 —— 但它只在 flutter build / flutter run 里执行(flutter_tools 的 ios/mac.dart)。Xcode 直接构建时 SPM 解析发生在所有 Flutter 步骤之前, 看到的还是 13.0。所以现象是「flutter build ios 能过、Xcode 里点构建就炸」。

⚠️ 这不是一次性问题:flutter pub get / clean / install、改完 pubspec 的自动 pub get, 任何重新生成插件清单的操作都会把它打回 13.0。

⚠️ 别改插件的 Package.swift 去迁就它:降了 user_portrait 只会让 Xcode 转头报 agent_service, 13 个要挨个降,而且多数插件确实用了新 API,降了编不过。 ⚠️ 也别手改那个生成文件:它头上写着 Generated file. Do not edit.,下次 pub get 就被覆盖。

sync_spm_platform.sh 做的就是 Flutter 那一步,但可重复执行、不依赖 flutter 命令 (Xcode pre-action 的 PATH 很干净,调 flutter 常找不到)。它另有一条守卫: 插件要求高于工程最低 target 时直接报错并点名是哪个文件 —— 那种情况脚本救不了, 只能抬工程 target 或降插件声明。--check 只检查不改写,可用于 CI。

必备本机文件

  • .env 是硬性前置:main.dart 启动第一步就 dotenv.load(fileName: ".env"),且 .env 同时被 pubspec.yaml 声明为 asset —— 文件不存在编译期就报 asset 缺失。已提供 .env.example(key 与真实 .env 逐项对齐)。.env 在 .gitignore 里,不入库(源仓库 eaimar_client-main 的 .gitignore 同样有 .env*,本来就从不进 git —— 所以换机器/重新拉代码后必须手工补 .env,这是搬仓时最容易漏的一步)。
  • 真实 .env 里 VOLCANO_* / OPENAI_* 全是空值:这些第三方能力已改由后端 AppConfig.env() 下发, 本机只在绕过后端直连调试时才填。实际必填的只有 SERVER_URL 和 Channel。
  • .mcp.json 同样被 pubspec 声明为 asset(open_ai_service_adapter.dart 里的加载代码虽已注释, 删文件仍会构建失败)。里面是两个真实 MCP server(mcp.ideapsound.com / api.deapsound.com),会入库。

仓库卫生(重要)

apps/client/.gitignore 挡掉的构建产物占 5.8G 中的 5.6G:build/(5.2G)、ios/Pods/(311M)、 android/.gradle/(120M)。实际入库约 118MB / 3200 文件,其中 ~40MB 是构建必需的预编译 SDK (local_plugins/azure_speech 的 MicrosoftCognitiveServicesSpeech.xcframework、device_jieli 的 JL_BLEKit 等)。 build/ 与 .dart_tool/ 的规则故意不带前导斜杠,这样才能一并覆盖 local_plugins/*/android/build 等嵌套产物。

⚠️ android/app/sign/eaimar.keystore(Android 发版签名库)目前会入库 —— 与本仓库"私有仓库下接受真实凭据" 的既有取舍一致,且不入库会导致其他人打不出可升级的正式包。仓库若要公开,这个必须先摘出去。

服务器域名(2026-08-28 统一)

自有服务器域名一律 ymaikj.com,上一代的 voitrans.net / voitrans.com / ideapsound.com / deapsound.com 已全部清除(含 41 个语言文件里用户可见的 web.voitrans.com)。现役清单:

用途 域名 位置
业务后端 ym-dev.ymaikj.com .env 的 SERVER_URL/HW_SERVER_URL
支付回调 pay.ymaikj.com pay_service.dart、goods_base_controller.dart
会议分享 share.ymaikj.com share_bottom_sheet.dart
Web 站点 web.ymaikj.com member_bottom_sheet.dart、meeting_mine_view.dart、各语言文案
微信 Universal Link ymaikj.com login_service.dart、pay_service.dart、ios/Runner/Runner.entitlements
MCP(死配置) mcp.ymaikj.com、api.ymaikj.com .mcp.json

⚠️ 微信 Universal Link 改了代码还不够,三处必须完全一致,否则微信登录与微信支付静默失败: ① universalLink: 'https://ymaikj.com/app/'(两个 dart 文件) ② Runner.entitlements 的 applinks:ymaikj.com ③ 微信开放平台后台登记的 Universal Link + https://ymaikj.com/.well-known/apple-app-site-association 代码侧(①②)已改完,③ 属于外部系统,发版前务必确认已同步。

⚠️ 另外两处含 voitrans 但不是域名、不要改:

  • lib/firebase_options.dart 的 projectId: 'voitrans' / 'voitrans-e0e3d' —— Firebase 项目标识, 改它要在 Firebase 控制台另建项目并换掉 google-services.json / GoogleService-Info.plist。
  • lib/core/translations/ 里的 key 名(copyrightVoitrans、voitransPrivateCloud…)—— 是标识符不是文案, 用户不可见,改了要 41 个语言文件连同引用处一起动。

share_bottom_sheet.dart 的分享链接格式是 share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{token}, 路径第二段是品牌标识、不是要请求的域名,由 share 端解析,别当成域名误改。

其他

  • 状态管理 GetX;目录分层见 apps/client/README.md(core/ data/ modules/ routes/)。
  • local_plugins/ 下 25 个本地插件用 path: 依赖,蓝牙/语音/音乐等 native 能力都在这里。 asr_tr_tts_manager 在 pubspec 里是注释掉的(缺 deepvoice 的 audio_recorder 插件,打开会 Gradle 报错)。
  • doc/ 下 41 个语种目录是国际化文案,tools/i18n_audit.py 做占位符校验。

品牌商与厂家公码整体下线(2026-09-12)

后台「生产管理」组现在就是六项,不多不少:芯片管理 / 方案商管理 / 工厂管理 / 产品管理 / MAC生成管理 / 渠道商管理。品牌商管理(/factory) 与 公码管理(/code) 是彻底删除,不是 2026-09-02 那次的「注释掉但页面接口都还在」。

删掉的 范围
页面 admin/app/pages/factory.vue、code.vue
console 接口 api_getbrands/addbrand/updatebrand/delbrand、四个 *factorypubliccode
api 模块 api_{add,del,get,gets,update}brand.go、四个 api_*factorypubliccode.go、user/api_bindauthcode.go
表 brand、factory_public_code —— console 启动时 DROP TABLE IF EXISTS,不可逆
角色 品牌商账号 identity=3(pb.Identity_Agent)不再接受新建

⚠️ 各业务表的 brandid 列刻意保留,没有删列:product.brandid 被 compat.go 用来给老 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 在 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 的 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, 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, 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 的 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,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),后台「类别管理」可增删改(内置 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。 转写段与总结段的后台入口已于 2026-09-17 在「会议配置」页恢复(见下节);翻译段仍只能改库。

⚠️ 存量后台账号 access CSV 里的 globalconfig/appconfig/agents/meetingsvc/calltranslate 不再对应任何菜单项,权限编辑器里也勾不到了。只勾过 appconfig 的账号由 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(应用名由父页面以 prop 下发), 页面壳是 meetconfig.vue,后端是重新写的 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 的 resolveThirdSvcs 会把 encrypted 字段解密成明文塞进 user_getthirdsvcs_v2 / user_getappconfig_v3 的响应下发。

但有些第三方能力只能由服务端调用,凭据是云账号主 AK/SK(拿到就能操作整个云账号)。 这类服务放进 svc_config 是为了统一后台管理入口,必须在下发口拦掉:

  • comm.SvcCatIdVerify = 11(身份证实名核验)登记在 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/,无状态工厂(不同于其它 sys 子系统的启动单例)—— 因为配置在 svc_config 里按应用作用域存,调用时才解析。
  • 调用失败 ≠ 不一致:两个 provider 都只在服务商明确判定时返回 Matched,其余(网络/鉴权/额度耗尽/ 未知返回码)一律返回 error。把它们当成"不一致"会导致服务商欠费时全体用户看到"您填的信息有误"。
  • 客户端接口:user_idverify(提交核验)、user_getidverify(查状态),见 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,落库在 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>/<rootDir>/<日期>/<随机> 服务端按会话 uid 拼死 EAIMAR/User/<uid>/<scene>/<yyyy/MM/dd>/<12位随机>.<ext>
SDK tencentcloud_cos_sdk_plugin 无,dio 直接 PUT

旧方案的问题:那对 AK/SK 抓包或反编译就能拿到,而它对整个桶有读写权限—— 不只是自己那部分,User/ 下所有人的录音都能被拉走或删掉。

链路:客户端 UploadOss.upload() → user_getuploadurl (api_getuploadurl.go)→ 拿到预签名 URL → dio 流式 PUT → 回写 audiourl。

  • OSS 配置的唯一读取点是 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/<uid>/ 前缀(agent 图片那处 原来拼了),前缀由服务端加,客户端再拼一层就是重复路径。

⚠️ 新老客户端并存:老包仍读 COS_* 直传腾讯云,所以业务库 config 表里那 5 个 COS_* 键先别删。历史录音的 audiourl 是 COS 的绝对地址,不会自动搬家。

⚠️ 录音目前仍是公共可读的:bucket 公共读(实测匿名 GET 返回 206,不存在的 key 返回 404 而非 403),谁拿到 URL 谁就能听,只靠 12 位随机 key 不可猜测来保护—— 所以随机名用的是 crypto/rand 不是 math/rand。要真正锁住得把 bucket 转私有 + 播放时也签临时读 URL,客户端所有播放入口都要改,还没做。

配置与密钥约定(重要)

  • 各 conf/*.yaml 与 deploy/<svc>/deploy.sh 中写死了真实的 DSN / 密钥 / 服务器 IP / 仓库密码,且纳入 git——这是用户在私有仓库下明确接受的取舍。修改这些文件时注意它们会随仓库提交。
  • ⚠️ 本仓库若要公开,必须先清理 各 *-deploy.sh 的 DEPLOY_HOST、deploy/registry-profiles.sh 的 REGISTRY_PASS,以及各 *.yaml 里的真实凭据。
  • 早期那套 deploy.sh <env> [action] 已删除(只指向灵谱、生产位留空),统一走 dev-deploy.sh / prod-build.sh / prod-deploy.sh。

启动日志里的「未配置」是设计,ERROR 才要查(2026-09-14 清过一轮)

home/api 的各 sys 子系统都是「配不上就降级」:缺凭据只留一条 init sys.xxx err: …(xx未配置,该功能不可用) 的 WARN,包级函数返回 ErrNotInited 而不是 panic (会 panic 的只有 wordfilter 与 ip2region,见 deploy/app/README)。阿龙测试机现在常态缺 4 项: Google 语音、Google 登录、微信支付、支付宝、微软翻译——都是没放对应 json/pem 或没填 key,不是故障。

这轮修掉的两条真 ERROR:

  • firebase/auth.go:36 [sys.tavily] cannot read credentials file —— sys/auth/firebase 整个包已删。 它是 google_auth 的重复实现,Auth() 一处调用方都没有(登录类型里没有 Firebase;客户端拿的 Firebase ID Token 走 stype=Google)。它还在 newSys 里 Log.Errorln 一次、又把同一个 error 返回给 调用方再打一条 WARN,于是同一件事刷两行、其中一行是红的。
  • Error 1062 Duplicate entry 'admin' for key 'adnim.PRIMARY' —— modules/api/model.go 每次启动无条件 Insert 一次默认超管、返回值还没接。改成只在账号不存在时插入。 ⚠️ 别图省事改用 Save/Upsert:那会把运营改过的密码在每次重启时刷回配置文件里的初始值。

顺带两处:sys/auth/* 五个包的 logger 名全被复制成了 sys.tavily(所以 firebase 的错误显示成 [sys.tavily],查的时候会被带偏),已各改各名;google_auth 原先 newSys 无条件返回成功、日志打 init sys.google_auth success!,而配置指的 json 根本不存在,直到有人点 Google 登录才失败—— 现在启动时就判「配得上没有」(空配置 / 写的是路径但文件不在),ApiKeyFile 以 { 开头则视为内嵌 JSON 放行。

迁移上下文

源项目在 /Users/liwei/work/go/yunyan/deep_server_up/(及 go_earphone/server/deep_server/)。从旧代码移植接口时,注意 import 路径由 earphone/... 改为 yunyan/...,并核对接口签名是否符合上文反射注册约定。