# 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 注册表应用名一致)、 `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` 必须等于 console 注册表 `app_registry.app_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/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 加) 入口显隐由后台「SaaS 管理 → 渠道分发」按 (应用, 渠道) 配置,**判定在服务端** ([comm.ChannelApp.ShowTourists](apps/services/comm/channelapp.go),同 `NeedForceUpdate`/`IsReviewing` 的规矩): **白名单语义:只有被点名的那一个版本可见,其余一律隐藏。** | `tourists` | `touristsversion` | 效果 | |---|---|---| | 关 | 任意 | 全部版本隐藏 | | 开 | 空 | 全部版本隐藏(没点名任何版本 = 谁都不放行;后台已拦住这种等于没开的配置) | | 开 | `1.0.2` | **只有 1.0.2 可见,其余版本一律隐藏** | 留空按「全隐藏」而非「全放行」是刻意选的失败方向:判宽的后果是不该露游客入口的版本 露了出去(过审风险、白嫖开户礼),判严只是入口少显示。**要全部隐藏就取消勾选, 不要靠留空版本号表达**——后台 `saveChannelApp` 会拒绝「勾了开关却没填版本」。 ⚠️ **这不向后兼容**:升级前 `tourists=true` 且没填版本的存量配置,升级后入口会消失。 部署后必须去后台把生效版本填上(或确认就是要关掉)。 第三行是送审场景:只给正在过审的那一版开游客登录。客户端在 `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` 的缓存窗口)入口不出现。 - `touristsversion` 是 `channel_app` 表的后加列,console 启动时由 [api_config.go](apps/services/modules/console/api_config.go) 的 `ADD COLUMN IF NOT EXISTS` 补上 (`CreateTable` 对已存在的表跳过 AutoMigrate,不补就永远没这列)。 ### 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()` 已按可直接上传的形状设计,接口就绪后补上传即可,不用改结构。 ### 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`,品牌读出来之前默认"未同意",失败方向偏向合规。 - 口径偏差是已知的取舍:判断依据从"从哪家商店下载"变成了"用哪家的手机"。审核实测都在对应 品牌真机上做,能过审;反向(小米商店的包装在华为手机上)只是多弹一次。 ### 必备本机文件 - **`.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` 环境变量,**留空则不写指纹**(绝不退化成裸哈希——身份证号空间有限, 裸哈希可被彻底反查)。盐一旦启用不要再改,否则历史指纹全部对不上。 - **一证可绑多号**:不拦截,但按指纹回查,后台用户页显示「同证件账号数」。 - 限流:同账号 10 分钟内失败 5 次即拒(`idVerifyFailWindow`/`idVerifyFailLimit`)——服务商按次计费。 - 本地先校验身份证格式(GB 11643 校验位),把明显无效的输入挡在计费调用之前。 ## 配置与密钥约定(重要) - 各 `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/...`,并核对接口签名是否符合上文反射注册约定。