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

10 KiB

授权码 v2 改造方案(序号按产品累加 + 独立批次表)

状态:已设计、编解码已落地、业务改造未动工。等待择期开工。

一、问题

现有授权码把序号切成 批次(8位) + 批内序号(20位):

  • 批次 probatch 是 factory 级计数器,每次生产 ++,上限 255,用尽即废。
  • 序号按批次重置,导致"序号"没有产品级全局意义。
  • 批次作为授权码的一个编码字段存在,而它本该是一条独立的生产记录。

目标:序号按产品累加、占满 uint32;批次独立成表,主键用可读短码,无数量上限。

二、关键发现(决定了迁移代价)

1. batch 和 serial 在比特层面本来就是同一个连续整数

comm/core.go 的 v1 编码:

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_<pid_hex> 分表
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++ 的读-改-写竞态:
      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 是共享配置与对象,不放算法实现)

第二步(历史归档,可延后,失败不影响线上)

  1. 迁移 SQL。第一道闸:

    -- 对每张 license_<pid_hex> 分表执行,任一命中即中止整个迁移
    SELECT count(*) FROM license_b00d WHERE probatch >= 240;   -- 必须为 0
    

    加列(Postgres 无 uint32,序号列必须 BIGINT,INT 放不下 0xFFFFFFFF):

    ALTER TABLE license_<pid_hex> ADD COLUMN IF NOT EXISTS codever   SMALLINT   NOT NULL DEFAULT 0;
    ALTER TABLE license_<pid_hex> ADD COLUMN IF NOT EXISTS serialno  BIGINT     NOT NULL DEFAULT 0;
    ALTER TABLE license_<pid_hex> ADD COLUMN IF NOT EXISTS batchcode VARCHAR(8) NOT NULL DEFAULT '';
    CREATE INDEX IF NOT EXISTS idx_<pid>_batchcode ON license_<pid_hex>(batchcode);
    -- 只对 v2 行要求序号唯一(老码 serialno 恒为 0)
    CREATE UNIQUE INDEX IF NOT EXISTS uq_<pid>_serialno ON license_<pid_hex>(serialno) WHERE codever = 2;
    
    ALTER TABLE product ADD COLUMN IF NOT EXISTS serialcursor BIGINT NOT NULL DEFAULT 0;
    

    批次表:

    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);
    
  2. 历史批次导入(需 Go 工具,短码要程序生成): factory_deliverynote 每行 = 一次生产 = 一个批次。序号区间按 v1 语义还原为 [probatch<<20 | 1, probatch<<20 | devicetnum],codever=1、legacyprobatch=probatch。

  3. 老 license 行回填 batchcode:

    UPDATE license_<pid_hex> 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 = <pid>;
    

    ⚠ 依赖 (factoryid, probatch) 唯一。若 probatch 重置 bug 留过疤,同一厂家会有两次生产复用 同一 probatch,此处会归错批次,需改用 createtime vs deliverynote.ts 的时间窗辅助拆分。 迁移前应先跑体检:

    SELECT factoryid, probatch, count(*) FROM factory_deliverynote GROUP BY 1,2 HAVING count(*) > 1;
    

    老码 serialno 保持 0、codever 保持 0,不做回填——v1 序号没有产品级全局意义。

  4. 前端: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) 组合同样单调、不重复