130 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 时逐条踩出来的):
confs/home.yaml的wordfilter.WorldFile若列了词库 txt,服务器上必须真有这些文件,否则 homepanic: init sys.wordfilter err: no found file:...→ entrypoint 杀容器 → 无限重启。词库不在 git 里, 不需要就保持注释(模板已默认注释)。表象是 api 报Table '<库>.userdevice' doesn't exist—— userdevice/user 等表由 home 里的 user 模块启动时CreateTable建,home 起不来就永远没表,别去查数据库,先看 panic。confs/ip2region_v4.xdb/v6.xdb:sys.ipinfo启动必需,缺了同样 panic。现在 prod-deploy 会自动下发(仅远端缺失时)。.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=?)。- 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-gov1.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里 importlib/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-12 起env里的凭据来自svc_config的env_key,不再来自global_config; 业务库config表只剩应用自有的业务参数。详见下文「凭据归一」。
- 客户端
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,别以为接口挂了。
游客登录与功能闸门(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 同传延迟,那是模型为语义完整做的等待,配不掉。文本接上流式之后 提前的是「开始出字」的时间,音频总延迟不变。
面对面 / 同声翻译:延迟构成与已做的优化(2026-09-03)
这两个模式不走端到端(端到端只用在 call 模式),是 ASR → HTTP 机器翻译 → TTS 三次串行往返。排查「慢」先按这个顺序拆,别一上来怀疑网络:
| 段 | 位置 |
|---|---|
| ASR 出整句 final | AzureAsrHelper(Android/iOS 各一份) |
| 机器翻译 | translation_controller._performTranslation → ServerTranslationService → 自家网关 → 服务商,两跳 |
| TTS | playTranslatedText → speakOnce |
已做的五处(都在 2026-09-03):
- 补上
Speech_SegmentationSilenceTimeoutMs=300(两端AzureAsrHelper, 常量SEGMENTATION_SILENCE_TIMEOUT_MS/segmentationSilenceTimeoutMs)。 原来一行都没设 → 走 Azure 默认 500ms,而这是「说完到出译文」里唯一一段纯等待。 ⚠️ 别再往下调,值越小越容易把一句话切碎,每段各翻一次反而更差。合法区间 100~5000。 - 同传 / 音视频改单语 ASR(
_asrLanguagesForCurrentMode())。 给 ASR 传 2 种语言 = 原生打开 Azure 连续语种识别(LanguageIdMode=Continuous), 它要先缓冲够音频、给候选语言逐个打分才敢出 final。只有面对面真需要判断「谁在说」, 同传/音视频是单向的,开着纯亏延迟。 ⚠️ 固定语种时原生上报的detectedLanguage就是supportedLanguages[0],_determineLanguageCodes据此算出shouldSwap=true,方向仍然正确—— 改这里之前先回去核对那个 if/else,判反了不会报错,只会全程翻译方向颠倒。 代价:同传下用户改说目标语种不再被自动识别成反向(同传本来就是单向场景)。 - 中间结果翻译从「每 300ms 无条件发」改成节流 + 可取消
(
_interimTranslateDebounce=700ms、_interimMinGrowth=4 字、CancelToken、_interimTranslateSeq丢弃过期结果)。中间译文只上屏、不播报、不写历史, 原来一句 5 秒的话要打十几次真实服务商调用,还会跟说完之后那次 final 抢链路和配额。 ⚠️ final 的请求永远不带 CancelToken:它要写历史、要播报,不能被后来者掐掉。 - TTS 提到
_updateExistingHistoryItem最前面。原来排在浮窗更新、写历史、 记统计这一串 await 之后——播报被白白推迟了这一整串的耗时。 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 语是这次补的)。
修掉的三个坑(都会表现成「点进去没拉服务器接口」):
OtaUpgradeController._initializeController原来是「杰理链路没连上就直接 return」, 而本项目用恒玄 ——_loadVersionInfo()永远不会被调到,页面从头到尾一次服务端读取都没有。 现在恒玄连着也继续走;杰理的 OTA 事件流仍只在杰理链路上订阅。AppConfig.getproduct()内部firstWhere没有 orElse,查不到直接抛 StateError, 而三处调用点写的都是if (product == null)—— 那个 null 判断永远等不到,异常直接穿出去。 已换成OtaCoreService.findProduct()(名字互相包含 → 唯一的经典蓝牙耳机产品 → null)。 老兜底名"DeapSound"是上一代品牌,早已不在产品表里,删掉了。getDeviceName()只认经典蓝牙 currentDevice 和杰理 deviceInfo,恒玄两个都拿不到, 恒为unknownEquipment→ 按名字查产品必然落空。已补恒玄来源。
另外两处顺手纠正的口径:
- 强更判定原来是
product.isforceupdate = product.version?.contains("+")—— 拿版本号里有没有+当开关,与后台配置完全脱节。改成直接用后台的isforceupdate字段。 (和 App 版本更新那边踩过的土规则是同一个毛病。) isNewVersionAvailable用int.parse逐段解析,遇到1.0.0-beta/1.2.3+4直接抛, 被外层 catch 吞成 false —— 表现是「明明有新版本却说已是最新」。改成取每段前导数字, 与服务端comm.CompareVersion同口径。
⚠️ CLAUDE.md 上文提到的 VersionUpdateService.compareVersion 与
test/version_compare_test.dart 在当前工作区里并不存在(version_update_service.dart
有大量未提交改动),所以 OTA 这条没有复用它,版本比较是本地实现的。
⚠️ 私有协议命令号会随固件版本变(2026-09-02 实测)
下面那张命令表是在 Echo-one 固件 0.0.2 上扫出来的,固件 0.0.3 上 AA 09 已经不回
BB 0A 了——连 BB 89 ack 都没有,静默无回包,表现就是「电量那块不显示」。
同一次连接里 AA 06(固件版本)仍然正常回 BB 86,所以不是链路问题,是命令号变了。
结论:换固件就要重扫命令,别把这张表当稳定契约。重扫用
BesBluetoothService.to.probeCommands(from: 0x01, to: 0x20)(当初 AA 09 就是在这段扫出来的;
0x21~0x7F 已确认整段无回包,2026-09-01 扫过 69 个命令,不用再浪费 28 秒)。
0x5x/0x6x 会切工作模式(进通话/录音/AI),任何情况下都别扫。
恒玄耳机电量:AA 09 请求 / BB 0A 上报(2026-09-01 真机实测定稿,仅适用固件 0.0.2)
厂商协议 3.1.10「耳机响应 APP 获取状态(响应命令 20)或者耳机状态变化主动上报」。
响应帧 BB 0A 与文档一致,但请求码不是文档写的那个——实测是 AA 09。
⚠️ 请求码只能靠实测,别照文档改。文档正文那句「响应命令 20」不带 0x 前缀
(而表格里 0xbb/0x0a 都带),两种进制都试过、都完全无回包:
| 试过的请求码 | 依据 | 结果 |
|---|---|---|
AA 20 |
把「20」当十六进制 | 无回包 |
AA 14 |
把「20」当十进制 | 无回包 |
AA 0A |
按「响应=请求」推 | 无回包 |
AA 09 |
扫 0x01~0x20 扫出来的 |
✅ BB 0A + BB 89 ack |
⚠️ 0x09 → 0x0A 是差 1,不是这台固件其它命令那种「响应 = 请求 | 0x80」
(AA 06→BB 86、AA 0E→BB 8E)。按 |0x80 反推出的 AA 0A 恰恰是错的,
早期实现就栽在这上面。AA 09 另附一个 BB 89 04 00 的 ack,已在 case 0x89 吞掉。
扫描时顺带发现的其它有回包命令(含义未知,备查):
AA 0B→BB 8B 04 00、AA 0D→BB 8D 07 00 02 01 02、AA 0F→BB 8F 04 00、AA 19→BB 99 04 01。
命令扫描用 BesBluetoothService.to.probeCommands(from:to:),只扫低位查询段,
0x5x/0x6x 会切工作模式(进通话/录音/AI),别碰。
帧格式 BB | 0A | CMD Length(1) | 块… | checksum,块是 [len, 属性, 数据…],
len 计的是「属性 + 数据」的字节数(07 07 20 FF 36 6B A4 F3 → len=7 = 属性 1 + MAC 6)。
| 属性 | 含义 | 备注 |
|---|---|---|
| 1 / 2 / 3 | 左耳 / 右耳 / 充电盒电量 | 最高位是充电指示位,取值要 & 0x7F |
| 4 | 耳机位置(是否在盒内) | 值常 >100,不是电量 |
| 5 | 连接状态 | 同上 |
| 6 | 预留 | 12 字节,含本机/对端 MAC |
| 7 | 耳机蓝牙地址 | 6 字节 |
文档样例 bb 0a 28 02 01 2B 02 02 18 02 03 64 … 解出左耳 43% / 右耳 24% / 盒 100%。
真机实测帧(Echo-one 固件 0.0.2)BB 0A 25 | 02 01 45 | 02 02 56 | 02 04 49 | 02 05 6C | 0D 06 … | 07 07 11 11 22 33 33 A4
解出 L=69% R=86%,且帧尾 MAC 与该耳机地址逐字节吻合——这是校验块偏移算对了的关键证据,
不是凑巧解出两个合理数字。三分钟后复测右耳 86%→85%、左耳不变,确认是真实电量在掉。
⚠️ 属性 3(充电盒电量)是可选的:真机不在盒内时这帧只有 [1,2,4,5,6,7],没有 3。
所以 batteryCase 停在 -1、UI 不显示充电盒那行是正常行为,不是解析失败。
耳机放回盒后属性 3 出现、解出 26%,这条已实测。
⚠️ 充电指示位(& 0x80)至今没有实测样本——只是照文档实现的。耳机在盒内时
三个电量的原字节都 <128,charging* 全是 false。这有两种可能且目前分不出来:
① 盒子当时确实没插电(那实现是对的);② 这版固件根本不用最高位表示充电,
充电状态得从别处读(那 & 0x7F 这条就是错的,且会在充电时把电量算错)。
给充电盒插电、看 L/R 原字节会不会 >127 就能定论,做之前别把这条当已验证事实。
⚠️ 此前电量一直不显示,是 bes_bluetooth_service.dart 里四个错叠加,且每一个都不报错、只表现为「界面上那块干脆不出现」:
- 请求码写成了响应码:
queryBattery()发AA 0A,把耳机的响应号当成了请求号。 固件不认,静默无回包。正确请求码是AA 09(见上表),与响应码0x0A是两个不同的号。 - 解析起点差一格:
_parseBattery照抄_parseFirmwareVersion的p = 4, 但固件版本帧长度字段占 2 字节、状态帧只占 1 字节。从 4 开始会把属性号 当块长度读成0x01 < 2→ 直接 break,一个值都解不出。 - 充电盒属性认成 0x04(实际是
0x03)。0x04是耳机位置,值常 >100, 当电量读会被 0~100 的范围判丢。 - 没做
& 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(经典蓝牙),于是:
- 客户端
_resolveProductId第一步就where(devicetype == 1),把 DEEPVOICE 整个产品滤掉; - 候选只剩 Echo-one,名字「DEEPVOICE」vs「ECHO-ONE」互不包含 → 走兜底
headsets.first.id= 45091; - 服务端拿 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清掉 (consoleregistry.go与 apimodel.go两处建表点都有,幂等)。不可逆。 api_binddevice.go不再把它抄进userdevice。DBUserDivice.devicetype这一列暂时保留: 存量行原样不动,新绑定的行为 0。
⚠️ 客户端还没跟着改(本次刻意不动),两处已知退化:
- 「我的设备」页新绑的设备显示成「未知设备」(
_MyDeviceCategory.fromValue(0))。 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),返回回调是这个页面唯一的出口,所以它里面任何
一次卡住或抛异常都会直接表现为「进了界面出不来」。三个已修的坑:
- 「结束录音并保存」原先
await toggleRecording()之后才Get.back()。 这条链末端是归档:echomeet_addrecord(Dio 30s connect + 30s receive), 而通话中数据网络本来就可能不通 —— 用户点完按钮后页面能纹丝不动一分钟。 现改成先Get.back()再让停止/落盘/归档在后台跑:这些活儿由全局BesCallRecordingService/HolderRecordingService(或 controller 自己持有的 麦克风/杰理通路)完成,跟页面在不在没关系。 - 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 / 路由名一次性定死。 - 退出时的 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。
- 链路在原生插件
apps/client/local_plugins/device_smartcar(自持 GATT,同一条连接复用 AE00 + ABC0), Dart 侧lib/devices/smartcar/只做组帧/广播解析/DevicePlugin 适配。 - ⚠️ iOS 的
JL_BLEKit是动态库,只能有一份:由device_jieli的Package.swift以jl-blekit产品暴露,device_smartcar按路径依赖。再 vendor 一份会「Class implemented in both」。 - ⚠️ Android 若搬 lumi 的认证(
jl_rcsp/jl_watch/jl_bluetooth_connectaar),不要再放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(iOSMicrophoneCapture用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_rcspmaven 上没有坐标, 只能解包成 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 规则首字节减一, 否则原样用。未经安卓真机验证。
- 2026-09-11 与 iOS 二次对齐:Ogg 拆包、
- 广播里的 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 表的同名键 |
| 应用环境配置 | 并入「服务与环境配置 → 应用参数」标签页 | 凭据搬走后只剩应用自己的业务参数 |
| 第三方服务配置 | 改名「服务与环境配置」,成为凭据唯一出处 | — |
| Agent 配置 / 通话翻译配置 | 删除(页面 + console 接口) | agent_config、call_translate_rule 在测试机与正式机上都是 0 行;通话翻译选路全在端侧 language_manager.findBestMatchingProvider |
| 会议记录服务 | 删除页面,⚠️ 数据与运行时保留 | 见下方警告 |
⚠️ 两张扁平 env 表的坑(这是本次改造的起因):同一个
AZURE_SPEECH_REGION / OPENAI_API_KEY / VOLC_OPENSPEECH_APP_ID / XUNFEI_ASR_*
在 global_config 与业务库 config 里值不一样,而合并时全局那张赢。
于是运营在「应用环境配置」里改半天没有任何效果,且不报任何错。别再把凭据写进 config 表。
现在的下发链路(svcresolve.go 的 appEnvForUser):
env = 业务库 config 表(应用自有业务参数) ← svc_config 按字段的 env_key 覆盖
comm.SvcField多了一个EnvKey(后台字段表里的「下发键名」列):非空时该字段的明文值 同时以这个名字出现在user_getappconfig的env里。已发布的客户端只会读env, 靠它继续供货、无需发新包;新客户端直接读结构化的thirdsvcs。- ⚠️ 改
env_key等于改已发布客户端读的键名,改一个字母老包就拿不到凭据, 且只表现为「某个能力鉴权失败」,不报配置错误。客户端读取点搜AppConfig.env(。 - ⚠️ env 覆盖层与
thirdsvcs走同一份 VIP 过滤(filterSvcsByVip)。分开做的话, VIP 过期的用户虽然拿不到 AST 服务条目,却仍能从env里读出同一套通话翻译凭据, 那道闸门就形同虚设。 - 同一作用域内
env_key不允许重名(console 的checkSvcEnvKeys在保存时拦): 重名时下发结果取决于服务 id 排序,表现为「凭据时对时不对」,改哪边都像没生效。
一次性迁移在 migrate_envtosvc.go, console 每次启动跑、幂等(只建不存在的服务、只填空着的字段值,运营改过的值不会被刷回去):
- 基础值取中国区现值(→ 区域 0 → 业务库
config兜底),只有与基础值不同的区域才写svc_region_override。实测 31 个键里 27 个是 14 个区域同值复制,不收敛就等于换个地方继续乱。 - 落点:
stt_azure/mt_azure/ast_alibaba/sts_bailian/stt_iflytek/ast_bytedance/llm_doubao/mt_volcano。ALIBABA_OSS_*与COMMENT_*确认无人读取,直接丢弃。 - 顺手修了两行填错列的历史数据(
group里是键名、key里是值、value空), 正是它让ALIBABA_BAILIAN_APP_ID/WORKSPACE_ID恒为 null、客户端一直用代码里写死的兜底值。 - ⚠️ 迁移不删
global_config的行(不可逆的线上凭据,留着是唯一回退依据,同migrateLicenseToDeviceMac的取舍)。业务库config里已确认迁走的键则会删除。 确认新链路跑稳后由人手工DROP TABLE global_config;。
svc_config 的四个坑(2026-09-12 阿龙测试环境实测踩出来的,都不报错)
这四条对任何改第三方服务配置的人都成立,不限于那次迁移:
- 空值不能下发成 env 键。 客户端大量写
AppConfig.env('X') ?? 默认值,Dart 的??只兜 null——下发一个空串会把默认值顶掉。AZURE_TRANSLATION_ENDPOINT就这么把请求打到过空地址。comm.ResolveSvcEnvKeys因此跳过空值:「没配」必须表现为键不存在。 - 区域覆盖里的空串会把基础值抹成空。
ResolveSvcPlainFields判的是「这个 key 在Overrides里存不存在」,存在就用,哪怕值是空串。库里早就躺着 14 行{"region":"","subscription_key":""}的空壳覆盖(建服务时留下的),于是基础行填得好好的, 所有区域拿到的还是空。排查「字段明明填了却不生效」先看这张表有没有空覆盖。 - 服务
enable=false= 该服务的凭据一个都不下发(resolveThirdSvcs整条跳过)。stt_azure当时正好是停用的,照搬这个状态就等于把AZURE_SPEECH_KEY/REGION从 env 里抹掉。 - 启动时的模板补字段会给服务实例加上空的加密字段(
patchSvcConfigFields按 provider+category 匹配内置模板)。空的加密字段会被svcCredMissing判成「⚠ 缺凭据」、 被checkSvcRequiredFields判成不许保存——那个服务在后台既报警告又改不动。 现在deprecatedTemplateFields里声明的字段会从实例上删掉(只删空的,有值的是数据不能碰)。 ⚠️ 顺带修正:ast_alibaba模板原先照云账号 AccessKey ID/Secret 建,而这条链路 (百炼 qwen3.5-livetranslate)用的是 DashScopeapp_key,AK/SK 一个都用不到。
⚠️ 多应用部署下删业务库 config 的行要分清死值和活值:全局表里有这个键 → 应用自己那行
迁移前就被盖住、从来没生效过,删掉不改变行为;全局表没有 → 那行才是生效值,只能从
「值确实取自它」的那个应用删,其余应用保留并告警(否则等于让它改用别人的凭据)。
⚠️ 「会议记录服务」删的只是后台入口,echomeet_orch 的数据与运行时都还在
(两台机各 4 行:asrfile_alibaba/asrfile_azure 识别、mt_alibaba 翻译、llmv_qwen 总结),
它是语音纪要整条流水线的服务选型来源,见
orch_resolver.go。要换服务商现在只能直接改库。
⚠️ 存量后台账号 access CSV 里的 globalconfig/appconfig/agents/meetingsvc/calltranslate
不再对应任何菜单项,权限编辑器里也勾不到了。只勾过 appconfig 的账号由
auth.ts 的 hasAccess 兼容到 svcconfig,不会因为一次合并丢入口。
第三方服务配置:服务端专用类别(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加一行 + adminserviceconfig.vue的SERVER_ONLY_SCATS加一项 (前端那份只管界面提示,不产生拦截效果)。
身份证实名核验
- 服务商:阿里云
Id2MetaVerify(cloudauth.aliyuncs.com,2019-03-07,ResultObject.BizCode1=一致/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 校验位),把明显无效的输入挡在计费调用之前。
文件上传:服务端签发预签名 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。
迁移上下文
源项目在 /Users/liwei/work/go/yunyan/deep_server_up/(及 go_earphone/server/deep_server/)。从旧代码移植接口时,注意 import 路径由 earphone/... 改为 yunyan/...,并核对接口签名是否符合上文反射注册约定。