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