# 授权码 v2 改造方案(序号按产品累加 + 独立批次表) 状态:**已设计、编解码已落地、业务改造未动工**。等待择期开工。 ## 一、问题 现有授权码把序号切成 `批次(8位) + 批内序号(20位)`: - 批次 `probatch` 是 `factory` 级计数器,每次生产 `++`,**上限 255**,用尽即废。 - 序号按批次重置,导致"序号"没有产品级全局意义。 - 批次作为授权码的一个**编码字段**存在,而它本该是一条独立的生产记录。 目标:序号按产品累加、占满 `uint32`;批次独立成表,主键用可读短码,无数量上限。 ## 二、关键发现(决定了迁移代价) ### 1. `batch` 和 `serial` 在比特层面本来就是同一个连续整数 `comm/core.go` 的 v1 编码: ```go data2 := (int(batchNumber) << 8) | (int(serialNumber>>12) & 0xFF) // 段2 data3 := (int(serialNumber>>0) & 0xFFF) // 段3 ``` 令 `gs = batch<<20 | serial`(28 位),则 `gs>>12 & 0xFFFF ≡ data2`、`gs & 0xFFF ≡ data3`。 **逐位恒等**(1400 组穷举验证零差异)。所以 v1 的"批次+批内序号"就是一个 28 位序号的高 8 位 / 低 20 位拆分。 ### 2. `batch` / `serial` 从未被业务消费 `ValidateLicense` 的全部 4 个调用点都是 `pid, _, _, err`: | 调用点 | 用途 | |---|---| | `modules/user/api_binddevice.go:79` | 取 pid 定位 `license_` 分表 | | `modules/user/api_unbinddevice.go:40` | 同上 | | `modules/api/api_batchresetlicensestatus.go:23` | 同上 | | `modules/console/api_device.go:386` | 同上 | 真正的校验是拿 `code` 主键查表。**重定义 batch/serial 的语义,后端零业务影响。** ### 3. `productid` 在 v1/v2 中位置相同 `productid` 独占段1(第 5-8 个 hex 字符),不参与版本判别。因此新旧码混存期间, `license.ProductID()` 恒可用,`binddevice` 等路径零风险。 ## 三、v2 设计 码长不变:**10 字节 = 20 个 hex 字符 = 5 段 × 4 字符**。段0 是随机种子,段1-4 是被种子派生的 混淆序列异或过的数据。 逻辑布局(**异或前**。密文经混淆后不可肉眼分辨版本): ``` hex位 1-4 5-8 9 10-12 13-16 17 18-20 v1 seed productid probatch高4位 probatch低4位 随机4位 随机 随机 +序号高8位 +序号低12位 v2 seed productid 0xF(标记) 序号[31:20] 序号[19:4] 序号[3:0] 随机 ``` - 第 9 个 hex 字符是**版本判别位**:v1 存 `probatch>>4`,v2 恒为 `0xF`。 - 序号占 `3+4+1 = 8` 个 hex 字符 = **完整 uint32**(0 ~ 4,294,967,295)。 - 随机填充从 5 个 hex 字符缩到 3 个,**不损失安全性**:那些位本就由 seed 派生 (`rng.Intn(65536)`),不是独立熵。码的唯一性照旧,`code ↔ (seed, pid, serial)` 仍是双射。 ### 硬前提:历史 `probatch` 必须全部 < 240(`0xF0`) 判别位是 `probatch >> 4`。若历史上存在 `probatch ∈ [240, 255]` 的批次,那些 v1 码会被 **误判为 v2**,序号解出垃圾。 - 用户已口头确认"肯定 < 240"(`probatch` 是每次生产 +1 的计数器,实际值很小)。 - **未经生产库验证**。迁移脚本第一道闸必须是 `MAX(probatch) >= 240 则中止`,fail-closed。 - `utils/license` 的 `TestParse_V1OverLimitIsMisdetected` 已把这条约束钉进代码:它断言 `batch >= 240` 的 v1 码**确实会**被误判。判别位若挪位置,该测试立刻失败。 ### 为什么不选 24 位方案 曾考虑 `gs = 0xF<<24 | serial(24位)`,空间 1677 万/产品,解码路径不分叉(新老共用一个公式)。 最终选 32 位,因为代价近乎为零(只多一个 `if` 分支),且彻底消除天花板焦虑。 `0xF` 标记的另一层价值:它让**将来扩容成为安全操作**。授权码里仍有 3 个 hex 字符的随机填充, 真不够时可以约定"带标记的码才读那些位",老码不受影响。 ## 四、新序号空间与老码物理隔离 v2 序号从 **1 开始**,不需要扫描老数据算起点——因为版本由标记位区分,不靠数值区间区分。 由此,两条历史风险被自动消解: 1. **老码撞号**(`(probatch, number)` 可能因 `api_updatefactory` 旧版把 `probatch` 重置为 0 而重复, 见 `modules/api/model_test.go` 的 `Test_RebuildFactoryProbatch` 注释)——不再要紧,老码不回填 `serialno`。 2. **pid 解析不依赖判别位**——混存期零风险。 ## 五、已完成 `apps/services/utils/license/`(自包含,只依赖标准库): | 符号 | 说明 | |---|---| | `GenerateV2(productid uint16, serial uint32)` | 生成 v2 码 | | `Parse(code) (*Info, error)` | 自动识别 v1/v2 | | `ProductID(code) (uint16, error)` | 不判版本、不校验批次,混存期恒可用 | | `V1ProbatchLimit = 0xF0` | 迁移闸门常量 | 6 个测试全绿,含:真实老码样本 `0D82F1CCD83D72B78FA9` → `pid=45069 batch=1 serial=260`; v2 往返覆盖 `0xFFFFFFFF`;`batch ∈ [1,239] × 6 种 serial` 共 1434 组 v1 码零误判; 2 万个连续序号零碰撞。 **尚未接入任何业务代码**,`comm.GenerateLicense/ValidateLicense` 原样保留,线上零影响。 ## 六、待做 ### 第一步(线上安全,老数据一行不动) 1. **proto + pb 重生成** - 新增 `DBFactoryBatch`(见下方 DDL) - `DBFactoryDevics` 加 `codever` / `serialno` / `batchcode` - `DBProduct` 加 `serialcursor` - ⚠ 本仓库无 pb 生成脚本。需 `protoc --go_out=pb --go_opt=paths=import -I ../proto ../proto/db.proto`, 再复刻源项目 `deep_server_up/pb.py` 的 `@go_tags` 注入——protoc 会把 json tag 重置成 `,omitempty` 并丢掉 gorm tag,不注入回去 DB 层会全线失效 2. **批次短码**:6 位,复用 `comm.GeneratePublicCode` 的字符集(去除易混的 `0/O/I/1`,32 字符集 → 10.7 亿空间)。唯一索引 + 冲突重试。 3. **生成逻辑改造**(两处:`modules/api/api_createfactorydevics.go`、`modules/console/api_device.go`) - 原子分配序号区间,顺手修掉现在 `modelFactory.Probatch++` 的读-改-写竞态: ```sql UPDATE product SET serialcursor = serialcursor + $1 WHERE id = $2 RETURNING serialcursor; -- 区间 = [ret - N + 1, ret] ``` - 建批次行,license 行写 `codever=2, serialno, batchcode` - ⚠ MAC 侧本期不动,`GenerateCustomMAC` 仍吃 `probatch`,故 `factory.probatch` **仍需继续自增** 4. **调用点切换**:4 处 `comm.ValidateLicense` → `license.ProductID`;之后删除 `comm.GenerateLicense` / `comm.ValidateLicense`(`comm` 是共享配置与对象,不放算法实现) ### 第二步(历史归档,可延后,失败不影响线上) 5. **迁移 SQL**。第一道闸: ```sql -- 对每张 license_ 分表执行,任一命中即中止整个迁移 SELECT count(*) FROM license_b00d WHERE probatch >= 240; -- 必须为 0 ``` 加列(Postgres 无 uint32,序号列必须 `BIGINT`,`INT` 放不下 `0xFFFFFFFF`): ```sql ALTER TABLE license_ ADD COLUMN IF NOT EXISTS codever SMALLINT NOT NULL DEFAULT 0; ALTER TABLE license_ ADD COLUMN IF NOT EXISTS serialno BIGINT NOT NULL DEFAULT 0; ALTER TABLE license_ ADD COLUMN IF NOT EXISTS batchcode VARCHAR(8) NOT NULL DEFAULT ''; CREATE INDEX IF NOT EXISTS idx__batchcode ON license_(batchcode); -- 只对 v2 行要求序号唯一(老码 serialno 恒为 0) CREATE UNIQUE INDEX IF NOT EXISTS uq__serialno ON license_(serialno) WHERE codever = 2; ALTER TABLE product ADD COLUMN IF NOT EXISTS serialcursor BIGINT NOT NULL DEFAULT 0; ``` 批次表: ```sql CREATE TABLE factory_batch ( batchcode VARCHAR(8) PRIMARY KEY, -- 6 位可读短码 factoryid INT NOT NULL, productid INT NOT NULL, serialstart BIGINT NOT NULL, serialend BIGINT NOT NULL, devicenum INT NOT NULL, codever SMALLINT NOT NULL DEFAULT 2, -- 1=从 deliverynote 迁入的历史批次 legacyprobatch INT NOT NULL DEFAULT 0, -- v1 批次号,v2 为 0 ts BIGINT NOT NULL, remark TEXT ); CREATE INDEX idx_factory_batch_fp ON factory_batch(factoryid, productid); ``` 6. **历史批次导入**(需 Go 工具,短码要程序生成): `factory_deliverynote` 每行 = 一次生产 = 一个批次。序号区间按 v1 语义还原为 `[probatch<<20 | 1, probatch<<20 | devicetnum]`,`codever=1`、`legacyprobatch=probatch`。 7. **老 license 行回填 `batchcode`**: ```sql UPDATE license_ l SET batchcode = b.batchcode FROM factory_batch b WHERE b.codever = 1 AND b.legacyprobatch = l.probatch AND b.factoryid = l.factoryid AND b.productid = ; ``` ⚠ 依赖 `(factoryid, probatch)` 唯一。若 `probatch` 重置 bug 留过疤,同一厂家会有两次生产复用 同一 `probatch`,此处会归错批次,需改用 `createtime` vs `deliverynote.ts` 的时间窗辅助拆分。 **迁移前应先跑体检**: ```sql SELECT factoryid, probatch, count(*) FROM factory_deliverynote GROUP BY 1,2 HAVING count(*) > 1; ``` 老码 `serialno` 保持 0、`codever` 保持 0,不做回填——v1 序号没有产品级全局意义。 8. **前端**:`apps/admin/app/pages/factory.vue` / `device.vue` 的批次展示改为序号区间 + 批次短码。 ## 七、本期不做 - **`GenerateCustomMAC` / CMEI**(用户明确"先别急着动")。MAC 侧同样是 `batch(8) + serial(24)`, 255 上限仍在。合并成 32 位全局序号时老 MAC 逐位不变,但 `modules/user/api_binddevice.go:133` 的 CMEI 取 `mac[2]` 当批次段、`mac[4..5]` 当序号段, 中间的 `mac[3]` 不参与——序号连续累加后 CMEI 会在 65536 个之后碰撞。需先定 CMEI 新规则。 - 32 位之外的扩容(吃掉剩余 3 个 hex 字符的随机填充)。`0xF` 标记已把这条路留好。 ## 八、未验证事项 | 事项 | 状态 | |---|---| | 生产库 `MAX(probatch) < 240` | 用户口头确认,**未查库**。迁移脚本以 fail-closed 闸门兜底 | | `(factoryid, probatch)` 是否唯一 | **未查库**。只影响第二步历史归档,不影响新码 | | 固件/产测工具是否解析 batch/serial | 未确认。即便解析也安全:v2 序号单调递增,老规则解出的 `(batch, serial)` 组合同样单调、不重复 |