diff --git a/CLAUDE.md b/CLAUDE.md index 9d6c2780..221f5fb0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ compose 的 image 由脚本注入 `IMAGE_NAME` 变量,同样不写死。 换名后首次上线要重新 `./prod-build.sh ` 推一版;服务器上手工维护的 `docker-compose.yml` 也要同步成新模板(脚本只下发 `.example`,不覆盖真实文件)。 注意 `voitrans-a11` 这个串还用在**别的语义**上,那些不能动:`confs/*.yaml` 的 `CLUSTER_TAG`(etcd 集群标签, -改了服务互相发现不到)、`home.yaml` 的 `ANALYZE_APP_NAME`(须与 console 注册表应用名一致)、 +改了服务互相发现不到)、`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` 的写法): @@ -131,7 +131,12 @@ ym 首次部署容器一直叫 `starpivot-app` 正是如此)。service 名变 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`,否则按应用作用域的配置全部落到全局默认。 + 其中 `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` 模板)。 @@ -168,6 +173,12 @@ ym 首次部署容器一直叫 `starpivot-app` 正是如此)。service 名变 - 选中应用通过请求头 `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) @@ -227,10 +238,10 @@ ym 首次部署容器一直叫 `starpivot-app` 正是如此)。service 名变 - 老装机升级上来时本地还留着伪造的 `guest-` token,过不了网关, splash 的 `_validateTokenAsync` 会清掉会话把人踢回登录页——这是预期行为。 -#### 游客登录入口的显隐:后台开关 + 版本限定(2026-08-29 加) +#### 游客登录入口的显隐:后台开关 + 版本限定(2026-08-29 加,2026-08-31 改配置入口) -入口显隐由后台「SaaS 管理 → 渠道分发」按 (应用, 渠道) 配置,**判定在服务端** -([comm.ChannelApp.ShowTourists](apps/services/comm/channelapp.go),同 `NeedForceUpdate`/`IsReviewing` 的规矩): +入口显隐由后台「**服务配置 → 游客显隐控制**」按**应用**配置(不分渠道),**判定在服务端** +([comm.AppRelease.ShowTourists](apps/services/comm/apprelease.go),同 `NeedForceUpdate`/`IsReviewing` 的规矩): **白名单语义:只有被点名的那一个版本可见,其余一律隐藏。** @@ -242,11 +253,15 @@ ym 首次部署容器一直叫 `starpivot-app` 正是如此)。service 名变 留空按「全隐藏」而非「全放行」是刻意选的失败方向:判宽的后果是不该露游客入口的版本 露了出去(过审风险、白嫖开户礼),判严只是入口少显示。**要全部隐藏就取消勾选, -不要靠留空版本号表达**——后台 `saveChannelApp` 会拒绝「勾了开关却没填版本」。 +不要靠留空版本号表达**——后台 `saveAppTourists` 会拒绝「勾了开关却没填版本」。 ⚠️ **这不向后兼容**:升级前 `tourists=true` 且没填版本的存量配置,升级后入口会消失。 部署后必须去后台把生效版本填上(或确认就是要关掉)。 +⚠️ **配置位置 2026-08-31 变了**:原先在「SaaS 管理 → 渠道分发」按 (应用, 渠道) 配, +现在按应用配、存 `app_release` 表。**旧 `channel_app` 行里的 `tourists`/`touristsversion` +不会自动搬过来**,升级后要在新页面重填一次。 + 第三行是送审场景:只给正在过审的那一版开游客登录。客户端在 `user_getchannelapps` 的请求里带 `version`(净版本号,不含构建号),服务端把每条渠道配置的 `tourists` **换算成结论**再下发——客户端拿到的 `tourists` 已经是「这一版该不该显示」, @@ -261,9 +276,15 @@ ym 首次部署容器一直叫 `starpivot-app` 正是如此)。service 名变 `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,不补就永远没这列)。 +- ⚠️ **`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 接入) @@ -301,6 +322,585 @@ App 内对话页与耳机唤醒(`BesAiSessionService`)两条链路都往同 落地也**只到本地**(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` 只剩 @@ -325,6 +925,60 @@ google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel - 口径偏差是已知的取舍:判断依据从"从哪家商店下载"变成了"用哪家的手机"。审核实测都在对应 品牌真机上做,能过审;反向(小米商店的包装在华为手机上)只是多弹一次。 +### 版本更新与强更判定(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` 同时被 @@ -412,6 +1066,12 @@ google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel 盐来自 `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 校验位),把明显无效的输入挡在计费调用之前。 diff --git a/apps/client/lib/data/services/bes_device_auth.dart b/apps/client/lib/data/services/bes_device_auth.dart new file mode 100644 index 00000000..c8262d1e --- /dev/null +++ b/apps/client/lib/data/services/bes_device_auth.dart @@ -0,0 +1,257 @@ + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; + +import '../../core/utils/logger.dart'; +import '../models/user_Info.dart'; +import 'bes_bluetooth_service.dart'; +import 'network/api.dart'; + +/// 恒玄耳机的确权结论。 +enum BesAuthResult { + /// 本地白名单命中,或服务端确认这台 MAC 挂在本应用的这个产品名下。 + authorized, + + /// 服务端**明确拒绝**:这台 MAC 没登记在该产品下(AuthorizeNoCanUse)。 + denied, + + /// 得不出结论——未登录 / 断网 / 产品表还没下发 / iOS 拿不到真 MAC。 + /// **按放行处理**,理由见 [verifyConnected]。 + unknown, +} + +/// 「账号 ↔ 恒玄耳机」的确权 + 绑定登记,走服务端 `user_binddevice`。 +/// +/// ## 这个接口对恒玄耳机就是「校验 MAC」,仅此而已 +/// +/// 服务端的设备表 2026-09-04 已从 `license_` 分表合并成**全局一张** `device_mac`: +/// 主键就是设备 MAC。`api_binddevice.go` 拿 MAC 全局反查,查到即放行, +/// 产品是从查到那一行的 `productid` 列**读出来的结果**。 +/// +/// 所以客户端这边**只需要把 MAC 报上去**——不传 pid、不查本地产品表、不做名字匹配。 +/// 杰理那套广播授权码(`code`)这条链路不用,传空即可。 +/// +/// ⚠️ 别再加回「先在本地产品表里挑出 pid」那套。它出过事故:一台已正确导入的耳机, +/// 因所属产品的 devicetype 被后台配成 2(BLE双端)而不是 1(经典蓝牙), +/// 被「只在 devicetype==1 的产品里挑」过滤掉、退回了别的产品的 pid, +/// 服务端拿着错 pid 查错的分表,报「这台 MAC 没登记在该产品下」—— +/// 提示指向数据缺失,实际数据好好的。 +/// +/// ## 第一次连才走服务端,之后读本地白名单 +/// +/// 确权通过的 MAC 落到 [_authorizedKey],下次连上直接命中放行、不打接口。 +/// 白名单**不按账号分**:确权说的是「这台硬件是不是正品」,是设备属性不是账号 +/// 属性,换账号不该让同一台耳机重新变成可疑设备。所以 [reset](登出)只清进程内 +/// 的上报去重集合,不动白名单。 +/// +/// ## 判不出来的时候一律放行 +/// +/// [BesAuthResult.unknown] 放行是刻意选的失败方向。判严的代价是**合法用户一断网 +/// 就用不了自己的耳机**;判宽的代价只是未登记的设备在离线时能用一会儿,等联上网 +/// 第一次确权就会被拦下。前者严重得多。 +/// +/// ## 两端的 MAC 从哪来(2026-09-02 起 iOS 也能校验了) +/// +/// `BesBluetoothService.connectedDeviceId` 在 Android 上是蓝牙 MAC,在 iOS 上却是 +/// CoreBluetooth 的 peripheral UUID(bluetooth_manager 的 `BluetoothManager.swift` +/// 里 `p.identifier.uuidString`),**每台 iPhone 看同一只耳机都不一样**,不可能命中 +/// 后台登记的 MAC——所以 iOS 上这道闸门以前直接放行、等于没有。 +/// +/// 现在统一走 [BesBluetoothService.resolveDeviceMac]:Android 仍用 connectedDeviceId, +/// iOS 用耳机在 `BB 0A` 状态帧属性 7 里**自报**的蓝牙地址(不经过系统 API,两端一致; +/// 实测与 Android 拿到的逐字节吻合)。取不到时照旧返回 [BesAuthResult.unknown] 放行。 +class BesDeviceAuth { + BesDeviceAuth._(); + + static const String _tag = 'BesDeviceAuth'; + + /// 确权通过的 MAC 白名单(持久化)。存大写 MAC。 + static const String _authorizedKey = 'bes_authorized_macs'; + + static final GetStorage _storage = GetStorage(); + + /// 本次进程内已经向服务端登记成功的 MAC,避免每次回连都打一次接口。 + /// 只记成功的——失败的要留着下次重试。 + static final Set _reported = {}; + + /// 同一台设备的确权正在进行中,避免状态流抖动时并发打接口。 + static final Set _inflight = {}; + + /// 登出时清掉:换账号后同一台耳机要重新登记到新账号名下。 + /// **白名单不清**(理由见类注释)。 + static void reset() => _reported.clear(); + + static List get _whitelist => + (_storage.read(_authorizedKey) ?? const []) + .map((e) => e.toString()) + .toList(); + + /// 这台 MAC 之前确权通过过吗 + static bool isAuthorizedLocally(String mac) => + mac.isNotEmpty && _whitelist.contains(mac.toUpperCase()); + + static Future _remember(String mac) async { + final list = _whitelist; + final m = mac.toUpperCase(); + if (list.contains(m)) return; + list.add(m); + await _storage.write(_authorizedKey, list); + } + + /// 把一台设备移出白名单,下次连上时重新走服务端确权。 + /// + /// 用户在设备管理页主动解绑时必须调这个:白名单命中会**跳过接口**, + /// 不清的话解绑之后再连上不会重新登记,设备管理页就永远空着了。 + /// (代价是解绑完再连一次又会自动绑回来——这是「不需要手动点绑定」的 + /// 必然结果,不是 bug。) + static Future forget(String mac) async { + if (mac.isEmpty) return; + final m = mac.toUpperCase(); + _reported.remove(m); + final list = _whitelist..remove(m); + await _storage.write(_authorizedKey, list); + } + + /// 【调试/售后用】清空本地白名单,强制下次连接重新走服务端确权。 + static Future clearWhitelist() async { + await _storage.remove(_authorizedKey); + _reported.clear(); + } + + /// 校验当前已连接的这台恒玄耳机。 + /// + /// 被 [BesBluetoothService] 的连接状态流调用——那条流是**所有**连接路径的必经 + /// 之地(主动连接、冷启动回连、原生 ACL 广播触发的被动接管都会经过), + /// 放在扫描页拦不住被动回连。 + /// + /// 判定为 [BesAuthResult.denied] 时会**就地断开连接并提示用户**,调用方只需 + /// 据返回值决定要不要继续后续握手。 + static Future verifyConnected() async { + if (!Get.isRegistered()) return BesAuthResult.unknown; + final bes = BesBluetoothService.to; + if (!bes.isConnected) return BesAuthResult.unknown; + + // iOS 上 connectedDeviceId 是 peripheral UUID,不能拿去校验; + // 改从耳机自报的 `BB 0A` 属性 7 取真 MAC(见 BesBluetoothService.resolveDeviceMac)。 + final mac = await bes.resolveDeviceMac(); + if (mac == null || mac.isEmpty) { + Logger.i(_tag, '取不到设备 MAC,跳过校验(放行)'); + return BesAuthResult.unknown; + } + final name = bes.connectedDeviceName.value; + + // ① 本地白名单——第一次之后就走这条,不打接口、离线也能连 + if (isAuthorizedLocally(mac)) return BesAuthResult.authorized; + + if (_inflight.contains(mac)) return BesAuthResult.unknown; + _inflight.add(mac); + try { + final result = await _verifyRemote(mac: mac, name: name); + if (result == BesAuthResult.denied) { + // 传原始 deviceId 而不是大写化的 mac:pairedDevices 里存的是 + // connectToDevice 当时拿到的那个串,大小写必须逐字对上才删得掉。 + await _reject(bes.connectedDeviceId.value); + } + return result; + } finally { + _inflight.remove(mac); + } + } + + static Future _verifyRemote({ + required String mac, + required String name, + }) async { + if (!User.isLoggedIn()) { + // 先连设备后登录是常见顺序。这里不算失败——等 MyDevicesController 拉列表 + // 或下次连接时会再校一次。 + Logger.i(_tag, '未登录,暂不校验,放行'); + return BesAuthResult.unknown; + } + + // 服务端已经绑过这台了,等于确权通过,省一次往返 + if (User.instance.devices.any( + (d) => (d.devicemac).toUpperCase() == mac)) { + await _remember(mac); + _reported.add(mac); + return BesAuthResult.authorized; + } + + try { + // ⚠️ **不再传 pid**(2026-09-04 起)。服务端的设备表已合成全局一张 device_mac, + // 按 MAC 就能定位到设备,产品是从查到的那一行的 productid 列读出来的。 + // + // 以前这里要先在本地产品表里猜出 pid 才能发请求,猜法是「只在 devicetype==1 + // 的产品里按名字互相包含匹配,匹配不上退回第一个」。这个猜法出过事故: + // 一台已正确导入的耳机,因所属产品的 devicetype 被后台配成 2(BLE双端) + // 而不是 1(经典蓝牙),整个产品被过滤掉、退回了别的产品的 pid, + // 服务端拿着错 pid 去错的分表查,报「这台 MAC 没登记在该产品下」—— + // 于是一台合法设备被判成 denied、断开、弹「设备连接受限」。 + // + // code 传空:恒玄没有杰理那套广播里的授权码。 + // 10s 上限:确权排在握手前面,dio 那边 connect/receive 各 30s, + // 弱网时不加这道限制会让首连的固件版本/电量查询干等一分钟。 + // 超时抛 TimeoutException → 走 catch → unknown → 放行。 + final resp = await Api.binddevice({ + 'code': '', + 'devicename': name, + 'devicemac': mac, + }).timeout(const Duration(seconds: 10)); + + // ⚠️ 这里 null 和抛异常含义完全不同,别合并处理: + // AuthInterceptor 在业务码 != 0 时把 data 置 null 后 resolve(**不抛**), + // 网络层出问题才抛 DioException。所以 null = 服务端明确拒绝, + // 异常 = 没连上服务端。前者拦,后者放。 + if (resp == null) { + Logger.w(_tag, + '服务端拒绝: name=$name mac=$mac —— 这个 MAC 不在设备表(device_mac)里,' + '去后台「设备管理 → 生成MAC」确认是否已生产/导入'); + return BesAuthResult.denied; + } + + final deviceJson = resp['device']; + if (deviceJson is! Map) { + // code==0 却没有 device:协议异常,不当成「未授权」处理 + Logger.w(_tag, '绑定接口未返回 device 字段,按无结论放行。resp=$resp'); + return BesAuthResult.unknown; + } + + final device = UserDevice.fromJson(deviceJson); + // addUserDevice 是无脑 add,先去重,免得反复连接堆出重复行 + User.instance.removeUserDevice(device.id); + User.instance.addUserDevice(device); + await _remember(mac); + _reported.add(mac); + Logger.i(_tag, '确权通过: name=$name mac=$mac id=${device.id}'); + return BesAuthResult.authorized; + } catch (e) { + Logger.e(_tag, '校验请求失败(按无结论放行): $e'); + return BesAuthResult.unknown; + } + } + + /// 确权不通过:断开这台设备,并把它从自动回连列表里摘掉。 + /// + /// ⚠️ **必须移出 pairedDevices**,否则冷启动的 `tryAutoConnect` 和原生 ACL + /// 广播会立刻把它连回来,变成「连上→弹窗→断开→连上」的死循环。 + static Future _reject(String address) async { + try { + // removePairedDevice 内部在地址匹配时会顺带 disconnectDevice + await BesBluetoothService.to.removePairedDevice(address); + if (BesBluetoothService.to.isConnected) { + await BesBluetoothService.to.disconnectDevice(); + } + } catch (e) { + Logger.e(_tag, '断开未确权设备失败: $e'); + } + Get.snackbar( + 'tip'.tr, + 'deviceConnectionRestricted'.tr, + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red.withValues(alpha: 0.1), + colorText: Colors.red, + duration: const Duration(seconds: 4), + ); + } +} diff --git a/apps/services/comm/const.go b/apps/services/comm/const.go index 88036233..699ba042 100644 --- a/apps/services/comm/const.go +++ b/apps/services/comm/const.go @@ -44,48 +44,51 @@ const ( // 数据表名定义处 const ( - TableUser = "user" //用户表 - TableUserdevice = "userdevice" //用户设备列表 - TableUserIdVerify = "useridverify" //用户实名认证记录(身份证号不落全文,只存掩码+加盐哈希) - TableRecord = "record" //记录表 - TableAgent = "agent" //智能体id - TableMcp = "mcp" //Mcp服务 - TableAppConfig = "config" //App自有配置(音乐、OSS等) - TableGlobalConfig = "global_config" //全局第三方服务配置(AI、翻译等,按区域) + TableUser = "user" //用户表 + TableUserdevice = "userdevice" //用户设备列表 + TableUserIdVerify = "useridverify" //用户实名认证记录(身份证号不落全文,只存掩码+加盐哈希) + TableRecord = "record" //记录表 + TableAgent = "agent" //智能体id + TableMcp = "mcp" //Mcp服务 + TableAppConfig = "config" //App自有配置(音乐、OSS等) + TableGlobalConfig = "global_config" //全局第三方服务配置(AI、翻译等,按区域) TableAppServiceConfig = "app_service_config" //应用×服务的基础设施/运行配置(DSN/Redis/NATS/COS…;启动 seed 自文件、后台可改),存 console 共享库 TableAppModuleConfig = "app_module_config" //应用业务功能配置(登录/短信/邮件/支付…),存"应用业务库"(随部署)、非公共库,密钥字段 AES 加密 // 按应用分离的「第三方服务配置 / Agent 配置」新表(app_name=''为全局默认,'X'为应用X覆盖)。 // 独立于老的 third_svc_config / agent 表,避免影响现有服务接口;均存 console 共享库(postgres)。 - TableSvcConfig = "svc_config" //第三方服务配置(按应用;旧 third_svc_config 迁入其全局作用域) - TableSysServiceConfig = "sys_service_config" //平台系统服务配置(对象存储/邮件/翻译,平台自用,独立于面向客户端的 svc_config) - TableSvcRegionOverride = "svc_region_override" //第三方服务区域字段覆盖(按应用;旧 third_svc_region_override 迁入) - TableAgentConfig = "agent_config" //yunyan 自管 Agent 配置(按应用;旧 console agent 表迁入) - TableSvcHealth = "svc_health" //第三方服务巡检结果快照(每 app_name+svc_id+region 一行,巡检器覆盖写) - TableAdmin = "adnim" //后台用户表 - TableEchomeetTemplate = "echomeet_template" //模板表 - TableEchomeetRecord = "echomeet_record" //会议记录表 + TableSvcConfig = "svc_config" //第三方服务配置(按应用;旧 third_svc_config 迁入其全局作用域) + TableSysServiceConfig = "sys_service_config" //平台系统服务配置(对象存储/邮件/翻译,平台自用,独立于面向客户端的 svc_config) + TableSvcRegionOverride = "svc_region_override" //第三方服务区域字段覆盖(按应用;旧 third_svc_region_override 迁入) + TableAgentConfig = "agent_config" //yunyan 自管 Agent 配置(按应用;旧 console agent 表迁入) + TableSvcHealth = "svc_health" //第三方服务巡检结果快照(每 app_name+svc_id+region 一行,巡检器覆盖写) + TableAdmin = "adnim" //后台用户表 + TableEchomeetTemplate = "echomeet_template" //模板表 + TableEchomeetRecord = "echomeet_record" //会议记录表 TableEchomeetOrch = "echomeet_orch" //会议记录服务编排(引用 svc_config 的服务,按 app_name+region 分层;存 console 共享库) TableEchomeetOrchSetting = "echomeet_orch_setting" //会议记录编排的作用域级开关(每 app_name+region 一行;如客户端可否指定总结模型) - TableChannelApp = "channelapp" //【已废弃】旧渠道包表(各应用业务库 mysql,主键仅 channel),由 console 主库的 channel_app 取代 - TableSolutionProvider = "solution_provider" //方案商数据表(jieli/lanxun/henxuan) - TableBrand = "brand" //品牌商数据表(原厂家) - TableChannel = "channel" //渠道商数据表(挂在品牌商下,id=8位短码;生产/统计/结算的渠道维度) - TableSettlementMonth = "settlement_month" //月结算单表(每 结算月×品牌商×渠道商 一行;存 console 主库) - TableProduct = "product" //产品表 - TableProductVersion = "product_version" //产品表版本管理 - TableProductionBatch = "production_batch" //生产批次台账表(原出货单) - TableLicense = "license" //授权码分表前缀 license_ - TableGoods = "goods" //支付商品表 - TablePayOrder = "payorder" //支付订单表 - TableWakeupVoice = "wakeupvoice" //唤醒语音表 - TableUserUseLog = "useruselog" //用户日志 - TableUserStatistics = "userstatistics" //用户统计表 + TableChannelApp = "channelapp" //【已废弃】旧渠道包表(各应用业务库 mysql,主键仅 channel),由 console 主库的 channel_app 取代 + TableChipVendor = "chip_vendor" //芯片厂商档案表(原 pb.SolutionVendor 枚举转表;id 沿用枚举值 1杰里/2蓝汛/3恒玄/4博通,solution_provider.vendor 指向它) + TableOemFactory = "oem_factory" //代工厂/组装厂档案表(与品牌商并列的独立实体;注意不是遗留的无人引用的 factory 表) + TableSolutionProvider = "solution_provider" //方案商数据表(jieli/lanxun/henxuan) + TableBrand = "brand" //品牌商数据表(原厂家) + TableChannel = "channel" //渠道商数据表(挂在品牌商下,id=8位短码;生产/统计/结算的渠道维度) + TableSettlementMonth = "settlement_month" //月结算单表(每 结算月×品牌商×渠道商 一行;存 console 主库) + TableProduct = "product" //产品表 + TableProductVersion = "product_version" //产品表版本管理 + TableProductionBatch = "production_batch" //生产批次台账表(原出货单) + TableLicense = "license" //【已废弃】授权码分表前缀 license_,仅供 registry 迁移读取,业务代码一律用 TableDeviceMac + TableDeviceMac = "device_mac" //设备MAC表(全局一张,主键 code=MAC;productid 只是归属列,不再是查询键) + TableGoods = "goods" //支付商品表 + TablePayOrder = "payorder" //支付订单表 + TableWakeupVoice = "wakeupvoice" //唤醒语音表 + TableUserUseLog = "useruselog" //用户日志 + TableUserStatistics = "userstatistics" //用户统计表 // TableAuthCode = "authcode" //授权码表 // TableFactoryDevice = "factorydevice" //厂家设备表 - TableUseRecordLog = "uselog" //用户使用统计日志 + TableUseRecordLog = "uselog" //用户使用统计日志 // app_stat_daily/monthly/yearly/global 四张「App 综合运营统计」表已移除:运营统计整体迁到 // 运营后台(analyze 模块把当日快照经 NATS 推到 console 主库 stats_global_day),app 服务不再产出。 TableConsoleLog = "console_log" //后台操作日志表 @@ -105,7 +108,8 @@ const ( TableThirdSvcTemplate = "third_svc_template" //第三方服务商字段模板表(数据驱动,启动 seed 内置常见服务商) TableCallTranslatePair = "call_translate_pair" //【已废弃】旧通话翻译语言对表(区域×单语言对×直接拼服务),由 call_translate_rule 取代,代码不再读写 TableCallTranslateRule = "call_translate_rule" //通话翻译规则表(区域×多源/多目标语言×优先级 → agent) - TableChannelAppCfg = "channel_app" //渠道分发配置表(console 主库,按 app_name 作用域;版本/强更/下载地址/支付渠道开关) + TableChannelAppCfg = "channel_app" //渠道分发配置表(console 主库,按 app_name+channel;下载地址/上架状态/支付渠道/跳过绑定) + TableAppRelease = "app_release" //应用发版与入口控制表(console 主库,按 app_name,一应用一行;版本/强更/更新日志/送审版本/游客显隐) TableAppGoods = "app_goods" //应用内购商品配置表(console 主库,按 app_name 作用域;多语言名称/货币/价格存 localname JSON) ) diff --git a/apps/services/comm/devicemac.go b/apps/services/comm/devicemac.go new file mode 100644 index 00000000..72104e0a --- /dev/null +++ b/apps/services/comm/devicemac.go @@ -0,0 +1,26 @@ +package comm + +import "strings" + +// ScopeToProduct 把「只看这个产品」这层过滤显式拼进查询条件。 +// +// device_mac 合表之后(2026-09-04 由 license_ 分表合并而来), +// productid 不再由表名承担。原先每个函数都靠 license_ 这张表**隐含**了 +// 产品作用域,合表后**每一处按产品语义的读写都必须显式带上 productid**—— +// 漏一处就跨产品操作:查询多列出别的产品的设备还只是难看, +// 批量禁用 / 按批次删除漏掉就是直接误伤别人的数据。 +// +// pid == 0 表示不限产品(后台「全部产品」视图),此时原样返回。 +func ScopeToProduct(pid uint32, query string, args []interface{}) (string, []interface{}) { + if pid == 0 { + return query, args + } + scoped := make([]interface{}, 0, len(args)+1) + scoped = append(scoped, pid) + scoped = append(scoped, args...) + if strings.TrimSpace(query) == "" { + return "productid=?", scoped + } + // 原条件整体括起来:调用方传的可能是 "a=? or b=?",不括会让 or 把 productid 短路掉。 + return "productid=? and (" + query + ")", scoped +} diff --git a/apps/services/modules/api/model.go b/apps/services/modules/api/model.go index 1395a165..3694a5b8 100644 --- a/apps/services/modules/api/model.go +++ b/apps/services/modules/api/model.go @@ -86,6 +86,10 @@ func (this *modelComp) Init(service core.IService, module core.IModule, comp cor if err = postgres.CreateTable(comm.TableProductionBatch, &pb.DBProductionBatch{}); err != nil { this.module.Errorln(err) } + // factoryid 后加字段(代工厂 oem_factory.id):CreateTable 对已存在的表跳过 AutoMigrate,显式补列。 + if res := postgres.Exec("ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS factoryid bigint DEFAULT 0"); res.Error != nil { + this.module.Errorln(res.Error) + } if err = postgres.CreateTable(comm.TableProduct, &pb.DBProduct{}); err != nil { this.module.Errorln(err) } else { @@ -98,6 +102,9 @@ func (this *modelComp) Init(service core.IService, module core.IModule, comp cor "ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS providerid bigint DEFAULT 0", "ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS createtime bigint DEFAULT 0", "ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS description text DEFAULT ''", + // oemfactoryid = 这款产品默认由哪家代工厂生产(oem_factory.id)。 + // ⚠️ 不叫 factoryid:DBProduct 的 factoryid 是老客户端兼容字段(gorm:"-",值同 brandid),同名会打架。 + "ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS oemfactoryid bigint DEFAULT 0", "CREATE INDEX IF NOT EXISTS idx_product_providerid ON " + comm.TableProduct + " (providerid)", } { if res := postgres.Exec(ddl); res.Error != nil { @@ -110,10 +117,8 @@ func (this *modelComp) Init(service core.IService, module core.IModule, comp cor if err = mysql.CreateTable(comm.TableChannelApp, &pb.DBChannelApp{}); err != nil { this.module.Errorln(err) } - if err = postgres.CreateTable(comm.TableLicense, &pb.DBAuthCode{}); err != nil { - this.module.Errorln(err) - return err - } + // 不建裸 license 表:授权码只住按产品切的 license_ 分表(见下方按产品建表的循环), + // 不带后缀的那张从来没有代码读写,建出来只是每个环境多一张恒空表。 if err = postgres.CreateTable(comm.TableFactoryPublicCode, &pb.DBFactoryPublicCode{}); err != nil { this.module.Errorln(err) return err @@ -150,12 +155,9 @@ func (this *modelComp) Start() (err error) { this.module.Errorln(err) return err } - for _, v := range products { - if err = postgres.CreateTable(fmt.Sprintf("%s_%x", comm.TableLicense, v.Id), &pb.DBAuthCode{}); err != nil { - this.module.Errorln(err) - return err - } - } + // 设备表已合成全局一张 device_mac(见 console/registry.go 的 migrateLicenseToDeviceMac), + // 不再按产品建分表。这里保留 products 查询——下面的统计初始化还要用。 + _ = products // product_stat 为空(首次部署 / 从未点过重建)时做一次初始扫描, // 避免代理仪表盘在首次重建前激活数/绑定用户数全部显示 0。 var statCount int64 @@ -348,7 +350,7 @@ func (this *modelComp) delagent(id string) (err error) { } // Mcp 服务:从 svc_config(MCP 服务类型) + 区域覆盖解析该区域可用的 MCP 服务器。 -// MCP 已并入第三方服务(comm.SvcCatMCP),服务定义全局(app_name=''),url/tools 按区域 fork。 +// MCP 已并入第三方服务(comm.SvcCatMCP),服务定义全局(app_name=”),url/tools 按区域 fork。 func (this *modelComp) mcpservers(region pb.Region) (servers []*pb.DBMcpServer, err error) { servers = make([]*pb.DBMcpServer, 0) svcs := make([]*comm.ThirdSvcConfig, 0) @@ -572,12 +574,7 @@ func (this *modelComp) addproducts(products ...*pb.DBProduct) (err error) { if err = postgres.Insert(comm.TableProduct, products); err != nil { return err } - for _, v := range products { - if err = postgres.CreateTable(fmt.Sprintf("%s_%x", comm.TableLicense, v.Id), &pb.DBAuthCode{}); err != nil { - this.module.Errorln(err) - return err - } - } + // 新增产品**不再建 license 分表**:设备统一住全局 device_mac 表。 return } @@ -684,15 +681,16 @@ func (this *modelComp) allocSerialRange(pid uint32, total int64) (end int64, err // 统计指定批次中已被使用(status>0 或 uid非空)的授权码数量 func (this *modelComp) countUsedAuthCodesByBatch(pid uint32, batchno string) (used int64, err error) { - err = postgres.Table(fmt.Sprintf("%s_%x", comm.TableLicense, pid)). - Where("batchno=? AND (status>0 OR uid<>'')", batchno).Count(&used).Error + q, a := comm.ScopeToProduct(pid, "batchno=? AND (status>0 OR uid<>'')", []interface{}{batchno}) + err = postgres.Table(comm.TableDeviceMac).Where(q, a...).Count(&used).Error return } // 删除指定批次的全部授权码 func (this *modelComp) delAuthCodesByBatch(pid uint32, batchno string) (affected int64, err error) { - tx := postgres.Table(fmt.Sprintf("%s_%x", comm.TableLicense, pid)). - Where("batchno=?", batchno).Delete(&pb.DBAuthCode{}) + // ⚠️ 删除必须带 productid:合表后没有分表这层天然隔离了。 + q, a := comm.ScopeToProduct(pid, "batchno=?", []interface{}{batchno}) + tx := postgres.Table(comm.TableDeviceMac).Where(q, a...).Delete(&pb.DBAuthCode{}) affected = tx.RowsAffected err = tx.Error return @@ -701,23 +699,25 @@ func (this *modelComp) delAuthCodesByBatch(pid uint32, batchno string) (affected // 厂家设备------------------------------ func (this *modelComp) factoryDevics(pid uint32, query interface{}, args ...interface{}) (models []*pb.DBAuthCode, err error) { models = make([]*pb.DBAuthCode, 0) - err = postgres.Find(fmt.Sprintf("%s_%x", comm.TableLicense, pid), &models, query, args...) + qs, _ := query.(string) + q, a := comm.ScopeToProduct(pid, qs, args) + err = postgres.Find(comm.TableDeviceMac, &models, q, a...) return } // 分页查询授权码(支持 ORDER BY + LIMIT/OFFSET),同时返回命中总数 func (this *modelComp) factoryDevicsPaged(pid uint32, query, orderClause string, limit, offset int, args ...interface{}) (models []*pb.DBAuthCode, total int64, err error) { models = make([]*pb.DBAuthCode, 0) - tName := fmt.Sprintf("%s_%x", comm.TableLicense, pid) + query, args = comm.ScopeToProduct(pid, query, args) - tx := postgres.Table(tName) + tx := postgres.Table(comm.TableDeviceMac) if query != "" { tx = tx.Where(query, args...) } if err = tx.Count(&total).Error; err != nil { return } - tx2 := postgres.Table(tName) + tx2 := postgres.Table(comm.TableDeviceMac) if query != "" { tx2 = tx2.Where(query, args...) } @@ -736,8 +736,9 @@ func (this *modelComp) factoryDevicsPaged(pid uint32, query, orderClause string, // 批量设置设备码封禁状态(disabled: 0 正常, -1 禁用),返回实际更新条数 func (this *modelComp) setFactoryDevicsDisabled(pid uint32, codes []string, disabled int32) (affected int64, err error) { - tx := postgres.Table(fmt.Sprintf("%s_%x", comm.TableLicense, pid)). - Where("code IN ?", codes).Update("disabled", disabled) + // ⚠️ 批量更新必须带 productid,理由同 delAuthCodesByBatch。 + q, a := comm.ScopeToProduct(pid, "code IN ?", []interface{}{codes}) + tx := postgres.Table(comm.TableDeviceMac).Where(q, a...).Update("disabled", disabled) affected = tx.RowsAffected err = tx.Error return @@ -745,26 +746,30 @@ func (this *modelComp) setFactoryDevicsDisabled(pid uint32, codes []string, disa func (this *modelComp) factoryDevic(pid uint32, code string) (model *pb.DBAuthCode, err error) { model = &pb.DBAuthCode{} - err = postgres.FindOne(fmt.Sprintf("%s_%x", comm.TableLicense, pid), &model, "code=?", code) + // code 是全局主键,pid 只作额外校验(传 0 即不校验)。 + q, a := comm.ScopeToProduct(pid, "code=?", []interface{}{code}) + err = postgres.FindOne(comm.TableDeviceMac, &model, q, a...) return } func (this *modelComp) factoryDevicforuid(pid uint32, uid string) (models []*pb.DBAuthCode, err error) { models = make([]*pb.DBAuthCode, 0) - err = postgres.Find(fmt.Sprintf("%s_%x", comm.TableLicense, pid), &models, "uid=?", uid) + q, a := comm.ScopeToProduct(pid, "uid=?", []interface{}{uid}) + err = postgres.Find(comm.TableDeviceMac, &models, q, a...) return } func (this *modelComp) addfactoryDevics(pid uint32, models []*pb.DBAuthCode) (err error) { // DBAuthCode 有 12 列,单条 INSERT 的预编译占位符上限是 65535(MySQL Error 1390)。 // 大批量会溢出,这里按 1000 行分批写入。 - err = postgres.Table(fmt.Sprintf("%s_%x", comm.TableLicense, pid)).CreateInBatches(models, 1000).Error + err = postgres.Table(comm.TableDeviceMac).CreateInBatches(models, 1000).Error return } -func (this *modelComp) updatefactoryDevic(pid uint32, models *pb.DBAuthCode) (err error) { - err = postgres.Save(fmt.Sprintf("%s_%x", comm.TableLicense, pid), &models) +// 下面两个按主键(code)保存,主键全局唯一,不需要产品作用域。 +func (this *modelComp) updatefactoryDevic(_ uint32, models *pb.DBAuthCode) (err error) { + err = postgres.Save(comm.TableDeviceMac, &models) return } -func (this *modelComp) updatefactoryDevics(pid uint32, models []*pb.DBAuthCode) (err error) { - err = postgres.Save(fmt.Sprintf("%s_%x", comm.TableLicense, pid), &models) +func (this *modelComp) updatefactoryDevics(_ uint32, models []*pb.DBAuthCode) (err error) { + err = postgres.Save(comm.TableDeviceMac, &models) return } diff --git a/apps/services/modules/console/api_device.go b/apps/services/modules/console/api_device.go index e6c1446d..2f96d4f9 100644 --- a/apps/services/modules/console/api_device.go +++ b/apps/services/modules/console/api_device.go @@ -7,13 +7,20 @@ import ( "time" "yunyan/comm" + "yunyan/lego/sys/log" "yunyan/pb" - "yunyan/utils/license" + "yunyan/utils/devcode" "yunyan/utils/mac" "github.com/gin-gonic/gin" ) +// 批次绑定赠送的上限(防手滑,不是业务硬约束)。 +const ( + batchRewardVipDayMax uint32 = 3650 // 10 年 + batchRewardMinuteMax uint32 = 525600 // 1 年的分钟数 +) + // defaultBrandContactMail 品牌商联系邮箱缺省必含项(与 modules/api 一致)。 const defaultBrandContactMail = "358030594@qq.com" @@ -24,17 +31,38 @@ const defaultBrandContactMail = "358030594@qq.com" // ---- 产品 / 版本 ---- func (this *serverComp) updateProduct(c *gin.Context, sys *appConn) { - var req pb.ApiUpdateProductReq + // 不直接绑 pb.ApiUpdateProductReq:改 PID 要同时知道新旧两个号,而 pb 里只有一个 id。 + var req struct { + Product *pb.DBProduct `json:"product"` + Oldid uint32 `json:"oldid"` // 原 PID;0 或与 product.id 相同 = 不改号 + } _ = c.ShouldBindJSON(&req) if req.Product == nil { writeErr(c, pb.ErrorCode_ReqParameterError, "product 必填") return } - old, err := dvProduct(sys, req.Product.Id) + // 定位用旧号。不传 oldid 时退化成「按 id 定位、不改号」,兼容切版本这类局部更新。 + locateId := req.Oldid + if locateId == 0 { + locateId = req.Product.Id + } + old, err := dvProduct(sys, locateId) if err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, "产品不存在") return } + // 改号:先校验(新号未占用、未投产),再裸 UPDATE 改主键并搬迁版本行。 + if req.Product.Id != locateId { + if msg := checkProductIdChange(sys, locateId, req.Product.Id); msg != "" { + writeErr(c, pb.ErrorCode_ReqParameterError, msg) + return + } + if err = renameProductId(locateId, req.Product.Id); err != nil { + writeErr(c, pb.ErrorCode_DBError, "改 PID 失败: "+err.Error()) + return + } + old.Id = req.Product.Id + } // 方案商:dvUpdateProduct 是全字段 Save,没传就会被清空。所以传 0 视为「不改」,沿用原绑定; // 传了值才校验存在性(产品表单必填,切版本这类局部更新不带该字段)。 if req.Product.Providerid == 0 { @@ -43,6 +71,35 @@ func (this *serverComp) updateProduct(c *gin.Context, sys *appConn) { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } + if msg := checkProductOemFactory(sys, req.Product.Oemfactoryid); msg != "" { + writeErr(c, pb.ErrorCode_ReqParameterError, msg) + return + } + // 品牌商 / 渠道方 / 绑定奖励 / 功能开关已从产品表单移除,前端不再回传这些字段。 + // dvUpdateProduct 是全字段 Save,不显式保留就会被抹成 0/false——那会让存量产品的 + // 绑定奖励失效、客户端功能开关集体关掉(这些值仍在下发给 App,见 user_getappconfig)。 + req.Product.Brandid = old.Brandid + req.Product.Channel = old.Channel + req.Product.Channelextra = old.Channelextra + req.Product.Bindrewardvpitime = old.Bindrewardvpitime + req.Product.Bindrewardtranslate = old.Bindrewardtranslate + req.Product.Bindrewardmeeting = old.Bindrewardmeeting + req.Product.Iscanbindagain = old.Iscanbindagain + req.Product.Iscontinuouschat = old.Iscontinuouschat + req.Product.Eqenable = old.Eqenable + req.Product.Keyenable = old.Keyenable + req.Product.Workmodeenable = old.Workmodeenable + req.Product.Callmodeenable = old.Callmodeenable + req.Product.Ledenable = old.Ledenable + req.Product.Finddeviceenable = old.Finddeviceenable + req.Product.Noisereductionenable = old.Noisereductionenable + req.Product.Shutdownenable = old.Shutdownenable + req.Product.Showcasebattery = old.Showcasebattery + req.Product.Wakeupenable = old.Wakeupenable + req.Product.Broadcastpeertranslate = old.Broadcastpeertranslate + req.Product.Hideaiassistant = old.Hideaiassistant + req.Product.Bassfilter = old.Bassfilter + req.Product.Serialcursor = old.Serialcursor // 授权码序号游标,绝不能被表单回传清零 // 创建时间只在新增时写一次,更新是全字段 Save,前端没回传就会被抹成 0。 if req.Product.Createtime == 0 { req.Product.Createtime = old.Createtime @@ -72,13 +129,51 @@ func (this *serverComp) updateProduct(c *gin.Context, sys *appConn) { writeOK(c, &pb.ApiUpdateProductResp{}) } +// delProduct 删产品,并把它那张 license_ 分表一起删掉。 +// +// ⚠️ 分表必须跟着删:建表在 ensureDeviceTables 里按产品列表做,删产品时却只删产品行, +// 于是每删一个产品就留下一张永远没人再读的空表。这个环境上已经攒了 44 张 +// (license_b001…license_b04e),全空、全无对应产品。 +// +// 有授权码就拒绝删产品:那些 MAC 已经烧进设备发出去了,删掉产品会让线上设备绑定时 +// 查不到产品直接报错,而且分表一删就再也追不回是哪一批货。要下架请停用产品,不要删。 func (this *serverComp) delProduct(c *gin.Context, sys *appConn) { var req pb.ApiDelProductReq _ = c.ShouldBindJSON(&req) + if req.Id == 0 { + writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") + return + } + if n, err := countLicenseRows(req.Id); err != nil { + writeErr(c, pb.ErrorCode_DBError, "校验授权码失败: "+err.Error()) + return + } else if n > 0 { + writeErr(c, pb.ErrorCode_ReqParameterError, + fmt.Sprintf("该产品已生产 %d 个 MAC,不能删除(这些设备已经发出去了)", n)) + return + } + var batches int64 + if err := sys.AdminDB().Table(comm.TableProductionBatch).Where("productid=?", req.Id).Count(&batches).Error; err != nil { + writeErr(c, pb.ErrorCode_DBError, "校验生产批次失败: "+err.Error()) + return + } + if batches > 0 { + writeErr(c, pb.ErrorCode_ReqParameterError, + fmt.Sprintf("该产品已有 %d 个生产批次,不能删除;请先撤销这些批次", batches)) + return + } if err := dvDelProduct(sys, req.Id); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } + // 设备行不用删:上面 countLicenseRows 已经保证这个产品在 device_mac 里是 0 行, + // 有设备就压根不让删产品。这里只顺手清掉合表前遗留的旧分表(迁移刻意不删它们, + // 见 registry.go 的 migrateLicenseToDeviceMac)。 + // 删失败也不该把「产品已删」这个结果回滚成失败——只记日志。 + if res := sys.AdminDB().Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s_%x", comm.TableLicense, req.Id)); res.Error != nil { + log.Warn("console.device: 删产品后清理遗留的授权码分表失败", + log.Field{Key: "productid", Value: req.Id}, log.Field{Key: "err", Value: res.Error.Error()}) + } this.module.deviceCache.refreshProducts() // 写后刷新产品缓存 writeOK(c, &pb.ApiDelProductResp{}) } @@ -135,13 +230,33 @@ func (this *serverComp) addProduct(c *gin.Context, sys *appConn) { writeErr(c, pb.ErrorCode_ReqParameterError, "product 必填") return } - brand, err := dvBrand(sys, req.Product.Brandid) - if err != nil { - writeErr(c, pb.ErrorCode_DBError, "品牌商不存在: "+err.Error()) + // PID 手工指定(与 CID/BID 同套路:有含义的十六进制编码)。传 0 则回落到自增分配(从 0xB001 起), + // 兼容不带 PID 的老调用;传了就必须没被占用,否则报的是裸主键冲突、看不出是什么问题。 + if req.Product.Id != 0 { + if exist, _ := dvProduct(sys, req.Product.Id); exist != nil && exist.Id != 0 { + writeErr(c, pb.ErrorCode_ReqParameterError, + fmt.Sprintf("PID 0x%X 已被产品「%s」占用", req.Product.Id, exist.Devicename)) + return + } + } + // 代工厂可选(0=不指定):产品级是「默认由哪家厂生产」,具体批次上还能再选一次。 + if msg := checkProductOemFactory(sys, req.Product.Oemfactoryid); msg != "" { + writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } - // 方案商挂在产品上(不是品牌商上):同一品牌的不同产品可能用不同芯片方案, - // 生产设备码时的服务ID(CID)直接取产品的 providerid,所以这里必须绑定且必须真实存在。 + // 品牌商已从产品表单移除:产品是「生产阶段」的东西,卖给哪个品牌是 MAC生成 时按批次定的 + // (production_batch.brandid),所以这里不再必填。传了就校验存在性,传 0 视为未绑定。 + var brand *pb.DBBrand + if req.Product.Brandid != 0 { + var berr error + if brand, berr = dvBrand(sys, req.Product.Brandid); berr != nil { + writeErr(c, pb.ErrorCode_DBError, "品牌商不存在: "+berr.Error()) + return + } + } + // 方案商挂在产品上(不是品牌商上):同一品牌的不同产品可能用不同芯片方案。 + // 三个要烧进固件的标识口径固定为 CID=芯片公司 / BID=方案公司 / PID=产品, + // 其中 BID 就是这里的 providerid、CID 再由该方案商的 vendor 推出,所以必须绑定且真实存在。 if msg := checkProductProvider(sys, req.Product.Providerid); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return @@ -152,22 +267,25 @@ func (this *serverComp) addProduct(c *gin.Context, sys *appConn) { return } req.Product.Createtime = time.Now().Unix() - if err = dvAddProduct(sys, req.Product); err != nil { // Insert 后回填 Product.Id + 建 license 分表 + if err := dvAddProduct(sys, req.Product); err != nil { // Insert 后回填 Product.Id + 建 license 分表 writeErr(c, pb.ErrorCode_DBError, err.Error()) return } - // 把新产品 id 追加进品牌商的产品列表。 - if len(brand.Productlists) == 0 { - brand.Productlists = fmt.Sprintf("%d", req.Product.Id) - } else { - brand.Productlists = fmt.Sprintf("%s,%d", brand.Productlists, req.Product.Id) - } - if err = dvSaveBrand(sys, brand); err != nil { - writeErr(c, pb.ErrorCode_DBError, err.Error()) - return + // 把新产品 id 追加进品牌商的产品列表。品牌商现在可以不填(brand 为 nil),此时整段跳过—— + // 少了这个判空会直接 nil panic 把 console 打挂。 + if brand != nil { + if len(brand.Productlists) == 0 { + brand.Productlists = fmt.Sprintf("%d", req.Product.Id) + } else { + brand.Productlists = fmt.Sprintf("%s,%d", brand.Productlists, req.Product.Id) + } + if err := dvSaveBrand(sys, brand); err != nil { + writeErr(c, pb.ErrorCode_DBError, err.Error()) + return + } + this.module.deviceCache.refreshBrands() // 改了品牌商 Productlists } - this.module.deviceCache.refreshProducts() // 新增产品 + 改了品牌商 Productlists,刷新两者缓存 - this.module.deviceCache.refreshBrands() + this.module.deviceCache.refreshProducts() writeOK(c, &pb.ApiAddProductResp{Product: req.Product}) } @@ -175,24 +293,57 @@ func (this *serverComp) addProduct(c *gin.Context, sys *appConn) { // 芯片方案厂商分组(DBSolutionProvider.vendor)。一个芯片厂商下往往有多套方案, // 每套方案单独一条方案商记录:vendor 归组、name 记方案商公司名、description 记这套方案做什么。 -// 注意:pb 里的 SolutionVendor 只生成到 3(恒玄),博通=4 已写进 apps/proto/db.proto, -// 但本机没装 protoc、pb 尚未重生成;proto3 是开放枚举,按数值收发即可, -// 下次重生成 pb 后这里可换成 pb.SolutionVendor_VENDOR_BOTONG 等常量。 -// 新增芯片厂商 = db.proto 的 SolutionVendor + 这张表 + 前端 utils/chipvendors.ts 三处补同一个数值。 -var chipVendorNames = map[int32]string{ - 1: "杰里", - 2: "蓝汛", - 3: "恒玄", - 4: "博通", -} +// +// vendor 存的是 chip_vendor 表的 id(见 model_chipvendor.go)。原先这里有一份写死的 +// chipVendorNames 映射,加一家厂商要同时改 db.proto 枚举、这张表、前端 utils/chipvendors.ts +// 三处再发版;已改为查表,后台「芯片管理」页可自助增删改。 +// pb 的 SolutionVendor 枚举保留不动——proto3 是开放枚举,按数值收发即可, +// 表 id 与历史枚举值 1~4 一一对应,存量方案商的 vendor 不用迁移。 -// checkChipVendor 校验芯片分组:必填且必须是已知厂商,返回空串表示通过。 -func checkChipVendor(v pb.SolutionVendor) string { +// checkChipVendor 校验芯片分组:必填且必须是 chip_vendor 表里已启用的一条,返回空串表示通过。 +// 停用的厂商不允许新绑(但存量方案商照常显示其名字,见前端 chipVendorName)。 +func checkChipVendor(sys *appConn, v pb.SolutionVendor) string { if int32(v) == 0 { - return "芯片分组必填(杰里/蓝汛/恒玄/博通)" + return "芯片分组必填,请先在「芯片管理」里维护厂商" + } + vendor, err := dvChipVendor(sys, uint32(v)) + if err != nil || vendor == nil || vendor.Id == 0 { + return fmt.Sprintf("未知的芯片分组:%d(可能已被删除,请到「芯片管理」确认)", int32(v)) } - if _, ok := chipVendorNames[int32(v)]; !ok { - return fmt.Sprintf("未知的芯片分组:%d", int32(v)) + if !vendor.Enabled { + return fmt.Sprintf("芯片厂商「%s」已停用,不能再绑新方案商", vendor.Name) + } + return "" +} + +// checkProductionFactory 校验批次要记的代工厂:0=不指定直接放行;非 0 时必须存在且未停用。 +// 可选而非必填:本功能上线前的批次都没有工厂概念,强制填会让老流程直接卡死。 +func checkProductionFactory(sys *appConn, id uint32) string { + if id == 0 { + return "" + } + f, err := dvOemFactory(sys, id) + if err != nil || f == nil || f.Id == 0 { + return "代工厂不存在,请到「工厂管理」确认" + } + if !f.Enabled { + return fmt.Sprintf("工厂「%s」已停用,不能用于生产", f.Name) + } + return "" +} + +// checkProductOemFactory 校验产品绑定的代工厂:0=不指定放行;非 0 时必须存在且未停用。 +// 可选而非必填:工厂档案可能还没建,强制填会让「先建产品、后补工厂」这个常见顺序走不通。 +func checkProductOemFactory(sys *appConn, id uint32) string { + if id == 0 { + return "" + } + f, err := dvOemFactory(sys, id) + if err != nil || f == nil || f.Id == 0 { + return "代工厂不存在,请到「工厂管理」确认" + } + if !f.Enabled { + return fmt.Sprintf("工厂「%s」已停用,不能绑到产品上", f.Name) } return "" } @@ -200,7 +351,7 @@ func checkChipVendor(v pb.SolutionVendor) string { // checkProductProvider 校验产品绑定的方案商:必填且必须存在,返回空串表示通过。 func checkProductProvider(sys *appConn, id uint32) string { if id == 0 { - return "归属方案商必填(生产设备码的服务ID(CID)取自产品的方案商)" + return "归属方案必填(方案ID(BID)取自它,芯片ID(CID)再由它的芯片推出)" } p, err := dvSolutionProvider(sys, id) if err != nil || p == nil || p.Id == 0 { @@ -226,7 +377,7 @@ func (this *serverComp) addSolutionProvider(c *gin.Context, sys *appConn) { writeErr(c, pb.ErrorCode_ReqParameterError, fmt.Sprintf("方案商 id %X 已存在(%s)", old.Id, old.Name)) return } - if msg := checkChipVendor(req.Provider.Vendor); msg != "" { + if msg := checkChipVendor(sys, req.Provider.Vendor); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } @@ -239,29 +390,51 @@ func (this *serverComp) addSolutionProvider(c *gin.Context, sys *appConn) { } func (this *serverComp) updateSolutionProvider(c *gin.Context, sys *appConn) { - var req pb.ApiUpdateSolutionProviderReq + // 不直接绑 pb.ApiUpdateSolutionProviderReq:改 BID 要同时知道新旧两个号, + // 而 pb 里只有一个 id。多带一个 oldid 字段比改 proto 轻。 + var req struct { + Provider *pb.DBSolutionProvider `json:"provider"` + Oldid uint32 `json:"oldid"` // 原 BID;0 或与 provider.id 相同 = 不改号 + } _ = c.ShouldBindJSON(&req) if req.Provider == nil || req.Provider.Id == 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "provider id is required") return } + // 定位用旧号,落库用新号。前端不传 oldid 时退化成「按 id 定位、不改号」,兼容旧调用。 + locateId := req.Oldid + if locateId == 0 { + locateId = req.Provider.Id + } if strings.TrimSpace(req.Provider.Name) == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "provider name 必填") return } - if msg := checkChipVendor(req.Provider.Vendor); msg != "" { + if msg := checkChipVendor(sys, req.Provider.Vendor); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } - old, err := dvSolutionProvider(sys, req.Provider.Id) + old, err := dvSolutionProvider(sys, locateId) if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } + // 改号:先校验(新号未被占用、名下没有产品),再裸 UPDATE 改主键,随后按新号继续保存其余字段。 + if req.Provider.Id != locateId { + if msg := checkProviderIdChange(sys, locateId, req.Provider.Id); msg != "" { + writeErr(c, pb.ErrorCode_ReqParameterError, msg) + return + } + if err = renameProviderId(locateId, req.Provider.Id); err != nil { + writeErr(c, pb.ErrorCode_DBError, "改 BID 失败: "+err.Error()) + return + } + old.Id = req.Provider.Id + } old.Name = req.Provider.Name old.Description = req.Provider.Description old.Contactmails = req.Provider.Contactmails - old.Vendor = req.Provider.Vendor // 芯片分组(杰里/蓝汛/恒玄/博通) + old.Vendor = req.Provider.Vendor // 归属芯片厂商(chip_vendor.id) if err = dvSaveSolutionProvider(sys, old); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return @@ -423,17 +596,23 @@ func (this *serverComp) previewAuthCodes(c *gin.Context, sys *appConn) { var req pb.ApiCreateAuthCodesReq _ = c.ShouldBindJSON(&req) - if !this.requireBrandScope(c, req.Brandid) { // 品牌商账号只能给自己生产 + // 先定渠道商,再由它推出品牌归属(生产表单已不选品牌商,见 resolveProductionBrandId)。 + channel, ok := resolveProductionChannelModel(c, sys, req.Channelid) + if !ok { return } - brand, err := dvBrand(sys, req.Brandid) - if err != nil { - writeErr(c, pb.ErrorCode_DBError, "品牌商不存在: "+err.Error()) + brandId := resolveProductionBrandId(c, channel) + if !this.requireBrandScope(c, brandId) { // 品牌商账号只能给自己生产 return } - channel, ok := resolveProductionChannelModel(c, sys, req.Channelid, req.Brandid) - if !ok { - return + brandName := "-" // 0 = 无品牌归属(平台直营),不去查库 + if brandId != 0 { + brand, berr := dvBrand(sys, brandId) + if berr != nil { + writeErr(c, pb.ErrorCode_DBError, "品牌商不存在: "+berr.Error()) + return + } + brandName = brand.Brand } product, err := dvProduct(sys, req.Productid) if err != nil { @@ -445,14 +624,20 @@ func (this *serverComp) previewAuthCodes(c *gin.Context, sys *appConn) { if codeCount == 0 { codeCount = 1 } - isClassicBT := product.Devicetype == pb.DeviceType_Classic_bluetooth_Headset - var total int64 + var importMacs []string switch req.Createtype { case 1: total = int64(req.Number) * int64(codeCount) case 2: - total = int64(len(strings.Split(req.Macaddresses, ","))) + // 预览也走同一套规范化/查重:格式错、重复、已生产过这三类问题在这一步就报出来, + // 别等运营在核对页点了「确认生产」才失败——那时序号游标已经被推进过了。 + var msg string + if importMacs, msg = normalizeImportMacs(sys, req.Productid, req.Macaddresses); msg != "" { + writeErr(c, pb.ErrorCode_ReqParameterError, msg) + return + } + total = int64(len(importMacs)) default: writeErr(c, pb.ErrorCode_ReqParameterError, "createtype 无效(1 生产 / 2 导入)") return @@ -465,27 +650,48 @@ func (this *serverComp) previewAuthCodes(c *gin.Context, sys *appConn) { // 预估序号区间:当前游标 +1 ~ +total(正式生产时原子分配,并发生产时以实际为准)。 start := product.Serialcursor + 1 end := product.Serialcursor + total + // MAC 区间只对【生产模式】成立:那时 MAC 由 (PID, 序号) 算出来,首尾两个就是真实落地的边界。 + // + // ⚠️ 导入模式没有区间。以前这里对 createtype==2 也按序号算了一对 MAC,那两个值 + // 与实际入库的外部 MAC 毫无关系——核对页显示的是一段根本不会出现的地址,批次台账里 + // 也存了同样的假区间。导入模式改成不给区间,改给条数与前几条样例。 startmac, endmac := "", "" - if isClassicBT || req.Createtype == 2 { + if req.Createtype == 1 { startmac = mac.Generate(uint16(req.Productid), uint32(start)) endmac = mac.Generate(uint16(req.Productid), uint32(end)) } + // 导入模式给前 3 条样例,让运营在核对页能确认「导进去的确实是我那份 CSV」。 + importSample := make([]string, 0, 3) + if req.Createtype == 2 { + for i, m := range importMacs { + if i >= 3 { + break + } + importSample = append(importSample, m) + } + } writeOK(c, gin.H{ - "batchno": genBatchNo(sys), - "brandid": req.Brandid, - "brandname": brand.Brand, - "channelid": channel.Id, - "channelname": channel.Name, - "productid": req.Productid, - "productname": product.Devicename, - "codetype": map[bool]string{true: "设备码(含MAC)", false: "授权码"}[isClassicBT || req.Createtype == 2], - "codecount": codeCount, - "total": total, - "serialstart": start, - "serialend": end, - "startmac": startmac, - "endmac": endmac, + "batchno": genBatchNo(sys), + "brandid": brandId, + "brandname": brandName, + "channelid": channel.Id, + "channelname": channel.Name, + "productid": req.Productid, + "productname": product.Devicename, + "codetype": map[bool]string{true: "MAC(外部导入)", false: "MAC(按序号生成)"}[req.Createtype == 2], + "codecount": codeCount, + "total": total, + "serialstart": start, + "serialend": end, + "startmac": startmac, + "endmac": endmac, + "importsample": importSample, + "sharerate": req.Sharerate, + "factoryid": product.Oemfactoryid, + "rewardvip": req.Rewardvip, + "rewardtranslate": req.Rewardtranslate, + "rewardmeeting": req.Rewardmeeting, }) } @@ -493,16 +699,29 @@ func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) { var req pb.ApiCreateAuthCodesReq _ = c.ShouldBindJSON(&req) - if !this.requireBrandScope(c, req.Brandid) { // 品牌商账号只能给自己生产 + // 渠道商必选:生产时定下的归属会随批次写进每条授权码,用户绑定该设备后统计与结算都按它归属。 + channel, ok := resolveProductionChannelModel(c, sys, req.Channelid) + if !ok { return } - if _, err := dvBrand(sys, req.Brandid); err != nil { - writeErr(c, pb.ErrorCode_DBError, "品牌商不存在: "+err.Error()) + // 品牌归属由渠道商/账号推出(生产表单已不选品牌商)。0 = 无品牌归属。 + brandId := resolveProductionBrandId(c, channel) + if !this.requireBrandScope(c, brandId) { // 品牌商账号只能给自己生产 return } - // 渠道商必选:生产时定下的归属会随批次写进每条授权码,用户绑定该设备后统计与结算都按它归属。 - channel, ok := resolveProductionChannelModel(c, sys, req.Channelid, req.Brandid) - if !ok { + if brandId != 0 { + if _, err := dvBrand(sys, brandId); err != nil { + writeErr(c, pb.ErrorCode_DBError, "品牌商不存在: "+err.Error()) + return + } + } + // 渠道分成比例按批次定(同一渠道商不同批次可以谈不同比例),生产时冻结进批次台账。 + if req.Sharerate < 0 || req.Sharerate > channelShareRateMax { + writeErr(c, pb.ErrorCode_ReqParameterError, "分成比例需在 0~10000(万分比) 之间") + return + } + if msg := checkBatchRewards(req.Rewardvip, req.Rewardtranslate, req.Rewardmeeting); msg != "" { + writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } product, err := dvProduct(sys, req.Productid) @@ -511,20 +730,29 @@ func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) { return } + // 代工厂由产品档案推出,不收前端传值:一款产品固定由哪家厂代工是产品属性, + // 生产表单上已改成只读展示。仍要校验一次——产品建档后工厂可能被停用/删除。 + factoryId := product.Oemfactoryid + if msg := checkProductionFactory(sys, factoryId); msg != "" { + writeErr(c, pb.ErrorCode_ReqParameterError, msg) + return + } // 每副产品需要生产的授权码份数(0或1视为单份)。 codeCount := product.Devicecodecount if codeCount == 0 { codeCount = 1 } - isClassicBT := product.Devicetype == pb.DeviceType_Classic_bluetooth_Headset - var total int64 var importMacs []string switch req.Createtype { case 1: // 生产模式 total = int64(req.Number) * int64(codeCount) - case 2: // 导入模式(外部 MAC 列表) - importMacs = strings.Split(req.Macaddresses, ",") + case 2: // 导入模式(外部已烧录好的 MAC 列表) + var msg string + if importMacs, msg = normalizeImportMacs(sys, req.Productid, req.Macaddresses); msg != "" { + writeErr(c, pb.ErrorCode_ReqParameterError, msg) + return + } total = int64(len(importMacs)) default: writeErr(c, pb.ErrorCode_ReqParameterError, "createtype 无效(1 生产 / 2 导入)") @@ -543,9 +771,12 @@ func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) { } start := end - total + 1 - // 设备码(经典蓝牙/导入模式)展示 MAC 区间。 + // MAC 区间只在【生产模式】下有意义:MAC 由 (PID, 序号) 算出,序号连续,首尾即真实边界。 + // 导入模式的 MAC 来自外部 CSV、彼此不连续,留空——批次台账宁可显示「-」, + // 也不要存一对按序号算出来、库里根本不存在的假地址。明细在 license_ 里, + // 生产记录点「查看MAC」就能看到全部。 startmac, endmac := "", "" - if isClassicBT || req.Createtype == 2 { + if req.Createtype == 1 { startmac = mac.Generate(uint16(req.Productid), uint32(start)) endmac = mac.Generate(uint16(req.Productid), uint32(end)) } @@ -559,7 +790,7 @@ func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) { } batch := &pb.DBProductionBatch{ Ts: time.Now().Unix(), - Brandid: req.Brandid, + Brandid: brandId, Productid: req.Productid, Serialstart: start, Serialend: end, @@ -569,40 +800,47 @@ func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) { Batchno: batchno, Remark: req.Remark, Channelid: channel.Id, + Factoryid: factoryId, + Sharerate: req.Sharerate, + // 绑定赠送冻结进批次:事后改这里不影响已发出的货,绑定时按设备所属批次发放。 + Rewardvip: req.Rewardvip, + Rewardtranslate: req.Rewardtranslate, + Rewardmeeting: req.Rewardmeeting, } if err = dvAddProductionBatch(sys, batch); err != nil { // Insert 回填 batch.Id writeErr(c, pb.ErrorCode_DBError, err.Error()) return } + // ============ 逐条建档 ============ + // + // ⚠️ **一条设备 = 一个 MAC,code 就是 MAC**(2026-09-02 起,授权码字符串已彻底移除)。 + // + // 原先每条要同时生成一个 20 字符的 license 串和一个 MAC,两个都指向同一台设备, + // 而绑定、禁用、重置、结算反查批次全都只用 MAC,license 从头到尾没人校验。 + // 两套标识并存的代价是实打实的:工厂要多问一列烧不烧,出问题时说不清「设备的唯一标识 + // 到底是哪个」,reset 一类接口还得先猜是哪种形态才能反解出 PID。 + // + // MAC 由 (PID, 序号) 算出来,前 2 字节就是 PID —— 产品、批次、渠道全都能从它查回来, + // 这正是把 license 整条拿掉的底气。生产模式对**所有产品**都发 MAC,不再区分设备类型: + // MAC 只是 (PID,序号) 的一种编码,跟设备是不是经典蓝牙无关。 models := make([]*pb.DBAuthCode, 0, total) for i := int64(0); i < total; i++ { - serial := start + i - code, gerr := license.GenerateV2(uint16(req.Productid), uint32(serial)) - if gerr != nil { - writeErr(c, pb.ErrorCode_SystemError, gerr.Error()) - return - } row := &pb.DBAuthCode{ - Code: code, - Codetype: pb.Codetype_AUTHCODE, - Brandid: req.Brandid, + Codetype: pb.Codetype_DEVICECODE, + Brandid: brandId, Productid: req.Productid, - Number: serial, + Number: start + i, Batchno: batch.Batchno, Channelid: channel.Id, Createtime: time.Now().Unix(), } - switch req.Createtype { - case 1: - if isClassicBT { // 经典蓝牙:码即设备码,写 MAC - row.Codetype = pb.Codetype_DEVICECODE - row.Devicemac = mac.Generate(uint16(req.Productid), uint32(serial)) - } - case 2: // 导入模式:外部 MAC - row.Codetype = pb.Codetype_DEVICECODE - row.Devicemac = strings.TrimSpace(importMacs[i]) + if req.Createtype == 2 { + row.Devicemac = importMacs[i] // 导入模式:外部已烧录好的 MAC,上面已规范化并查过重 + } else { + row.Devicemac = mac.Generate(uint16(req.Productid), uint32(start+i)) } + row.Code = row.Devicemac models = append(models, row) } @@ -611,7 +849,7 @@ func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) { return } writeOK(c, &pb.ApiCreateAuthCodesResp{ - Brandid: req.Brandid, + Brandid: brandId, Channelid: channel.Id, Productid: req.Productid, Batchno: batch.Batchno, @@ -681,15 +919,26 @@ func (this *serverComp) revokeProductionBatch(c *gin.Context, sys *appConn) { // ---- 授权码重置(单条 / 批量 / 按用户)---- -// resetOneLicense 把一条授权码重置到初始未用状态(单条/批量共享): -// 取 pid(对不上则回退固定 45058)→ 若已绑定用户则删其 userdevice → -// 清授权码行(status/uid/devicemac/usedtime) → 若原为激活态则扣减 product_stat 激活数。 -func resetOneLicense(sys *appConn, code string) error { - pid, err := license.ProductID(code) - if err != nil { - pid = uint16(45058) +// resetOneLicense 把一条授权码/MAC 重置到初始未用状态(单条/批量共享): +// 定位 pid → 若已绑定用户则删其 userdevice → 清行(status/uid/devicemac/usedtime) +// → 若原为激活态则扣减 product_stat 激活数。 +// +// productid 由调用方传入(后台列表本来就按产品筛,PID 是现成的)。传 0 才退回从设备码反解。 +// +// ⚠️ 以前这里是「反解 license,失败就回退写死的 45058」——那会去 license_b002 这张不相干的 +// 分表里找,结果必然是 record not found,接口报错但看起来像「这条码不存在」。 +// 现在 code 可能就是 MAC,而导入进来的 MAC 前两字节是外部厂商的 OUI,反解出来是别人的 PID, +// 后果从「报错」变成「悄悄改了别的产品的数据」。所以宁可拿不到 PID 就报错,不要猜。 +func resetOneLicense(sys *appConn, productid uint32, code string) error { + pid := productid + if pid == 0 { + p, err := devcode.ProductID(code) + if err != nil { + return fmt.Errorf("无法确定该设备码所属产品,请带上 productid: %s", code) + } + pid = uint32(p) } - model, err := dvAuthCode(sys, uint32(pid), code) + model, err := dvAuthCode(sys, pid, code) if err != nil { return err } @@ -701,9 +950,15 @@ func resetOneLicense(sys *appConn, code string) error { } model.Status = 0 model.Uid = "" - model.Devicemac = "" + // ⚠️ 只有认证码(AUTHCODE)的 devicemac 是绑定时填进去的,重置要清掉。 + // 设备码(DEVICECODE)的 devicemac 是这台设备出厂就定死的身份——2026-09-02 起 code 就是它—— + // 清掉等于把设备从库里抹了:按 MAC 反查绑定的路径再也找不到它,而 code 那一列还留着, + // 两列从此对不上。解绑路径(api_unbinddevice.go)一直是按这个规则做的,这里以前漏了。 + // ⚠️ 不清 devicemac:设备码 = MAC = 分表主键 code(授权码字符串已于 2026-09-02 移除), + // 清掉等于把这台设备从库里抹掉——按 MAC 反查绑定的路径再也找不到它, + // 而 code 那一列还留着,两列从此对不上。 model.Usedtime = 0 - if err = dvUpdateAuthCode(sys, uint32(pid), model); err != nil { + if err = dvUpdateAuthCode(sys, pid, model); err != nil { return err } if wasActivated && model.Productid > 0 { @@ -718,7 +973,7 @@ func (this *serverComp) batchResetLicenseStatus(c *gin.Context, sys *appConn) { _ = c.ShouldBindJSON(&req) resp := &pb.ApiBatchResetLicenseStatusResp{} for _, code := range req.Licenses { - if err := resetOneLicense(sys, code); err != nil { + if err := resetOneLicense(sys, req.Productid, code); err != nil { resp.FailCount++ } else { resp.SuccessCount++ @@ -751,3 +1006,63 @@ func (this *serverComp) restFactoryDevics(c *gin.Context, sys *appConn) { } writeOK(c, &pb.ApiRestUserDevicsResp{}) } + +// checkBatchRewards 校验批次的绑定赠送三项。返回空串表示通过。 +// +// 上限不是技术限制,是防手滑:VIP 天数多打一个 0 就是 3650 天永久会员, +// 而授权码一旦生产出去就发给工厂了,撤不回来。 +func checkBatchRewards(vipDays, translateMin, meetingMin uint32) string { + if vipDays > batchRewardVipDayMax { + return fmt.Sprintf("赠送VIP天数不能超过 %d 天", batchRewardVipDayMax) + } + if translateMin > batchRewardMinuteMax { + return fmt.Sprintf("赠送翻译时长不能超过 %d 分钟", batchRewardMinuteMax) + } + if meetingMin > batchRewardMinuteMax { + return fmt.Sprintf("赠送会议时长不能超过 %d 分钟", batchRewardMinuteMax) + } + return "" +} + +// normalizeImportMacs 规范化并校验导入模式的 MAC 列表。返回 (规范化后的列表, 错误提示)。 +// +// MAC 现在就是授权码分表的主键,所以三件事必须在写库之前挡住,否则代价很高: +// 1. **格式**:大小写/有无冒号不一致会让同一台设备存成两行,而绑定是按 MAC 精确匹配的, +// 烧进耳机的写法和库里的写法差一点就永远绑不上; +// 2. **本次重复**:批量 INSERT 撞主键会整批失败,但序号游标已经被原子推进过了, +// 那段序号就白白废掉、再也发不出来; +// 3. **已存在**:同一批 MAC 被导入两次,第二次同样撞主键整批失败。 +// +// 逐条报第一个出错的位置,运营才知道去改 CSV 的哪一行。 +func normalizeImportMacs(sys *appConn, pid uint32, raw string) ([]string, string) { + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + seen := make(map[string]bool, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue // 允许结尾多一个逗号、CSV 里夹空行 + } + norm, ok := devcode.NormalizeMac(p) + if !ok { + return nil, fmt.Sprintf("第 %d 个 MAC 格式不对: %s(需要 6 字节十六进制,如 B0:23:00:00:00:01)", len(out)+1, p) + } + if seen[norm] { + return nil, fmt.Sprintf("导入列表里 MAC 重复: %s", norm) + } + seen[norm] = true + out = append(out, norm) + } + if len(out) == 0 { + return nil, "MAC 地址列表为空" + } + // 与已有记录查重。一次 IN 查询,不逐条查——导入动辄上千条。 + existing, err := dvAuthCodesByCodes(sys, pid, out) + if err != nil { + return nil, "校验 MAC 是否已存在失败: " + err.Error() + } + if len(existing) > 0 { + return nil, fmt.Sprintf("有 %d 个 MAC 已经生产过了,例如 %s", len(existing), existing[0]) + } + return out, "" +} diff --git a/apps/services/modules/console/api_idchange.go b/apps/services/modules/console/api_idchange.go new file mode 100644 index 00000000..984a2447 --- /dev/null +++ b/apps/services/modules/console/api_idchange.go @@ -0,0 +1,142 @@ +package console + +import ( + "fmt" + + "yunyan/comm" + "yunyan/lego/sys/postgres" +) + +// ============================ 主键改号(BID / PID) ============================ +// +// 方案商的 BID 与产品的 PID 都是「有含义的十六进制编码」,且都是所在表的主键。 +// 建档时填错过去只能删了重建(删产品会连带丢掉版本记录),所以放开了改号。 +// +// ⚠️ 改主键是不可逆操作,且这两个值会烧进固件 / 决定 license 分表名,所以一律 +// 「有任何引用就不许改」,而不是级联更新: +// - 方案商 BID:被 product.providerid 引用; +// - 产品 PID:被 production_batch.productid、license_ 分表、 +// 以及各应用业务库的 userdevice.productid / product_stat.productid 引用。 +// 后两者在别的库里,console 够不着也保证不了一致性——所以只要本库已经有批次或授权码, +// 就说明货已经生产出去了,此时改号必然造成线上设备对不上,直接拒绝。 +// +// 允许改的窗口就是「刚建档、还没投产」。这也是实际会填错的时候。 + +// checkChipVendorIdChange 校验芯片厂商能否从 oldId 改成 newId(即改 CID)。返回空串表示可以改。 +// +// 与 BID/PID 不同:芯片厂商的下游只有方案商的 vendor 列,且完全在本库内,所以这里【级联更新】 +// 而不是拒绝——CID 是最可能填错、也最需要事后订正的一个(新芯片方案定型前常常只有暂定值)。 +func checkChipVendorIdChange(sys *appConn, oldId, newId uint32) string { + if newId == 0 { + return "CID 必填" + } + if newId == oldId { + return "" + } + if exist, _ := dvChipVendor(sys, newId); exist != nil && exist.Id != 0 { + return fmt.Sprintf("CID 0x%X 已被芯片厂商「%s」占用", newId, exist.Name) + } + return "" +} + +// renameChipVendorId 改芯片厂商的 CID,并级联把方案商的 vendor 引用一起搬过去。 +// 两步必须都做:只改厂商表会让名下方案商指向不存在的厂商,列表里显示成「厂商#N」。 +func renameChipVendorId(oldId, newId uint32) error { + if oldId == newId { + return nil + } + if res := postgres.Exec("UPDATE "+comm.TableChipVendor+" SET id=? WHERE id=?", newId, oldId); res.Error != nil { + return res.Error + } + if res := postgres.Exec("UPDATE "+comm.TableSolutionProvider+" SET vendor=? WHERE vendor=?", newId, oldId); res.Error != nil { + return res.Error + } + return nil +} + +// checkProviderIdChange 校验方案商能否从 oldId 改成 newId。返回空串表示可以改。 +func checkProviderIdChange(sys *appConn, oldId, newId uint32) string { + if newId == 0 { + return "BID 必填" + } + if newId == oldId { + return "" + } + // 新号不能已被占用 + if exist, _ := dvSolutionProvider(sys, newId); exist != nil && exist.Id != 0 { + return fmt.Sprintf("BID 0x%X 已被方案商「%s」占用", newId, exist.Name) + } + // 已有产品挂在旧号下就不许改:product.providerid 是裸整数、没有外键, + // 改了那些产品会指向一个不存在的方案商,而 CID 正是从这里推导的。 + n, err := dvCountProductsByProvider(sys, oldId) + if err != nil { + return "校验产品引用失败: " + err.Error() + } + if n > 0 { + return fmt.Sprintf("该方案商下已有 %d 个产品,不能改 BID。请先把这些产品改挂到别的方案商下", n) + } + return "" +} + +// checkProductIdChange 校验产品能否从 oldId 改成 newId。返回空串表示可以改。 +func checkProductIdChange(sys *appConn, oldId, newId uint32) string { + if newId == 0 { + return "PID 必填" + } + if newId == oldId { + return "" + } + if exist, _ := dvProduct(sys, newId); exist != nil && exist.Id != 0 { + return fmt.Sprintf("PID 0x%X 已被产品「%s」占用", newId, exist.Devicename) + } + // 已投产就不许改:批次台账与授权码分表都按 PID 组织,且设备码已经发出去了。 + var batches int64 + if err := sys.AdminDB().Table(comm.TableProductionBatch).Where("productid=?", oldId).Count(&batches).Error; err != nil { + return "校验生产批次失败: " + err.Error() + } + if batches > 0 { + return fmt.Sprintf("该产品已有 %d 个生产批次,不能改 PID(授权码分表 license_%x 与已发出的设备都按它组织)", batches, oldId) + } + if n, err := countLicenseRows(oldId); err != nil { + return "校验授权码失败: " + err.Error() + } else if n > 0 { + return fmt.Sprintf("该产品已生产 %d 个授权码,不能改 PID", n) + } + return "" +} + +// countLicenseRows 统计某产品名下已生产的设备行数(改 PID 前的前置检查)。 +// 2026-09-04 合表后设备都在 device_mac,按 productid 过滤即可。 +func countLicenseRows(pid uint32) (int64, error) { + var n int64 + if err := postgres.Table(comm.TableDeviceMac).Where("productid=?", pid).Count(&n).Error; err != nil { + return 0, err + } + return n, nil +} + +// renameProviderId 执行方案商改号(先校验后调用)。用裸 UPDATE 改主键: +// gorm 的 Save 是「按主键定位再更新」,改不动主键本身。 +func renameProviderId(oldId, newId uint32) error { + if oldId == newId { + return nil + } + res := postgres.Exec("UPDATE "+comm.TableSolutionProvider+" SET id=? WHERE id=?", newId, oldId) + return res.Error +} + +// renameProductId 执行产品改号(先校验后调用)。产品版本表按 productid 归属, +// 跟着一起搬——版本是产品的附属数据,不搬会变成谁也看不见的孤儿行。 +// 授权码/批次在校验里已确认为空,无需处理。 +func renameProductId(oldId, newId uint32) error { + if oldId == newId { + return nil + } + if res := postgres.Exec("UPDATE "+comm.TableProduct+" SET id=? WHERE id=?", newId, oldId); res.Error != nil { + return res.Error + } + if res := postgres.Exec("UPDATE "+comm.TableProductVersion+" SET productid=? WHERE productid=?", newId, oldId); res.Error != nil { + return res.Error + } + return nil +} diff --git a/apps/services/modules/console/model_device.go b/apps/services/modules/console/model_device.go index 01412c94..595755ee 100644 --- a/apps/services/modules/console/model_device.go +++ b/apps/services/modules/console/model_device.go @@ -1,7 +1,6 @@ package console import ( - "fmt" "strings" "yunyan/comm" @@ -41,6 +40,12 @@ func dvDelSolutionProvider(sys *appConn, id uint32) (err error) { return } +// dvCountProductsByProvider 统计挂在某方案商下的产品数,改 BID / 删方案商前用它挡住产生孤儿产品。 +func dvCountProductsByProvider(sys *appConn, providerId uint32) (n int64, err error) { + err = sys.AdminDB().Table(comm.TableProduct).Where("providerid=?", providerId).Count(&n).Error + return +} + // ---- 品牌商(brand,原厂家)/ 产品(下拉只读)---- func dvBrands(sys *appConn) (models []*pb.DBBrand, err error) { @@ -81,7 +86,9 @@ func dvChannels(sys *appConn, brandid uint32, status int32, keyword string) (mod where := make([]string, 0, 3) args := make([]interface{}, 0, 3) if brandid != 0 { - where = append(where, "brandid=?") + // brandid=0 的是「全局渠道商」(后台新增渠道商已不再选品牌商),任何品牌商都看得到、用得上。 + // 只写 brandid=? 会让品牌商账号除了自家官方渠道之外什么也看不见。 + where = append(where, "(brandid=? OR brandid=0)") args = append(args, brandid) } if status >= 0 { @@ -142,11 +149,8 @@ func genChannelId(sys *appConn) string { // dvAddProduct 新增产品并为其建好 license 分表(license_%x,%x 为回填后的产品 id)。 func dvAddProduct(sys *appConn, product *pb.DBProduct) (err error) { - if err = sys.AdminDB().Insert(comm.TableProduct, product); err != nil { // Insert 后回填 product.Id - return - } - err = sys.AdminDB().CreateTable(fmt.Sprintf("%s_%x", comm.TableLicense, product.Id), &pb.DBAuthCode{}) - return + // 设备统一住全局 device_mac 表(2026-09-04 合表),新增产品**不再建 license 分表**。 + return sys.AdminDB().Insert(comm.TableProduct, product) // Insert 后回填 product.Id } func dvProducts(sys *appConn) (models []*pb.DBProduct, err error) { @@ -221,14 +225,15 @@ func dvAddAuthCodes(sys *appConn, pid uint32, models []*pb.DBAuthCode) (err erro if len(models) == 0 { return nil } - err = sys.AdminDB().Table(fmt.Sprintf("%s_%x", comm.TableLicense, pid)).CreateInBatches(models, 1000).Error + err = sys.AdminDB().Table(comm.TableDeviceMac).CreateInBatches(models, 1000).Error return } // dvSetAuthCodesDisabled 批量设封禁状态(disabled: 0 正常, -1 禁用),返回更新条数。 func dvSetAuthCodesDisabled(sys *appConn, pid uint32, codes []string, disabled int32) (affected int64, err error) { - tx := sys.AdminDB().Table(fmt.Sprintf("%s_%x", comm.TableLicense, pid)). - Where("code IN ?", codes).Update("disabled", disabled) + // ⚠️ 合表后没有分表这层天然隔离,批量更新必须显式带 productid。 + q, a := comm.ScopeToProduct(pid, "code IN ?", []interface{}{codes}) + tx := sys.AdminDB().Table(comm.TableDeviceMac).Where(q, a...).Update("disabled", disabled) affected = tx.RowsAffected err = tx.Error return @@ -236,49 +241,51 @@ func dvSetAuthCodesDisabled(sys *appConn, pid uint32, codes []string, disabled i // dvCountUsedAuthCodesByBatch 统计某生产批次中已被使用(status>0 或 uid 非空)的授权码数。 func dvCountUsedAuthCodesByBatch(sys *appConn, pid uint32, batchno string) (used int64, err error) { - err = sys.AdminDB().Table(fmt.Sprintf("%s_%x", comm.TableLicense, pid)). - Where("batchno=? AND (status>0 OR uid<>'')", batchno).Count(&used).Error + q, a := comm.ScopeToProduct(pid, "batchno=? AND (status>0 OR uid<>'')", []interface{}{batchno}) + err = sys.AdminDB().Table(comm.TableDeviceMac).Where(q, a...).Count(&used).Error return } // dvDelAuthCodesByBatch 删除某生产批次的全部授权码,返回删除条数。 func dvDelAuthCodesByBatch(sys *appConn, pid uint32, batchno string) (affected int64, err error) { - tx := sys.AdminDB().Table(fmt.Sprintf("%s_%x", comm.TableLicense, pid)). - Where("batchno=?", batchno).Delete(&pb.DBAuthCode{}) + // ⚠️ 删除必须带 productid,理由同 dvSetAuthCodesDisabled。 + q, a := comm.ScopeToProduct(pid, "batchno=?", []interface{}{batchno}) + tx := sys.AdminDB().Table(comm.TableDeviceMac).Where(q, a...).Delete(&pb.DBAuthCode{}) affected = tx.RowsAffected err = tx.Error return } -// dvAuthCodesForUid 取某用户在某产品下持有的全部授权码(按 license 分表)。 +// dvAuthCodesForUid 取某用户在某产品下持有的全部设备(pid 传 0 = 不限产品)。 func dvAuthCodesForUid(sys *appConn, pid uint32, uid string) (models []*pb.DBAuthCode, err error) { models = make([]*pb.DBAuthCode, 0) - err = sys.AdminDB().Find(fmt.Sprintf("%s_%x", comm.TableLicense, pid), &models, "uid=?", uid) + q, a := comm.ScopeToProduct(pid, "uid=?", []interface{}{uid}) + err = sys.AdminDB().Find(comm.TableDeviceMac, &models, q, a...) return } -// dvUpdateAuthCodes 批量保存授权码(按 license 分表)。 -func dvUpdateAuthCodes(sys *appConn, pid uint32, models []*pb.DBAuthCode) (err error) { +// dvUpdateAuthCodes 批量保存设备行。按主键(code)保存,主键全局唯一,不需要产品作用域。 +func dvUpdateAuthCodes(sys *appConn, _ uint32, models []*pb.DBAuthCode) (err error) { if len(models) == 0 { return nil } - err = sys.AdminDB().Save(fmt.Sprintf("%s_%x", comm.TableLicense, pid), &models) + err = sys.AdminDB().Save(comm.TableDeviceMac, &models) return } // dvAuthCodesPaged 分页查询授权码(ORDER BY + LIMIT/OFFSET),返回命中总数。 func dvAuthCodesPaged(sys *appConn, pid uint32, query, orderClause string, limit, offset int, args ...interface{}) (models []*pb.DBAuthCode, total int64, err error) { models = make([]*pb.DBAuthCode, 0) - tName := fmt.Sprintf("%s_%x", comm.TableLicense, pid) + query, args = comm.ScopeToProduct(pid, query, args) - tx := sys.AdminDB().Table(tName) + tx := sys.AdminDB().Table(comm.TableDeviceMac) if query != "" { tx = tx.Where(query, args...) } if err = tx.Count(&total).Error; err != nil { return } - tx2 := sys.AdminDB().Table(tName) + tx2 := sys.AdminDB().Table(comm.TableDeviceMac) if query != "" { tx2 = tx2.Where(query, args...) } @@ -297,12 +304,14 @@ func dvAuthCodesPaged(sys *appConn, pid uint32, query, orderClause string, limit func dvAuthCode(sys *appConn, pid uint32, code string) (model *pb.DBAuthCode, err error) { model = &pb.DBAuthCode{} - err = sys.AdminDB().FindOne(fmt.Sprintf("%s_%x", comm.TableLicense, pid), &model, "code=?", code) + // code 是全局主键,pid 只作额外校验(传 0 即不校验)。 + q, a := comm.ScopeToProduct(pid, "code=?", []interface{}{code}) + err = sys.AdminDB().FindOne(comm.TableDeviceMac, &model, q, a...) return } -func dvUpdateAuthCode(sys *appConn, pid uint32, model *pb.DBAuthCode) (err error) { - err = sys.AdminDB().Save(fmt.Sprintf("%s_%x", comm.TableLicense, pid), &model) +func dvUpdateAuthCode(sys *appConn, _ uint32, model *pb.DBAuthCode) (err error) { + err = sys.AdminDB().Save(comm.TableDeviceMac, &model) return } @@ -387,3 +396,19 @@ func dvDelFactoryPublicCode(sys *appConn, code string) (err error) { err = sys.AdminDB().Delete(comm.TableFactoryPublicCode, "code=?", code) return } + +// dvAuthCodesByCodes 查这批 code(=MAC) 已经存在哪些,返回已存在的 code。 +// 导入 MAC 前的查重用:MAC 就是主键,撞了会让整批 INSERT 失败而序号游标已经推进过。 +// +// ⚠️ **全局查重,不按产品**(2026-09-04 合表后):一台设备的 MAC 是它的唯一身份, +// 同一个 MAC 出现在两个产品名下会让「按 MAC 反查设备」出现歧义, +// 也就是绑定时到底算哪个产品说不清。所以跨产品重复也要在导入时挡住。 +// pid 参数保留只为调用方签名不变。 +func dvAuthCodesByCodes(sys *appConn, _ uint32, codes []string) (out []string, err error) { + out = make([]string, 0) + if len(codes) == 0 { + return out, nil + } + err = sys.AdminDB().Table(comm.TableDeviceMac).Where("code IN ?", codes).Pluck("code", &out).Error + return +} diff --git a/apps/services/modules/console/model_settlement.go b/apps/services/modules/console/model_settlement.go index 836fcbc7..48289f68 100644 --- a/apps/services/modules/console/model_settlement.go +++ b/apps/services/modules/console/model_settlement.go @@ -16,12 +16,14 @@ import ( // 月结算数据模型。结算单存 console 主库(与品牌商/渠道商同库), // 结算基数来自【各应用业务库】的订单表 payorder——订单分散在各部署,生成时逐库聚合再汇总。 // -// 分成算法(渠道商从品牌商那份里再切,比例在生成时快照冻结进结算行): +// 分成算法(2026-09-02 起按【生产批次】结算,比例在生成时快照冻结进结算行): // -// brand_gross = base * brand_rate / 10000 品牌商总提成 -// channel_amount = brand_gross * channel_rate / 10000 渠道商从品牌那份里切 -// brand_net = brand_gross - channel_amount 品牌商实得 -// platform_amount = base - brand_gross 平台留存 +// channel_amount = base * channel_rate / 10000 渠道商分成,比例取自该批次 +// platform_amount = base - channel_amount 平台留存 +// +// 为什么按批次出行:比例是一批一谈的(存 production_batch.sharerate), +// 同一家渠道商不同批次比例可以不同,一个渠道商一行装不下两个比例。 +// 品牌商维度已移除——生产链路不再涉及品牌商。 // // 退款口径:结算只统计【当前状态为已支付】的订单。同月内发生的退款会让订单状态离开 // 已支付,重算时自然从基数里消失;跨月退款不回改已确认的历史月份(历史账不可变), @@ -37,19 +39,83 @@ const ( // rateBase 分成比例的分母(万分比:2000 = 20%)。 const rateBase int64 = 10000 -// ensureSettlementTable 在 console 主库建好结算单表(幂等)。 +// ensureSettlementTable 在 console 主库建好结算单表,并把旧维度的表迁移到新主键(幂等)。 +// +// 2026-09-02 结算维度从 (月, 品牌商, 渠道商) 改成 (月, 渠道商, 生产批次),主键跟着变。 +// +// ⚠️ 不能用「DROP 掉再 CreateTable」来换主键:postgres.CreateTable 内部缓存了启动时扫到的 +// 表清单,DROP 之后那份缓存仍认为表存在,于是直接返回、根本不建——结果是表被删了又没建回来, +// 结算页查询直接 42P01。所以这里一律走 ALTER,不删表。 +// +// 有数据的旧表拒绝自动迁移:历史结算单是钱账,行怎么拆到批次上没有唯一解,得人工定。 func ensureSettlementTable() error { - return postgres.CreateTable(comm.TableSettlementMonth, &pb.DBSettlementMonth{}) + if err := postgres.CreateTable(comm.TableSettlementMonth, &pb.DBSettlementMonth{}); err != nil { + return err + } + var brandCol *string + if err := postgres.Raw( + "SELECT column_name::text FROM information_schema.columns WHERE table_name=? AND column_name='brandid'", + comm.TableSettlementMonth).Scan(&brandCol).Error; err != nil { + return err + } + if brandCol == nil || *brandCol == "" { + return nil // 已是新维度 + } + var n int64 + if err := postgres.Table(comm.TableSettlementMonth).Count(&n).Error; err != nil { + return err + } + if n > 0 { + return fmt.Errorf("结算单表 %s 仍是旧维度(带 brandid)且有 %d 行数据,无法自动迁移到「按批次结算」的新主键;"+ + "请人工备份后清空该表再启动", comm.TableSettlementMonth, n) + } + t := comm.TableSettlementMonth + for _, ddl := range []string{ + "ALTER TABLE " + t + " ADD COLUMN IF NOT EXISTS batchno varchar(16) DEFAULT ''", + "ALTER TABLE " + t + " ADD COLUMN IF NOT EXISTS productid bigint DEFAULT 0", + // 换主键:先卸掉旧的复合主键,再按新维度加回去。 + "ALTER TABLE " + t + " DROP CONSTRAINT IF EXISTS " + t + "_pkey", + "ALTER TABLE " + t + " ALTER COLUMN batchno SET NOT NULL", + "ALTER TABLE " + t + " ADD PRIMARY KEY (period, channelid, batchno)", + // 旧维度的列删掉:留着会让人以为还能按品牌商对账。 + "ALTER TABLE " + t + " DROP COLUMN IF EXISTS brandid", + "ALTER TABLE " + t + " DROP COLUMN IF EXISTS brand_rate", + "ALTER TABLE " + t + " DROP COLUMN IF EXISTS brand_gross", + "ALTER TABLE " + t + " DROP COLUMN IF EXISTS brand_net", + } { + if res := postgres.Exec(ddl); res.Error != nil { + return res.Error + } + } + return nil } -// settleAgg 一个 (品牌商, 渠道商) 归属在某月的订单聚合结果。 +// settleAgg 一台来源设备在某月的订单聚合结果(从业务库按设备维度聚合出来的中间态)。 +// +// 为什么聚到「设备」这么细:结算要按生产批次出行,而订单上只有来源设备快照 +// (src_license / src_mac / src_productid),批次得拿设备去 license 分表反查。 +// 业务库与 console 主库不是同一个库,join 不了,只能先按设备聚合再在 Go 里映射。 +// 基数 = 当月有下单的设备数,通常远小于订单数。 type settleAgg struct { - Brandid uint32 `gorm:"column:brandid"` Channelid string `gorm:"column:channelid"` + Productid uint32 `gorm:"column:productid"` + License string `gorm:"column:license"` + Mac string `gorm:"column:mac"` OrderCount int64 `gorm:"column:order_count"` BaseAmount int64 `gorm:"column:base_amount"` } +// deviceKey 这台来源设备在 license 分表里的主键候选:优先 license,为空退 MAC。 +// +// ⚠️ 两者都可能为空(用户从未绑过设备就下单),那种订单归不到批次, +// 落 batchno=” 的行、比例 0、全归平台。 +func (a *settleAgg) deviceKey() string { + if k := strings.TrimSpace(a.License); k != "" { + return k + } + return strings.TrimSpace(a.Mac) +} + // periodRange 把结算月 YYYYMM 换算成 [起, 止) 的 unix 秒区间(按统计时区,与统计口径一致)。 func periodRange(period uint32) (start, end int64, err error) { if period < 200001 || period > 999912 { @@ -71,15 +137,16 @@ func periodOf(t time.Time) uint32 { return uint32(tt.Year()*100 + int(tt.Month())) } -// aggOrdersOnDB 在一个应用业务库上,按 (来源品牌商, 来源渠道商) 聚合该月已支付订单。 +// aggOrdersOnDB 在一个应用业务库上,按【来源设备】聚合该月已支付订单。 // 归属取订单自己的来源设备快照(src_*):下单时冻结,事后换绑/换设备不影响历史账。 // 未迁移出 src_* 列的老库会查询报错,由调用方按「跳过该库并记录」处理。 func aggOrdersOnDB(db mysql.ISys, start, end int64) ([]*settleAgg, error) { rows := make([]*settleAgg, 0) err := db.Table(comm.TablePayOrder). - Select("src_brandid AS brandid, src_channelid AS channelid, COUNT(*) AS order_count, COALESCE(SUM(amount),0) AS base_amount"). + Select("src_channelid AS channelid, src_productid AS productid, src_license AS license, src_mac AS mac, "+ + "COUNT(*) AS order_count, COALESCE(SUM(amount),0) AS base_amount"). Where("status = ? AND pay_time >= ? AND pay_time < ?", int32(pb.PayOrderStatus_PAY_ORDER_PAID), start, end). - Group("src_brandid, src_channelid"). + Group("src_channelid, src_productid, src_license, src_mac"). Scan(&rows).Error if err != nil { return nil, err @@ -87,22 +154,86 @@ func aggOrdersOnDB(db mysql.ISys, start, end int64) ([]*settleAgg, error) { return rows, nil } -// computeSettlement 按分成比例算出一行结算的各项金额(分,整数运算,末位截断)。 -func computeSettlement(base int64, brandRate, channelRate int32) (brandGross, channelAmount, brandNet, platform int64) { - if brandRate < 0 { - brandRate = 0 +// resolveBatchOfDevices 把「产品 → 一批设备键」映射成「设备键 → 批次号」。 +// +// 设备键可能是 license 串也可能是 MAC,两者分别对应 license_ 表的 code 与 devicemac 列, +// 所以一个产品查两次、合并成一张表。分表不存在(产品从未投产)时跳过。 +// +// ⚠️ 一次 IN 查询而不是逐个查:当月有下单的设备可能上千台,逐条查会把生成结算单拖成分钟级。 +func resolveBatchOfDevices(byProduct map[uint32][]string) map[string]string { + out := make(map[string]string) + for pid, keys := range byProduct { + if pid == 0 || len(keys) == 0 { + continue + } + type row struct { + Code string `gorm:"column:code"` + Devicemac string `gorm:"column:devicemac"` + Batchno string `gorm:"column:batchno"` + } + rows := make([]row, 0) + // 2026-09-04 合表:设备都在 device_mac,按 productid 收窄到本产品。 + // 产品从未投产时查出 0 行,与原来「分表不存在就跳过」等价。 + if err := postgres.Table(comm.TableDeviceMac). + Select("code, devicemac, batchno"). + Where("productid=? AND (code IN ? OR devicemac IN ?)", pid, keys, keys). + Scan(&rows).Error; err != nil { + continue + } + for _, r := range rows { + if r.Batchno == "" { + continue + } + // code 与 devicemac 都登记进去:调用方拿到的设备键两种形态都能命中。 + if r.Code != "" { + out[batchMapKey(pid, r.Code)] = r.Batchno + } + if r.Devicemac != "" { + out[batchMapKey(pid, r.Devicemac)] = r.Batchno + } + } + } + return out +} + +// batchMapKey 设备键带上产品:调用方本来就按产品分组查,键里带 pid 保持一一对应。 +// (合表后 MAC 已全局唯一,但结算按 产品×渠道 出账,键里保留 pid 更贴近账目口径。) +func batchMapKey(pid uint32, deviceKey string) string { + return fmt.Sprintf("%d/%s", pid, deviceKey) +} + +// batchRates 批量取这些批次的分成比例与所属产品。查不到的批次(已撤销)不在返回里, +// 调用方按比例 0 处理——货已经卖出去了,批次台账没了不能反过来把订单也吞掉。 +func batchRates(batchnos []string) map[string]*pb.DBProductionBatch { + out := make(map[string]*pb.DBProductionBatch) + if len(batchnos) == 0 { + return out } + rows := make([]*pb.DBProductionBatch, 0) + if err := postgres.Table(comm.TableProductionBatch).Where("batchno IN ?", batchnos).Find(&rows).Error; err != nil { + return out + } + for _, r := range rows { + out[r.Batchno] = r + } + return out +} + +// computeSettlement 按渠道分成比例算出一行结算的金额(分,整数运算,末位截断)。 +// 比例来自该批次的 sharerate 快照;批次归不到或比例为 0 时全归平台。 +func computeSettlement(base int64, channelRate int32) (channelAmount, platform int64) { if channelRate < 0 { channelRate = 0 } - brandGross = base * int64(brandRate) / rateBase - channelAmount = brandGross * int64(channelRate) / rateBase - brandNet = brandGross - channelAmount - platform = base - brandGross + if int64(channelRate) > rateBase { + channelRate = int32(rateBase) // 比例上限 100%,防脏数据把平台留存算成负数 + } + channelAmount = base * int64(channelRate) / rateBase + platform = base - channelAmount return } -// upsertSettlements 批量覆盖写入结算行(按复合主键 period+brandid+channelid)。 +// upsertSettlements 批量覆盖写入结算行(按复合主键 period+channelid+batchno)。 func upsertSettlements(rows []*pb.DBSettlementMonth) error { if len(rows) == 0 { return nil @@ -112,39 +243,34 @@ func upsertSettlements(rows []*pb.DBSettlementMonth) error { CreateInBatches(rows, 200).Error } -// listSettlements 按条件列结算单。brandid=0 不限;channelid 用指针区分「不限」与「空串(无渠道归属)」; -// status<0 不限。 -func listSettlements(period uint32, brandid uint32, channelid *string, status int32, brandScope []uint32) (models []*pb.DBSettlementMonth, err error) { +// listSettlements 按条件列结算单。channelid 用指针区分「不限」与「空串(无渠道归属)」; +// batchno 空串=不限;status<0 不限。 +func listSettlements(period uint32, channelid *string, batchno string, status int32) (models []*pb.DBSettlementMonth, err error) { models = make([]*pb.DBSettlementMonth, 0) - conds := make([]string, 0, 5) - args := make([]interface{}, 0, 5) + conds := make([]string, 0, 4) + args := make([]interface{}, 0, 4) if period > 0 { conds = append(conds, "period=?") args = append(args, period) } - if brandid != 0 { - conds = append(conds, "brandid=?") - args = append(args, brandid) - } if channelid != nil { conds = append(conds, "channelid=?") args = append(args, *channelid) } + if b := strings.TrimSpace(batchno); b != "" { + conds = append(conds, "batchno=?") + args = append(args, b) + } if status >= 0 { conds = append(conds, "status=?") args = append(args, status) } - // brandScope 非 nil 时限定到这些品牌商(品牌商账号收敛用);空切片代表「无任何可见品牌商」。 - if brandScope != nil { - if len(brandScope) == 0 { - return models, nil - } - conds = append(conds, "brandid IN ?") - args = append(args, brandScope) + if len(conds) == 0 { + conds = append(conds, "1=1") } err = postgres.Table(comm.TableSettlementMonth). Where(strings.Join(conds, " AND "), args...). - Order("period DESC, brandid ASC, channelid ASC"). + Order("period DESC, channelid ASC, batchno ASC"). Find(&models).Error return } @@ -157,9 +283,9 @@ func latestSettlementPeriod() (period uint32) { } // getSettlement 按复合主键取一行结算单。 -func getSettlement(period uint32, brandid uint32, channelid string) (model *pb.DBSettlementMonth, err error) { +func getSettlement(period uint32, channelid, batchno string) (model *pb.DBSettlementMonth, err error) { model = &pb.DBSettlementMonth{} - err = postgres.FindOne(comm.TableSettlementMonth, model, "period=? AND brandid=? AND channelid=?", period, brandid, channelid) + err = postgres.FindOne(comm.TableSettlementMonth, model, "period=? AND channelid=? AND batchno=?", period, channelid, batchno) return } diff --git a/apps/services/modules/console/registry.go b/apps/services/modules/console/registry.go index 1a73c02e..4a3035b6 100644 --- a/apps/services/modules/console/registry.go +++ b/apps/services/modules/console/registry.go @@ -2,6 +2,8 @@ package console import ( "fmt" + "sort" + "strconv" "strings" "sync" @@ -153,6 +155,27 @@ func openDriver(dsn string) (mysql.ISys, error) { // 启动时跑一次:设备库内容已集中到 console 自己的库,不再按应用建。 func ensureDeviceTables() error { adb := postgres.GetSys() + // 芯片厂商:方案商的 vendor 列指向它,故先于 solution_provider 建好。 + if err := adb.CreateTable(comm.TableChipVendor, &ChipVendor{}); err != nil { + return err + } + // 补种原 SolutionVendor 枚举的 4 个取值(id 与枚举数值一一对应,存量方案商靠它归组)。 + // 幂等:已存在的行不覆盖,运营改过的名字不会被启动时刷回去。 + // 一次性迁移:上一版把 CID 做成独立列(id 自增 + cid 另存),本版改成「id 即 CID」。 + // 已部署过上一版的库要把带 cid 的行搬到以 CID 为主键,并级联改方案商的 vendor 引用。 + // 迁移完会删掉 cid 列,之后本调用直接跳过。必须先于补种执行。 + if err := migrateChipVendorCidToId(); err != nil { + return err + } + if err := ensureChipVendorSeed(); err != nil { + return err + } + // id 即 CID、由人工指定,不再自增,故不设自增下限。 + // 代工厂/组装厂档案(与品牌商并列的独立实体)。 + // ⚠️ 表名是 oem_factory,不是库里那张无人引用的遗留 factory 表,别混。 + if err := adb.CreateTable(comm.TableOemFactory, &OemFactory{}); err != nil { + return err + } if err := adb.CreateTable(comm.TableSolutionProvider, &pb.DBSolutionProvider{}); err != nil { return err } @@ -176,9 +199,11 @@ func ensureDeviceTables() error { if err := adb.CreateTable(comm.TableChannel, &pb.DBChannel{}); err != nil { return err } - // official/sharerate 是后加字段(品牌官方渠道标记、渠道分成比例):CreateTable 对已存在的表 + // official/sharerate 是后加字段(品牌官方渠道标记、[遗留]渠道商级分成比例):CreateTable 对已存在的表 // 跳过 AutoMigrate,故显式补列。历史行 official=false(存量品牌商没有官方渠道,需要的话在 - // 后台手工建一条)、sharerate=0(不分成)。 + // 后台手工建一条)、sharerate=0。 + // ⚠️ sharerate 已停止在后台维护,分成比例改按生产批次配(production_batch.sharerate); + // 列不删是因为月结算暂时还读它,且删列会丢掉存量比例。 for _, ddl := range []string{ "ALTER TABLE " + comm.TableChannel + " ADD COLUMN IF NOT EXISTS official boolean DEFAULT false", "ALTER TABLE " + comm.TableChannel + " ADD COLUMN IF NOT EXISTS sharerate integer DEFAULT 0", @@ -190,12 +215,33 @@ func ensureDeviceTables() error { if err := adb.CreateTable(comm.TableProductionBatch, &pb.DBProductionBatch{}); err != nil { return err } + // factoryid / sharerate 是后加字段(这批货由哪家代工厂生产、给渠道商的分成比例万分比): + // CreateTable 对已存在的表跳过 AutoMigrate,故显式补列。 + // 存量批次 factoryid=0 = 未指定(建这批时还没有工厂档案);sharerate=0 = 不分成 + // (这些批次是在「比例挂在渠道商上」的年代生产的,比例得回 channel.sharerate 看)。 + for _, ddl := range []string{ + "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS factoryid bigint DEFAULT 0", + "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS sharerate integer DEFAULT 0", + // 绑定赠送三项:存量批次全 0,绑定时会回退到产品上的旧字段,故不需要 backfill。 + "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardvip bigint DEFAULT 0", + "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardtranslate bigint DEFAULT 0", + "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardmeeting bigint DEFAULT 0", + } { + if res := adb.Exec(ddl); res.Error != nil { + return res.Error + } + } if err := adb.CreateTable(comm.TableProduct, &pb.DBProduct{}); err != nil { return err } _ = adb.AutoIncrementStart(comm.TableProduct, "id", 0xB001) // appnames 是后加字段(产品归属的应用名 CSV):CreateTable 对已存在的表跳过 AutoMigrate,故显式补列。 // 历史行留空,语义为「未绑定应用」——此时品牌商账号的应用下拉退化为不限制(见 brandAppNames)。 + // oemfactoryid 是后加字段(这款产品默认由哪家代工厂生产,oem_factory.id)。 + // ⚠️ 不叫 factoryid:DBProduct 里那个 factoryid 是老客户端兼容字段(gorm:"-",值同 brandid),同名会打架。 + if res := adb.Exec("ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS oemfactoryid bigint DEFAULT 0"); res.Error != nil { + return res.Error + } if res := adb.Exec("ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS appnames varchar(255) DEFAULT ''"); res.Error != nil { return res.Error } @@ -215,32 +261,142 @@ func ensureDeviceTables() error { if err := adb.CreateTable(comm.TableProductVersion, &pb.DBProductVersion{}); err != nil { return err } - if err := adb.CreateTable(comm.TableLicense, &pb.DBAuthCode{}); err != nil { + if err := adb.CreateTable(comm.TableFactoryPublicCode, &pb.DBFactoryPublicCode{}); err != nil { return err } - if err := adb.CreateTable(comm.TableFactoryPublicCode, &pb.DBFactoryPublicCode{}); err != nil { + // 设备 MAC 表:**全局一张**,不再按产品切分表。 + // 设备的身份是它自己的 MAC(主键 code = devicemac),productid 只是归属列。 + // 分表时代的教训:pid 既是查询键又要客户端猜,猜错就报「这台 MAC 没登记在该产品下」, + // 指向数据缺失、实际是 pid 错了,极难排查。详见 migrateLicenseToDeviceMac。 + if err := adb.CreateTable(comm.TableDeviceMac, &pb.DBAuthCode{}); err != nil { return err } - // 按 product 列表建 license_%x 分表。 var products []*pb.DBProduct if err := adb.Find(comm.TableProduct, &products, ""); err != nil && err != postgres.ErrNoDocuments { return err } - for _, p := range products { - if err := adb.CreateTable(fmt.Sprintf("%s_%x", comm.TableLicense, p.Id), &pb.DBAuthCode{}); err != nil { - return err + // 渠道商维度是后加字段:CreateTable 对已存在的表跳过 AutoMigrate,故批次台账与设备表 + // 都要显式补列(幂等)。历史行 channelid 留空,语义为「未分配渠道」。 + for _, t := range []string{comm.TableProductionBatch, comm.TableDeviceMac} { + if res := adb.Exec("ALTER TABLE " + t + " ADD COLUMN IF NOT EXISTS channelid varchar(16) DEFAULT ''"); res.Error != nil { + return res.Error } } - // 渠道商维度是后加字段:CreateTable 对已存在的表跳过 AutoMigrate,故批次台账与全部 license 分表 - // 都要显式补列(幂等)。历史行 channelid 留空,语义为「未分配渠道」。 - tables := []string{comm.TableProductionBatch, comm.TableLicense} - for _, p := range products { - tables = append(tables, fmt.Sprintf("%s_%x", comm.TableLicense, p.Id)) + return migrateLicenseToDeviceMac(products) +} + +// migrateLicenseToDeviceMac 把按产品切的 license_ 分表**合并**进全局 +// device_mac 表。幂等,每次启动跑,跑完即空转。 +// +// 为什么要合:设备的身份是它自己的 MAC,本来就全局唯一。分表把 pid 变成了查询键, +// 于是客户端得先猜出 pid 才能校验一台设备——猜错就报「这台 MAC 没登记在该产品下」, +// 提示词指向数据缺失、实际是 pid 错了。2026-09-04 就踩了这个:一台已正确导入的耳机 +// 因为所属产品的 devicetype 配成了 2(BLE双端)而不是 1(经典蓝牙),被客户端的 +// 「只在 devicetype==1 的产品里挑」过滤掉,兜底退回了另一个产品的 pid, +// 服务端拿着错 pid 去错的分表里查,自然查不到。 +// +// 迁移步骤(每张 license_* 表,含没有对应产品的孤儿表——那里面也是真实发出去的设备): +// ① code := devicemac —— 老数据里 code 是 20 字符的 license 串、devicemac 另存, +// 两个都指向同一台设备。授权码那套已经删了(utils/license 包不复存在)。 +// ② 按两表**列名交集**做 INSERT ... SELECT ... ON CONFLICT DO NOTHING。 +// 不能 SELECT *:各分表建于不同时期,列不一致(有的多 factoryid/probatch 两列)。 +// ③ productid 为空/0 的行,用表名里的 pid 补上——它是绑定礼、埋点、结算的归属依据。 +// +// ⚠️ **搬完不删旧表**。这是不可逆数据(已经发到用户手上的设备),留着旧表是出问题时 +// 唯一的回退依据。确认新表跑稳之后再由人手工 DROP。 +func migrateLicenseToDeviceMac(products []*pb.DBProduct) error { + adb := postgres.GetSys() + + // device_mac 的列集合,用于和各源表求交集。 + dstCols, err := tableColumns(adb, comm.TableDeviceMac) + if err != nil { + return err } - for _, t := range tables { - if res := adb.Exec("ALTER TABLE " + t + " ADD COLUMN IF NOT EXISTS channelid varchar(16) DEFAULT ''"); res.Error != nil { + if len(dstCols) == 0 { + return fmt.Errorf("console.registry: %s 建表失败或无列", comm.TableDeviceMac) + } + + var names []string + if err := adb.Raw( + "SELECT tablename FROM pg_tables WHERE schemaname = current_schema() AND tablename LIKE ?", + comm.TableLicense+`\_%`).Scan(&names).Error; err != nil { + return err + } + for _, t := range names { + // 表名形如 license_b023,后缀是 pid 的十六进制。解不出就当 0, + // 那种表的行必须自带 productid,否则归属列会是空——只告警不中断。 + pid := pidFromLicenseTable(t) + + // ① 存量 code 改写成 MAC。WHERE 带 code<>devicemac,跑过一遍后就是 0 行。 + if res := adb.Exec("UPDATE " + t + " SET code = devicemac WHERE devicemac <> '' AND code <> devicemac"); res.Error != nil { return res.Error + } else if res.RowsAffected > 0 { + log.Infof("console.registry: %s 有 %d 条存量授权码已改写成 MAC", t, res.RowsAffected) + } + + srcCols, err := tableColumns(adb, t) + if err != nil { + return err + } + // ② 列名交集。productid 单独处理(要用表名兜底),其余按名字直搬。 + cols := make([]string, 0, len(srcCols)) + for c := range srcCols { + if c == "productid" { + continue // 单独处理,见 ③ + } + if dstCols[c] { + cols = append(cols, c) + } + } + if len(cols) == 0 { + continue + } + sort.Strings(cols) // map 迭代无序,排一下让生成的 SQL 与日志可复现 + // ③ productid:源表有这一列就用它、为空则回落到表名里的 pid;没这一列就直接用表名。 + srcPid := fmt.Sprintf("%d", pid) + if srcCols["productid"] { + srcPid = fmt.Sprintf("COALESCE(NULLIF(productid, 0), %d)", pid) + } + sql := fmt.Sprintf( + "INSERT INTO %s (productid, %s) SELECT %s, %s FROM %s WHERE code <> '' ON CONFLICT (code) DO NOTHING", + comm.TableDeviceMac, strings.Join(cols, ", "), srcPid, strings.Join(cols, ", "), t) + res := adb.Exec(sql) + if res.Error != nil { + return fmt.Errorf("console.registry: 迁移 %s -> %s 失败: %w", t, comm.TableDeviceMac, res.Error) + } + if res.RowsAffected > 0 { + log.Infof("console.registry: 已把 %s 的 %d 行合并进 %s(旧表保留)", t, res.RowsAffected, comm.TableDeviceMac) } } return nil } + +// tableColumns 取一张表的列名集合。表不存在返回空 map(不报错)。 +func tableColumns(adb postgres.ISys, table string) (map[string]bool, error) { + var cols []string + if err := adb.Raw( + "SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?", + table).Scan(&cols).Error; err != nil { + return nil, err + } + out := make(map[string]bool, len(cols)) + for _, c := range cols { + out[c] = true + } + return out, nil +} + +// pidFromLicenseTable 从 license_ 解出 pid。解不出返回 0。 +func pidFromLicenseTable(table string) uint32 { + suffix := strings.TrimPrefix(table, comm.TableLicense+"_") + if suffix == table || suffix == "" { + return 0 + } + n, err := strconv.ParseUint(suffix, 16, 32) + if err != nil { + log.Warn("console.registry: 授权码表名解不出 pid,归属列将依赖表内数据", + log.Field{Key: "table", Value: table}) + return 0 + } + return uint32(n) +} diff --git a/apps/services/modules/user/api_binddevice.go b/apps/services/modules/user/api_binddevice.go index c720f2ad..972de5d4 100644 --- a/apps/services/modules/user/api_binddevice.go +++ b/apps/services/modules/user/api_binddevice.go @@ -9,7 +9,6 @@ import ( "yunyan/comm" "yunyan/lego/sys/log" "yunyan/pb" - "yunyan/utils/license" ) // @Summary 绑定耳机设备 @@ -29,7 +28,6 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic model *pb.DBAuthCode codestate int32 divice *pb.DBUserDivice - pid uint16 Isgiveaway bool err error ) @@ -40,14 +38,8 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic } return } - if product, err = this.module.cache.GetProduct(req.Pid); err != nil { //未找到授权码 - errdata = &pb.ErrorData{ - Code: pb.ErrorCode_AuthorizeNoCanUse, - Message: err.Error(), - } - return - } - + // ⚠️ 产品**不再**用 req.Pid 取——pid 现在是从设备行的 productid 列读出来的结果, + // 见下面「定位设备行」那段。req.Pid 仅为协议兼容保留,服务端不再采信。 if divices, err = this.module.model.getDevices(session.GetUserId()); err != nil { //未找到授权码 errdata = &pb.ErrorData{ Code: pb.ErrorCode_AuthorizeNoCanUse, @@ -56,11 +48,18 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic return } for _, v := range divices { - if v.Devicename == req.Devicename && v.Devicemac == req.Devicemac && v.License == req.Code { - // errdata = &pb.ErrorData{ - // Code: pb.ErrorCode_DeviceBinded, - // Message: "设备已绑定", - // } + if alreadyBound(v, req) { + // 名字可能变了(Android 给的是系统缓存的配对名,固件也改过名: + // DEEPVOICE → Echo-one),顺手刷新,但**不新建一行**。 + if req.Devicename != "" && v.Devicename != req.Devicename { + v.Devicename = req.Devicename + if err = this.module.model.saveDevice(v); err != nil { + this.module.Warn("BindDevice: 刷新设备名失败", + log.Field{Key: "deviceId", Value: v.Id}, + log.Field{Key: "err", Value: err.Error()}) + err = nil + } + } divice = v resp = &pb.UserBindDeviceResp{ Device: divice, @@ -68,32 +67,59 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic return } } - if product.Devicetype == pb.DeviceType_Classic_bluetooth_Headset { - if model, err = this.module.model.getFactoryDeviceformac(product.Id, req.Devicemac); err != nil { //设备已被注册 - errdata = &pb.ErrorData{ - Code: pb.ErrorCode_AuthorizeNoCanUse, - Message: err.Error(), + // ---- 定位设备行 ---- + // + // device_mac 是**全局一张**表(2026-09-04 由 license_ 分表合并而来): + // 主键 code 与 devicemac 本来就全局唯一,定位一台设备**不需要 pid**。 + // + // 以前这里按 product.Devicetype 分两支,而 product 又是拿客户端传的 req.Pid 取的: + // - 经典蓝牙耳机 → 按 (客户端猜的 pid, MAC) 去对应分表查 + // - 其它 → 从 code 反解 pid,解不出回退写死的 45058 + // 客户端猜 pid 靠「产品名 ↔ 设备名互相包含」,猜不中就退回第一个耳机产品。 + // 2026-09-04 就栽在这:一台已正确导入的耳机,因所属产品的 devicetype 被配成 2 + // (BLE双端)而非 1(经典蓝牙),被客户端过滤掉、退回了别的产品的 pid, + // 服务端拿着错 pid 去错的分表查,报「这台 MAC 没登记在该产品下」—— + // 提示指向数据缺失,实际数据好好的,只是 pid 错了。 + // + // 现在:code 与 MAC 都试一遍,命中即可,pid 从命中行里读。 + // 先 code 后 MAC——设备码类设备(杰理等)只有 code,经典蓝牙耳机只有 MAC,各命中一条。 + located := false + if c := strings.TrimSpace(req.Code); c != "" { + if model, err = this.module.model.getFactoryDevice(c); err == nil { + located = true + } + } + if !located { + if mc := strings.TrimSpace(req.Devicemac); mc != "" { + if model, err = this.module.model.getFactoryDeviceformac(mc); err == nil { + located = true } - return } - } else { - pid, err = license.ProductID(req.Code) - if err != nil { //老得一批License 由于数据和新版对因不上 会产生错误 - // errdata = &pb.ErrorData{ - // Code: pb.ErrorCode_AuthorizeIllegal, - // Message: err.Error(), - // } - err = nil - pid = uint16(45058) - // return + } + if !located { + this.module.Warn("BindDevice: 设备未登记", + log.Field{Key: "uid", Value: session.GetUserId()}, + log.Field{Key: "code", Value: req.Code}, + log.Field{Key: "devicemac", Value: req.Devicemac}, + log.Field{Key: "devicename", Value: req.Devicename}) + errdata = &pb.ErrorData{ + Code: pb.ErrorCode_AuthorizeNoCanUse, + Message: "设备未登记", } - if model, err = this.module.model.getFactoryDevice(uint32(pid), req.Code); err != nil { //设备已被注册 - errdata = &pb.ErrorData{ - Code: pb.ErrorCode_AuthorizeNoCanUse, - Message: err.Error(), - } - return + return + } + // 产品归属来自设备行本身。取不到说明产品被删了、或 productid 是脏数据—— + // 这时绑定没法继续:下面的渠道归属、绑定礼、埋点全要靠 product。 + if product, err = this.module.cache.GetProduct(int32(model.Productid)); err != nil { + this.module.Warn("BindDevice: 设备行的产品取不到", + log.Field{Key: "code", Value: model.Code}, + log.Field{Key: "productid", Value: model.Productid}, + log.Field{Key: "err", Value: err.Error()}) + errdata = &pb.ErrorData{ + Code: pb.ErrorCode_AuthorizeNoCanUse, + Message: err.Error(), } + return } if model.Disabled != 0 { //设备码已被后台禁用 直接拒绝绑定 errdata = &pb.ErrorData{ @@ -106,7 +132,12 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic if model.Status != 0 && model.Uid != session.GetUserId() { //授权码已被使用 自动解绑 if divices, err = this.module.model.getDevices(model.Uid); err == nil { for _, v := range divices { - if v.License == model.Code { + // ⚠️ 不能只比 License:经典蓝牙耳机绑定时客户端 code 传空, + // 存量 userdevice 行的 license 列就是空串,永远等不于 model.Code, + // 于是原用户那条绑定删不掉——同一台耳机换个账号登录, + // 旧账号的设备列表里会一直留着一台它其实已经不拥有的耳机。 + // 设备码类设备的身份是 MAC,按 MAC 兜底认。 + if sameUserDevice(v, model) { this.module.model.delDevice(v.Id) // 删除设备 // 接管即原用户被动解绑:按原设备自身的产品/渠道冲正 -1, // 否则同一台设备每换一次账号就多计一次绑定。 @@ -156,19 +187,25 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic Devicetype: product.Devicetype, Devicename: req.Devicename, Devicemac: req.Devicemac, - License: req.Code, - Cmei: cmei, - Version: req.Version, + // 存授权码行的主键(model.Code),不是客户端传上来的 req.Code: + // 经典蓝牙耳机走 MAC 反查,客户端 code 传的是空串,照抄会让这一列永远为空, + // 解绑与接管都要靠它定位授权码行(见 sameUserDevice / api_unbinddevice.go)。 + License: model.Code, + Cmei: cmei, + Version: req.Version, } model.Status = 1 //已绑定 model.Uid = session.GetUserId() - model.Devicemac = req.Devicemac + // ⚠️ **不拿客户端上报的 MAC 覆盖 model.Devicemac**。设备的 MAC 是出厂就定死的身份, + // 2026-09-02 起分表主键 code 就是它(授权码字符串已整体移除)。 + // 客户端送的写法可能不规范(上面的反查做了规范化兜底,仍能查到这一行), + // 一旦照它覆盖,code 与 devicemac 两列就对不上,这台设备再也查不回来。 // 首次激活(此前 status==0)记录激活时间:供按统计日聚合设备激活/绑定数。 // 历史设备漏记 usedtime 是失误,从此补上;仅在 0→1 首激时写,换绑/接管不覆盖原始激活日。 if codestate == 0 { model.Usedtime = time.Now().Unix() } - if err = this.module.model.saveFactoryDevice(model.Productid, model); err != nil { + if err = this.module.model.saveFactoryDevice(model); err != nil { errdata = &pb.ErrorData{ Code: pb.ErrorCode_DBError, Message: err.Error(), @@ -200,26 +237,47 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic log.Field{Key: "Isgiveaway", Value: user.Isgiveaway}, log.Field{Key: "Iscanbindagain", Value: product.Iscanbindagain}, log.Field{Key: "codestate", Value: codestate}) - if !user.Isgiveaway || codestate == 0 { + // 设备权益只发给**第一个绑定这台设备的人**,判据是 codestate==0(这台设备 + // 此前从未被绑过)。 + // + // ⚠️ 原条件是 `!user.Isgiveaway || codestate == 0`,多出来的 + // `!user.Isgiveaway` 那一支是个口子:只要这个**用户**没领过绑定礼,哪怕设备 + // 是二手的(codestate!=0,已经被别人绑过、权益也已经发出去过一次),照样再发 + // 一整份。等于一台设备转手几次就能送出去几份 VIP/翻译/会议时长。 + // + // codestate 是可靠判据:解绑处 `model.Status = 0` 是**注释掉的** + // (api_unbinddevice.go,只清 Uid),status 一旦 0→1 就回不去, + // 所以「绑了解、解了绑」刷不出第二份。 + // + // `user.Isgiveaway = true` 仍然要写:厂家公码(api_bindauthcode.go)拿它当 + // 防重领标记,见 model_user.go 的说明。把它一起删掉会让绑过设备的用户 + // 还能再领一次公码。 + if codestate == 0 { Isgiveaway = true user.Isgiveaway = true + // 绑定赠送:优先按【设备所属生产批次】发(后台「MAC生成管理 → 生成MAC」里一批一配), + // 批次没配(三项全 0 / 取不到批次 / 存量码没有批次号)才回退到产品档案上的旧字段。 + vipDays, transMin, meetMin := this.resolveBindRewards(model, product) var addVipDay, addTrade, addMeet int64 - if product.Bindrewardvpitime > 0 { - addVipDay = 90 + if vipDays > 0 { + // ⚠️ 这里以前写死 90 天,配多少都发 90——product.Bindrewardvpitime 实际只当了开关用。 + // 现在按配置的天数发,批次里填几天就是几天。 + addVipDay = int64(vipDays) + addSec := addVipDay * 24 * 60 * 60 if user.Vipexptime == 0 || user.Vipexptime < time.Now().Unix() { //重置 - user.Vipexptime = time.Now().Unix() + 90*24*60*60 + user.Vipexptime = time.Now().Unix() + addSec } else { //累加 - user.Vipexptime = user.Vipexptime + 90*24*60*60 + user.Vipexptime = user.Vipexptime + addSec } } // translate/meeting 配置为分钟,积分按秒存储 - if product.Bindrewardtranslate > 0 { - addTrade = int64(product.Bindrewardtranslate) * 60 + if transMin > 0 { + addTrade = int64(transMin) * 60 user.Tradeintegral += addTrade user.Tradetotalintegral += addTrade } - if product.Bindrewardmeeting > 0 { - addMeet = int64(product.Bindrewardmeeting) * 60 + if meetMin > 0 { + addMeet = int64(meetMin) * 60 user.Meetintegral += addMeet user.Meettotalintegral += addMeet } @@ -280,3 +338,80 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic } return } + +// resolveBindRewards 决定这台设备绑定时送什么:VIP天数 / 翻译分钟 / 会议分钟。 +// +// 取值顺序: +// 1. 设备所属生产批次的 rewardvip/rewardtranslate/rewardmeeting——同一款产品不同批次 +// (不同渠道、不同活动)送的东西经常不一样,配置点在后台「生成MAC」表单上; +// 2. 批次三项全为 0,或批次取不到(存量授权码没有 batchno、批次被撤销)→ 回退产品档案上的旧字段。 +// +// ⚠️ 判「批次是否配过」用的是三项全 0,不是「批次存在」:新批次默认三项都是 0, +// 若按「批次存在就用批次的值」,所有新批次都会变成不送任何东西,而产品上原本配好的赠送 +// 会在不改任何配置的情况下静默失效。 +// +// ⚠️ 取批次失败只回退、不报错:赠送是绑定的附带动作,为了它把绑定整个失败掉不划算。 +func (this *apiComp) resolveBindRewards(code *pb.DBAuthCode, product *pb.DBProduct) (vipDays, translateMin, meetingMin uint32) { + if code != nil { + if batch, err := this.module.model.getProductionBatch(code.Batchno); err == nil && batch != nil { + if batch.Rewardvip > 0 || batch.Rewardtranslate > 0 || batch.Rewardmeeting > 0 { + return batch.Rewardvip, batch.Rewardtranslate, batch.Rewardmeeting + } + } + } + if product == nil { + return 0, 0, 0 + } + // 产品档案上的旧字段:Bindrewardvpitime 历史上只当开关用(非 0 就发 90 天), + // 这里保持那个语义,免得存量产品的赠送天数在升级后变样。 + vipDays = product.Bindrewardvpitime + if vipDays > 0 { + vipDays = 90 + } + return vipDays, product.Bindrewardtranslate, product.Bindrewardmeeting +} + +// alreadyBound 判断用户已有的这条绑定是不是本次要绑的同一台设备。 +// +// 口径与 [sameUserDevice] 一致:**设备的身份是 MAC**。只有没有 MAC 的设备 +// (早年按设备码导入、未登记 MAC 的)才退回比设备码。 +// +// ⚠️ 原判断是 `v.Devicename == req.Devicename && v.Devicemac == req.Devicemac +// && v.License == req.Code`,两个条件都会误判: +// - **License**:经典蓝牙耳机客户端 code 传空(req.Code == ""),而服务端存进 +// userdevice.license 的是 model.Code(就是 MAC),两者永远不等 —— 这个去重 +// **恒为 false**。而 addDevice 是裸 Insert,于是每重新绑定一次就多一条重复行。 +// - **Devicename**:Android 的 getBondedDevices 给的是系统缓存的配对名, +// 固件还改过名(DEEPVOICE → Echo-one),同一台设备换个名字就又插一行。 +func alreadyBound(v *pb.DBUserDivice, req *pb.UserBindDeviceReq) bool { + if v == nil || req == nil { + return false + } + if mac := strings.TrimSpace(req.Devicemac); mac != "" && strings.TrimSpace(v.Devicemac) != "" { + return strings.EqualFold(strings.TrimSpace(v.Devicemac), mac) + } + return req.Code != "" && v.License == req.Code +} + +// sameUserDevice 判断用户设备列表里的某一行是否就是这条授权码所代表的设备。 +// +// 口径:**设备的身份是 MAC**,MAC 对得上就是同一台,与 license 写成什么无关 +// (大小写不敏感——两边的写法取决于当初是谁写进去的)。license 只用于认那些 +// 没有 MAC 的设备:早年按设备码导入、未登记 MAC 的杰理类设备走这条。 +// +// ⚠️ 原实现是「License 非空就只比 License」,两个坑: +// - 经典蓝牙耳机 2026-09-02 之前绑定的行 license 列恒为空(客户端 code 传空、 +// 服务端照抄),之后写入的又等于 MAC 本身,优先它没有任何好处; +// - license 与 MAC 不一致时(后台重导入过 license)会判成「不是同一台」, +// 接管时清不掉原用户那条绑定,旧账号的设备列表里就一直留着一台已经不属于 +// 它的耳机。 +func sameUserDevice(v *pb.DBUserDivice, code *pb.DBAuthCode) bool { + if v == nil || code == nil { + return false + } + if v.Devicemac != "" && code.Devicemac != "" && + strings.EqualFold(strings.TrimSpace(v.Devicemac), strings.TrimSpace(code.Devicemac)) { + return true + } + return v.License != "" && v.License == code.Code +} diff --git a/apps/services/modules/user/api_unbinddevice.go b/apps/services/modules/user/api_unbinddevice.go index 4b23cf67..3b01422c 100644 --- a/apps/services/modules/user/api_unbinddevice.go +++ b/apps/services/modules/user/api_unbinddevice.go @@ -1,10 +1,12 @@ package user import ( + "strings" + "yunyan/comm" + "yunyan/lego/sys/log" "yunyan/lego/sys/mysql" "yunyan/pb" - "yunyan/utils/license" ) // @Summary 解绑定耳机设备 @@ -19,10 +21,20 @@ import ( func (this *apiComp) UnBindDevice(session comm.IUserSession, req *pb.UserUnBindDeviceReq) (resp *pb.UserUnBindDeviceResp, errdata *pb.ErrorData) { var ( divice *pb.DBUserDivice - model *pb.DBAuthCode - pid uint16 err error ) + // 解绑的**唯一**必要条件是「这条绑定记录属于当前用户」。 + // + // ⚠️ 别再把「授权码行必须存在」设成前置。绑定关系存在 userdevice(MySQL), + // 授权码行存在 license_ 分表(Postgres),**两个库**天然会不一致: + // 后台重新导入过 license、分表选错、MAC 写法对不上,任何一种都会让查询返回 + // record not found。原实现遇到这种情况直接 return,delDevice 永远走不到—— + // 表现就是「设备管理页列着它,点解绑却报『请求的资源不存在』,永远删不掉」, + // 而用户完全没有自救手段。 + // + // 授权码行上的归属只是台账,清不掉也无害:别人再绑这台设备时, + // api_binddevice.go 的接管分支(model.Status != 0 && model.Uid != 当前用户) + // 会把旧绑定收拾干净。 if divice, err = this.module.model.getDevice(req.Id); err != nil { if err == mysql.ErrNoDocuments { resp = &pb.UserUnBindDeviceResp{} @@ -38,45 +50,9 @@ func (this *apiComp) UnBindDevice(session comm.IUserSession, req *pb.UserUnBindD resp = &pb.UserUnBindDeviceResp{} return } - pid, err = license.ProductID(divice.License) - if err != nil { - // errdata = &pb.ErrorData{ - // Code: pb.ErrorCode_AuthorizeIllegal, - // Message: err.Error(), - // } - // return - err = nil - pid = uint16(45058) - } - if model, err = this.module.model.getFactoryDevice(uint32(pid), divice.License); err != nil { //设备已被注册 - errdata = &pb.ErrorData{ - Code: pb.ErrorCode_AuthorizeNoCanUse, - Message: err.Error(), - } - return - } - // if model.Status != 1 { //授权码已被使用 - // errdata = &pb.ErrorData{ - // Code: pb.ErrorCode_DeviceUnBinded, - // Message: "设备未绑定", - // } - // return - // } - if model.Uid == session.GetUserId() { - // model.Status = 0 //已绑定 - model.Uid = "" - if model.Codetype == pb.Codetype_AUTHCODE { //认证码 才清理设备地址 - model.Devicemac = "" - } - if err = this.module.model.saveFactoryDevice(model.Productid, model); err != nil { - errdata = &pb.ErrorData{ - Code: pb.ErrorCode_DBError, - Message: err.Error(), - } - return - } - } + // 台账维护:尽力清掉授权码行上的归属,失败只记日志、不阻断解绑。 + this.releaseFactoryDeviceOwner(session.GetUserId(), divice) if err = this.module.model.delDevice(divice.Id); err != nil { errdata = &pb.ErrorData{ @@ -100,3 +76,58 @@ func (this *apiComp) UnBindDevice(session comm.IUserSession, req *pb.UserUnBindD resp = &pb.UserUnBindDeviceResp{} return } + +// releaseFactoryDeviceOwner 把授权码行上的归属清掉(台账维护)。 +// +// **尽力而为**:定位不到、写不回都只记一条日志就返回,绝不阻断解绑。 +// 理由见 UnBindDevice 里的说明——userdevice 与 license 分表在两个库, +// 把它设成硬前置会让用户永远删不掉自己的绑定记录。 +// +// 定位顺序按「设备身份就是 MAC」这条口径:**先 MAC,后 License**。 +// License 只用于兜底那些没有 MAC 的老记录(早年按设备码导入、未登记 MAC 的)。 +// 原实现是 License 优先,而经典蓝牙耳机的 license 列存量恒为空、新写入的又等于 +// MAC 本身,优先它没有任何好处,反倒在 license 与 MAC 不一致时定位到错误的行。 +func (this *apiComp) releaseFactoryDeviceOwner(uid string, divice *pb.DBUserDivice) { + // device_mac 是全局一张表,定位设备**不再需要 PID**(2026-09-04 合表)。 + // 原先要先把 PID 弄对才能选中分表,弄不对就静默跳过归属清理。 + var ( + model *pb.DBAuthCode + err error + ) + if mc := strings.TrimSpace(divice.Devicemac); mc != "" { + model, err = this.module.model.getFactoryDeviceformac(mc) + } else if lic := strings.TrimSpace(divice.License); lic != "" { + model, err = this.module.model.getFactoryDevice(lic) + } else { + this.module.Warn("UnBindDevice: 绑定记录既无 MAC 也无授权码,跳过授权码归属清理", + log.Field{Key: "deviceId", Value: divice.Id}) + return + } + if err != nil { + // 最常见的就是这条:device_mac 里没有这台设备(后台重导入过、MAC 写法对不上)。 + // 解绑照常进行。 + this.module.Warn("UnBindDevice: 设备行定位失败,已跳过归属清理,解绑照常进行", + log.Field{Key: "deviceId", Value: divice.Id}, + log.Field{Key: "devicemac", Value: divice.Devicemac}, + log.Field{Key: "license", Value: divice.License}, + log.Field{Key: "err", Value: err.Error()}) + return + } + if model.Uid != uid { + // 已经被别人接管了,不该由这次解绑去动它 + return + } + model.Uid = "" + // ⚠️ 不清 devicemac:设备码 = MAC = 分表主键 code(授权码字符串已于 2026-09-02 移除), + // 清掉等于把这台设备从库里抹掉——按 MAC 反查绑定的路径再也找不到它, + // 而 code 那一列还留着,两列从此对不上。 + // + // ⚠️ 也不清 Status:绑定礼靠 codestate==0 判「这台设备此前没被绑过」, + // 归零会让同一台设备反复绑解刷出多份权益。 + if err = this.module.model.saveFactoryDevice(model); err != nil { + this.module.Warn("UnBindDevice: 授权码归属清理写回失败,解绑照常进行", + log.Field{Key: "deviceId", Value: divice.Id}, + log.Field{Key: "code", Value: model.Code}, + log.Field{Key: "err", Value: err.Error()}) + } +} diff --git a/apps/services/modules/user/model_user.go b/apps/services/modules/user/model_user.go index 22ad541d..d8e226ae 100644 --- a/apps/services/modules/user/model_user.go +++ b/apps/services/modules/user/model_user.go @@ -2,6 +2,9 @@ package user import ( "context" + "fmt" + "strings" + "time" "yunyan/comm" "yunyan/lego/core" "yunyan/lego/core/cbase" @@ -9,8 +12,7 @@ import ( "yunyan/lego/sys/postgres" redissys "yunyan/lego/sys/redis" "yunyan/pb" - "fmt" - "time" + "yunyan/utils/devcode" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -52,6 +54,7 @@ func (this *modelUserComp) findformac(mac string) (user *pb.DBUser, err error) { err = mysql.FindOne(comm.TableUser, user, "phonemac=?", mac) return } + // findtouristformac 按设备硬件 id 找回**游客账号**。 // // 不能直接用 findformac:phonemac 是所有登录方式建号时都会写的字段, @@ -184,20 +187,64 @@ func (this *modelUserComp) delDevice(id uint64) (err error) { // ------------------- // 合作商绑定 -// getFactoryDevice / getFactoryDeviceformac 是绑定/解绑路径上的"读后写同一行"校验读, -// 用 FindOnePrimary 强制走主库,避免读到只读副本复制延迟内的旧状态造成重复绑定竞态。 -func (this *modelUserComp) getFactoryDevice(pid uint32, code string) (model *pb.DBAuthCode, err error) { +// +// 设备/授权码统一住在**全局一张** device_mac 表里(2026-09-04 由 license_ 分表合并而来)。 +// 主键 code 就是设备 MAC,本来就全局唯一,所以定位一台设备**不需要也不应该带 pid**—— +// pid 是从查到的那一行的 productid 列**读出来的结果**,不是查询条件。 +// +// 分表时代的教训:客户端得先猜出 pid 才能校验设备,猜错就报「这台 MAC 没登记在该产品下」, +// 提示词指向数据缺失、实际是 pid 错了,极难排查。详见 registry.go 的 migrateLicenseToDeviceMac。 +// +// 这两个函数是绑定/解绑路径上的"读后写同一行"校验读,用 FindOnePrimary 强制走主库, +// 避免读到只读副本复制延迟内的旧状态造成重复绑定竞态。 + +// getFactoryDevice 按授权码(= 主键 code)取设备行。 +func (this *modelUserComp) getFactoryDevice(code string) (model *pb.DBAuthCode, err error) { model = &pb.DBAuthCode{} - err = postgres.FindOnePrimary(fmt.Sprintf("%s_%x", comm.TableLicense, pid), model, "code=?", code) + err = postgres.FindOnePrimary(comm.TableDeviceMac, model, "code=?", code) return } -func (this *modelUserComp) getFactoryDeviceformac(pid uint32, devicemac string) (model *pb.DBAuthCode, err error) { + +// getFactoryDeviceformac 按 MAC 全局反查这台设备。**恒玄耳机的绑定校验就走这里**。 +// +// devicemac 是精确字符串匹配,两端的写法必须逐字一致。落库形态统一是「大写 + 冒号」 +// (mac.Generate 的输出,导入模式也已规范化成同一形态),客户端 Android 侧送上来的 +// BluetoothDevice.getAddress() 本来也是这个形态。但两端各自演进容易走偏(换 SDK、 +// iOS 补上 MAC 来源、运维手工塞数据…),一旦写法差一个大小写或少个冒号, +// 表现是「这台 MAC 没登记」——提示词指向数据缺失,实际是格式不匹配。 +// +// 所以这里多加一次兜底:精确匹配没中,就把入参规范化后再查一次。 +// 顺序不能反——先精确保证存量非规范数据(如果有)仍然查得到,规范化只用于补救。 +func (this *modelUserComp) getFactoryDeviceformac(devicemac string) (model *pb.DBAuthCode, err error) { + model = &pb.DBAuthCode{} + if err = postgres.FindOnePrimary(comm.TableDeviceMac, model, "devicemac=?", devicemac); err == nil { + return model, nil + } + norm, ok := devcode.NormalizeMac(devicemac) + if !ok || norm == devicemac { + return model, err // 规范化不了,或本来就是规范形态:保留第一次的错误 + } model = &pb.DBAuthCode{} - err = postgres.FindOnePrimary(fmt.Sprintf("%s_%x", comm.TableLicense, pid), model, "productid=? and devicemac=?", pid, devicemac) + err = postgres.FindOnePrimary(comm.TableDeviceMac, model, "devicemac=?", norm) return } -func (this *modelUserComp) saveFactoryDevice(pid uint32, code *pb.DBAuthCode) (err error) { - err = postgres.Save(fmt.Sprintf("%s_%x", comm.TableLicense, pid), code) + +// getProductionBatch 按批次号取生产批次台账(console 主库与 license 分表同一个 postgres)。 +// 绑定赠送已改成按批次配置,绑定时靠它取这一批送什么。 +// batchno 为空(存量授权码没有批次号)直接返回 nil,由调用方回退到产品上的旧字段。 +func (this *modelUserComp) getProductionBatch(batchno string) (model *pb.DBProductionBatch, err error) { + if strings.TrimSpace(batchno) == "" { + return nil, nil + } + model = &pb.DBProductionBatch{} + if err = postgres.FindOne(comm.TableProductionBatch, model, "batchno=?", batchno); err != nil { + return nil, err + } + return model, nil +} + +func (this *modelUserComp) saveFactoryDevice(code *pb.DBAuthCode) (err error) { + err = postgres.Save(comm.TableDeviceMac, code) return } @@ -244,8 +291,9 @@ func (this *modelUserComp) getProductVersions(pid uint32) (models []*pb.DBProduc // 下发同语义。下发给客户端的仍是 pb.DBChannelApp,由 toPbChannelApp 做映射。 // loadChannelApps 读本应用作用域 + 全局默认的全部渠道配置行。 -// 应用名走 comm.AppName()(APP_NAME 优先、回退 ANALYZE_APP_NAME),否则只填了统计应用名的 -// 部署会读不到自己那一层配置,只剩全局默认行。 +// 应用名走 comm.AppName()——即 .env 的 ANALYZE_APP_NAME(APP_NAME 已于 2026-08-12 删除, +// 别再照旧注释去找)。它须与 app_registry 的 app_name 列一致,否则读不到自己那一层配置、 +// 只剩全局默认行。 func (this *modelUserComp) loadChannelApps() ([]*comm.ChannelApp, error) { app := comm.AppName() rows := make([]*comm.ChannelApp, 0) @@ -256,31 +304,63 @@ func (this *modelUserComp) loadChannelApps() ([]*comm.ChannelApp, error) { return rows, nil } -// toPbChannelApp 把后台配置映射成下发结构;nil 进 nil 出,让上层照旧返回空。 +// loadAppRelease 取该应用生效的发版配置(应用行覆盖全局行);都没配返回 nil。 +// +// 版本/强更/送审版本/游客显隐这几项按**应用**存(不分渠道),见 comm.AppRelease。 +func (this *modelUserComp) loadAppRelease() (*comm.AppRelease, error) { + app := comm.AppName() + rows := make([]*comm.AppRelease, 0) + err := postgres.Find(comm.TableAppRelease, &rows, "(app_name=? OR app_name='')", app) + if err != nil && err != postgres.ErrNoDocuments { + return nil, err + } + return comm.PickAppRelease(rows, app), nil +} + +// toPbChannelApp 把后台配置映射成下发结构;两张表都没配才返回 nil。 +// +// **下发结构是两张表拼出来的**:因渠道而异的(下载地址/上架状态/支付渠道/跳过绑定)来自 +// [comm.ChannelApp],整个应用一套的(版本/强更/更新日志/送审版本/游客显隐)来自 +// [comm.AppRelease]。任一为 nil 就只填另一边,对应字段留零值 —— +// 客户端据此走「无更新 / 不显示入口」分支,不会因为配置缺失就把人挡在门外。 +// +// ⚠️ **渠道行不存在时绝不能整个返回 nil**。应用级的开关(尤其是游客登录)与渠道无关, +// 一旦让它取决于「渠道分发里有没有这一行」,就等于把刚拆开的耦合又接了回去: +// 运营在「游客显隐控制」里明明配好了,只因为没给这台设备的品牌渠道建过行, +// 入口就永远不出现,而且不报任何错。这个坑真踩过,别改回去。 +// +// channel 由调用方给(而不是从 c 里取):渠道行缺失时 c 是 nil,但客户端仍然需要知道 +// 这条回的是哪个渠道。 // // clientVer 是客户端上报的当前版本号,只用于把 Tourists 从「后台的原始配置」换算成 // 「这一版客户端到底该不该显示游客登录入口」——下发的 Tourists 是结论而非配置, // 客户端直接照着显隐即可,不需要也不应该再拿 Touristsversion 自己比一遍。 // Touristsversion 仍原样下发,仅供排查「为什么这版看不到入口」。 -func toPbChannelApp(c *comm.ChannelApp, clientVer string) *pb.DBChannelApp { - if c == nil { +func toPbChannelApp(channel int32, c *comm.ChannelApp, rel *comm.AppRelease, clientVer string) *pb.DBChannelApp { + if c == nil && rel == nil { return nil } - return &pb.DBChannelApp{ - Channel: c.Channel, - Version: c.Version, - Description: c.Description, - Address: c.Address, - Tourists: c.ShowTourists(clientVer), - Touristsversion: c.TouristsVersion, - Allowskipdevicebinding: c.AllowSkipDeviceBinding, - Paychannels: c.PayChannels, - Minversion: c.MinVersion, - Forceupdate: c.ForceUpdate, - Changelog: c.Changelog, - Reviewversion: c.ReviewVersion, - Status: c.Status, + out := &pb.DBChannelApp{Channel: channel} + channelAddress := "" + if c != nil { + out.Description = c.Description + out.Allowskipdevicebinding = c.AllowSkipDeviceBinding + out.Paychannels = c.PayChannels + out.Status = c.Status + channelAddress = c.Address + } + // 渠道填了本渠道商店链接就用它,否则回退应用默认地址。 + out.Address = rel.DownloadAddress(channelAddress) + if rel != nil { + out.Version = rel.Version + out.Minversion = rel.MinVersion + out.Forceupdate = rel.ForceUpdate + out.Changelog = rel.Changelog + out.Reviewversion = rel.ReviewVersion + out.Tourists = rel.ShowTourists(clientVer) + out.Touristsversion = rel.TouristsVersion } + return out } // getChannelAppCfg 取该渠道生效的原始配置(应用行覆盖全局行);未配置或处于草稿态返回 nil。 @@ -302,16 +382,26 @@ func (this *modelUserComp) getChannelApp(channel int32, clientVer string) (model if err != nil { return nil, err } - return toPbChannelApp(cfg, clientVer), nil + rel, err := this.loadAppRelease() + if err != nil { + return nil, err + } + return toPbChannelApp(channel, cfg, rel, clientVer), nil } // getChannelApps 下发全部渠道:同一渠道应用行覆盖全局行;草稿状态不下发。 // clientVer 供游客登录的版本限定判定,见 toPbChannelApp。 +// +// 发版配置只查一次,拼进每一条渠道记录 —— 它是按应用存的,与渠道无关。 func (this *modelUserComp) getChannelApps(clientVer string) (models []*pb.DBChannelApp, err error) { rows, err := this.loadChannelApps() if err != nil { return nil, err } + rel, err := this.loadAppRelease() + if err != nil { + return nil, err + } seen := map[int32]bool{} models = make([]*pb.DBChannelApp, 0, len(rows)) for _, r := range rows { @@ -323,7 +413,7 @@ func (this *modelUserComp) getChannelApps(clientVer string) (models []*pb.DBChan if picked == nil || !picked.Enable || picked.Status == comm.ChannelStatusDraft { continue } - models = append(models, toPbChannelApp(picked, clientVer)) + models = append(models, toPbChannelApp(picked.Channel, picked, rel, clientVer)) } return models, nil }