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.pemalong→ 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 时逐条踩出来的):
confs/home.yaml的wordfilter.WorldFile若列了词库 txt,服务器上必须真有这些文件,否则 homepanic: init sys.wordfilter err: no found file:...→ entrypoint 杀容器 → 无限重启。词库不在 git 里, 不需要就保持注释(模板已默认注释)。表象是 api 报Table '<库>.userdevice' doesn't exist—— userdevice/user 等表由 home 里的 user 模块启动时CreateTable建,home 起不来就永远没表,别去查数据库,先看 panic。confs/ip2region_v4.xdb/v6.xdb:sys.ipinfo启动必需,缺了同样 panic。现在 prod-deploy 会自动下发(仅远端缺失时)。.env别照抄模板:CLUSTER_TAG、ANALYZE_APP_NAME、REDIS_KEY_PREFIX都要换成本应用的身份, 其中ANALYZE_APP_NAME必须等于 console 注册表app_registry.app_name,否则按应用作用域的配置全部落到全局默认。- console 后台要先把该应用登记进
app_registry(阿龙正式机 2026-08-18 时该表还是空的)。
dev-deploy.sh <环境> 只做镜像部署:构建推送 → 远端 pull 重启,不上传 服务器上的真实 .env / confs/*.yaml(只下发 *.example 模板)。
后端架构(lego 框架)
整套后端围绕 lego 自研框架,核心是 Service → Module → Comp 三层 + 反射注册路由。
- 启动:
lego.Run(service, modules...)(lego/lego.go)依次Init → Start → Run → Destroy。 - 两种 Service 基类:
lego/base/rpcx(集群服务):接 ETCD 做服务发现,服务间用 rpcx RPC 互调。api/gateway/home/mcp/migu/timer用它,且OnInstallComp装services.NewHttpRouteComp()。lego/base/single(单例服务):不接 ETCD/集群,只驱动单个模块。console用它,主库 Supabase Postgres + 独立 Redis + NATS,维护app_registry注册表,按"选中应用"动态建到目标应用库的连接。
- Module → Comp:模块(如
modules/api)在OnInstallComp里RegisterComp一组组件:apiComp(HTTP 入口)、modelComp(数据访问)、各种 consumer/interceptor。见 modules/api/module.go。 - HTTP 路由靠反射自动注册(modules/comp_httpgate.go):继承
modules.MCompHttpGate的组件,其每个导出方法只要签名是func (c *apiComp) MethodName(session comm.IUserSession, req *pb.XxxReq) (*pb.XxxResp, *pb.ErrorData)就会被注册成路由
<module>_<methodname小写>(如api_getfactorys)。新增接口 = 新建modules/<mod>/api_xxx.go加一个符合签名的方法即可,无需手动注册。后缀Check的方法不注册。 - 错误处理约定:成功填
resp;失败构造&pb.ErrorData{Code: pb.ErrorCode_XXX, Message: ...}并return。 - pb 包:
pb/*.pb.go由apps/proto/下.proto经protoc+protoc-gen-gov1.36.6 生成 (cd apps/proto && python3 pb.py,需~/go/bin在 PATH 里)。 ⚠️ 别照 go.mod 的google.golang.org/protobuf v1.36.2去装生成器——那是运行时库版本, 生成器版本写在每个.pb.go头部(protoc-gen-go v1.36.6)。装错版本会让pb.py把 全部 22 个文件降版重写,产出几千行与本次改动无关的 diff。生成后务必git diff --stat -- apps/services/pb/确认只动了你改的那几个,手动改 .pb.go 无效;改协议要改.proto再重新生成。注意 console 模块有本地 gorm struct(非 pb)的 model 文件,见modules/console/model_*.go。
Admin 前端约定(关键,易踩坑)
- API 代理:server/middleware/proxy.ts 把
/web/api/*和/console/api/*代理到CONSOLE_BACKEND(容器内由 env 注入,本地默认http://127.0.0.1:8080)。 useApi().webApi(method, data)(app/composables/useApi.ts):- 已自动拼成
/web/api/api_${method}并已解包data(成功返回data本体;失败throw;code:18自动登出)。 - ⚠️ 调用时
method不要带api_前缀,否则变成api_api_xxx→code:11。 - 方法名以
get/list/load/query/fetch/search/count/stat/export/check开头视为只读,不弹全屏遮罩;其余(add/update/del/save/create…)视为写操作,请求期间套遮罩防重复提交。 - 选中应用通过请求头
X-App-Id传给后端。
- 已自动拼成
- 应用下拉统一走
api_getmyapps(超管/管理员例外,仍用console/api/apps/list):后端按账号绑定下发可选部署。品牌商/渠道商账号的可见应用不靠手工勾选,而是由「名下产品绑定的应用」推导——产品的应用归属存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加一行 + adminserviceconfig.vue的SERVER_ONLY_SCATS加一项 (前端那份只管界面提示,不产生拦截效果)。
身份证实名核验
- 服务商:阿里云
Id2MetaVerify(cloudauth.aliyuncs.com,2019-03-07,ResultObject.BizCode1=一致/2=不一致) 与腾讯云IdCardVerification(faceid.tencentcloudapi.com,2018-03-01,Result"0"=一致/"-1"=不一致)。 实现在 sys/idverify/,无状态工厂(不同于其它 sys 子系统的启动单例)—— 因为配置在 svc_config 里按应用作用域存,调用时才解析。 - 调用失败 ≠ 不一致:两个 provider 都只在服务商明确判定时返回
Matched,其余(网络/鉴权/额度耗尽/ 未知返回码)一律返回 error。把它们当成"不一致"会导致服务商欠费时全体用户看到"您填的信息有误"。 - 客户端接口:
user_idverify(提交核验)、user_getidverify(查状态),见 api_idverify.go。 - 合规口径:身份证号全文不落库,只存掩码(
4403**********1234)与加盐 SHA-256 指纹。 盐来自ID_HASH_SALT环境变量,留空则不写指纹(绝不退化成裸哈希——身份证号空间有限, 裸哈希可被彻底反查)。盐一旦启用不要再改,否则历史指纹全部对不上。 - 一证可绑多号:不拦截,但按指纹回查,后台用户页显示「同证件账号数」。
- 限流:同账号 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/...,并核对接口签名是否符合上文反射注册约定。