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

35 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 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(换仓库只改这一处,各脚本 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 <version> 推一版;服务器上手工维护的 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)依次 Init → Start → Run → Destroy。
  • 两种 Service 基类:
    • lego/base/rpcx(集群服务):接 ETCD 做服务发现,服务间用 rpcx RPC 互调。api/gateway/home/mcp/migu/timer 用它,且 OnInstallComp 装 services.NewHttpRouteComp()。
    • lego/base/single(单例服务):不接 ETCD/集群,只驱动单个模块。console 用它,主库 Supabase Postgres + 独立 Redis + NATS,维护 app_registry 注册表,按"选中应用"动态建到目标应用库的连接。
  • Module → Comp:模块(如 modules/api)在 OnInstallComp 里 RegisterComp 一组组件:apiComp(HTTP 入口)、modelComp(数据访问)、各种 consumer/interceptor。见 modules/api/module.go。
  • HTTP 路由靠反射自动注册(modules/comp_httpgate.go):继承 modules.MCompHttpGate 的组件,其每个导出方法只要签名是
    func (c *apiComp) MethodName(session comm.IUserSession, req *pb.XxxReq) (*pb.XxxResp, *pb.ErrorData)
    

    就会被注册成路由 <module>_<methodname小写>(如 api_getfactorys)。新增接口 = 新建 modules/<mod>/api_xxx.go 加一个符合签名的方法即可,无需手动注册。后缀 Check 的方法不注册。

  • 错误处理约定:成功填 resp;失败构造 &pb.ErrorData{Code: pb.ErrorCode_XXX, Message: ...} 并 return。
  • pb 包:pb/*.pb.go 由 apps/proto/ 下 .proto 经 protoc + protoc-gen-go v1.36.6 生成 (cd apps/proto && python3 pb.py,需 ~/go/bin 在 PATH 里)。 ⚠️ 别照 go.mod 的 google.golang.org/protobuf v1.36.2 去装生成器——那是运行时库版本, 生成器版本写在每个 .pb.go 头部(protoc-gen-go v1.36.6)。装错版本会让 pb.py 把 全部 22 个文件降版重写,产出几千行与本次改动无关的 diff。生成后务必 git diff --stat -- apps/services/pb/ 确认只动了你改的那几个,手动改 .pb.go 无效;改协议要改 .proto 再重新生成。注意 console 模块有本地 gorm struct(非 pb)的 model 文件,见 modules/console/model_*.go。

Admin 前端约定(关键,易踩坑)

  • API 代理:server/middleware/proxy.ts 把 /web/api/* 和 /console/api/* 代理到 CONSOLE_BACKEND(容器内由 env 注入,本地默认 http://127.0.0.1:8080)。
  • useApi().webApi(method, data)(app/composables/useApi.ts):
    • 已自动拼成 /web/api/api_${method} 并已解包 data(成功返回 data 本体;失败 throw;code:18 自动登出)。
    • ⚠️ 调用时 method 不要带 api_ 前缀,否则变成 api_api_xxx → code:11。
    • 方法名以 get/list/load/query/fetch/search/count/stat/export/check 开头视为只读,不弹全屏遮罩;其余(add/update/del/save/create…)视为写操作,请求期间套遮罩防重复提交。
    • 选中应用通过请求头 X-App-Id 传给后端。
  • 应用下拉统一走 api_getmyapps(超管/管理员例外,仍用 console/api/apps/list):后端按账号绑定下发可选部署。品牌商/渠道商账号的可见应用不靠手工勾选,而是由「名下产品绑定的应用」推导——产品的应用归属存 DBProduct.appnames(应用名 CSV,产品管理页多选),见 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)接住,转发到对应服务里 反射注册出来的同名方法。所以后端加一个 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 后台维护。
  • 客户端 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 加)

入口显隐由后台「SaaS 管理 → 渠道分发」按 (应用, 渠道) 配置,判定在服务端 (comm.ChannelApp.ShowTourists,同 NeedForceUpdate/IsReviewing 的规矩):

白名单语义:只有被点名的那一个版本可见,其余一律隐藏。

tourists touristsversion 效果
关 任意 全部版本隐藏
开 空 全部版本隐藏(没点名任何版本 = 谁都不放行;后台已拦住这种等于没开的配置)
开 1.0.2 只有 1.0.2 可见,其余版本一律隐藏

留空按「全隐藏」而非「全放行」是刻意选的失败方向:判宽的后果是不该露游客入口的版本 露了出去(过审风险、白嫖开户礼),判严只是入口少显示。要全部隐藏就取消勾选, 不要靠留空版本号表达——后台 saveChannelApp 会拒绝「勾了开关却没填版本」。

⚠️ 这不向后兼容:升级前 tourists=true 且没填版本的存量配置,升级后入口会消失。 部署后必须去后台把生效版本填上(或确认就是要关掉)。

第三行是送审场景:只给正在过审的那一版开游客登录。客户端在 user_getchannelapps 的请求里带 version(净版本号,不含构建号),服务端把每条渠道配置的 tourists 换算成结论再下发——客户端拿到的 tourists 已经是「这一版该不该显示」, 不要再拿 touristsversion 自己比一遍。没上报版本时按不匹配处理(隐藏), 点名了版本却认不出是哪一版只能从严。

  • ⚠️ 已发布的老客户端关不掉:这次改动之前 _checkGuestLoginConfig() 恒定写死 true(login_controller.dart), 入口根本不读服务端配置。所以「其他版本全部隐藏」只对装了本次改动及以后版本的客户端成立, 存量包无论后台怎么配都仍会显示入口。
  • 入口所在的 _buildSocialLoginSection 必须是 Obx 不能是 Builder:配置是异步拿到的, Builder 只渲染 onInit 时的初值 false,入口再也不会出现。
  • 拿不到配置(无网/接口失败)时按隐藏处理。反过来默认显示会在送审期把不该露的入口露给审核员。 代价是断网启动后 10 分钟内(checkForUpdate 的缓存窗口)入口不出现。
  • touristsversion 是 channel_app 表的后加列,console 启动时由 api_config.go 的 ADD COLUMN IF NOT EXISTS 补上 (CreateTable 对已存在的表跳过 AutoMigrate,不补就永远没这列)。

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

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

链路:协议层解析 → 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) 把 _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,品牌读出来之前默认"未同意",失败方向偏向合规。
  • 口径偏差是已知的取舍:判断依据从"从哪家商店下载"变成了"用哪家的手机"。审核实测都在对应 品牌真机上做,能过审;反向(小米商店的包装在华为手机上)只是多弹一次。

必备本机文件

  • .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 的 resolveThirdSvcs 会把 encrypted 字段解密成明文塞进 user_getthirdsvcs_v2 / user_getappconfig_v3 的响应下发。

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

  • comm.SvcCatIdVerify = 11(身份证实名核验)登记在 comm/svcpool.go 的 serverOnlySvcCats 里;resolveThirdSvcs 用 comm.IsServerOnlySvc 整条跳过。
  • ⚠️ svcresolve.go 里那三行过滤是唯一拦截点,删掉等于把主账号密钥明文发给每个 App 用户。 comm/svcpool_serveronly_test.go 守着这条底线。
  • 新增服务端专用类别:serverOnlySvcCats 加一行 + admin serviceconfig.vue 的 SERVER_ONLY_SCATS 加一项 (前端那份只管界面提示,不产生拦截效果)。

身份证实名核验

  • 服务商:阿里云 Id2MetaVerify(cloudauth.aliyuncs.com,2019-03-07,ResultObject.BizCode 1=一致/2=不一致) 与腾讯云 IdCardVerification(faceid.tencentcloudapi.com,2018-03-01,Result "0"=一致/"-1"=不一致)。 实现在 sys/idverify/,无状态工厂(不同于其它 sys 子系统的启动单例)—— 因为配置在 svc_config 里按应用作用域存,调用时才解析。
  • 调用失败 ≠ 不一致:两个 provider 都只在服务商明确判定时返回 Matched,其余(网络/鉴权/额度耗尽/ 未知返回码)一律返回 error。把它们当成"不一致"会导致服务商欠费时全体用户看到"您填的信息有误"。
  • 客户端接口:user_idverify(提交核验)、user_getidverify(查状态),见 api_idverify.go。
  • 合规口径:身份证号全文不落库,只存掩码(4403**********1234)与加盐 SHA-256 指纹。 盐来自 ID_HASH_SALT 环境变量,留空则不写指纹(绝不退化成裸哈希——身份证号空间有限, 裸哈希可被彻底反查)。盐一旦启用不要再改,否则历史指纹全部对不上。
  • 一证可绑多号:不拦截,但按指纹回查,后台用户页显示「同证件账号数」。
  • 限流:同账号 10 分钟内失败 5 次即拒(idVerifyFailWindow/idVerifyFailLimit)——服务商按次计费。
  • 本地先校验身份证格式(GB 11643 校验位),把明显无效的输入挡在计费调用之前。

配置与密钥约定(重要)

  • 各 conf/*.yaml 与 deploy/<svc>/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/...,并核对接口签名是否符合上文反射注册约定。