From d7445984cbb55d07c6e636617b718d322216acec Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Fri, 10 Jul 2026 17:56:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E5=90=97=20=E7=9A=84=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/services/comm/log | 1 - apps/services/utils/license/README.md | 208 ++++++++++++++++++++ apps/services/utils/license/license.go | 143 ++++++++++++++ apps/services/utils/license/license_test.go | 172 ++++++++++++++++ 4 files changed, 523 insertions(+), 1 deletion(-) delete mode 100644 apps/services/comm/log create mode 100644 apps/services/utils/license/README.md create mode 100644 apps/services/utils/license/license.go create mode 100644 apps/services/utils/license/license_test.go diff --git a/apps/services/comm/log b/apps/services/comm/log deleted file mode 100644 index 853a9d60..00000000 --- a/apps/services/comm/log +++ /dev/null @@ -1 +0,0 @@ -2026/07/10 10:56:05.452 ERROR comm/moduleconfig.go:202 [ModuleConfig] 密钥字段解密失败,已跳过该字段(保留默认值) module=wechatpay key=PrivateKeyPath err=GCM 解密失败: cipher: message authentication failed 本服务key指纹=sha256:19731e34cd8c [不足 32 字节,已右侧补零(原长 30)] 【诊断】密文是用「空 key(FIELD_ENCRYPT_KEY 未注入时补零出的全零密钥)」(指纹 sha256:17a63bfb3b5b [!!未配置 FIELD_ENCRYPT_KEY,正在使用全零密钥])加密的存量数据,与本服务当前 key 不是同一把 —— 到 console 后台把该字段重新填一遍保存,即可用当前 key 重新加密落库 diff --git a/apps/services/utils/license/README.md b/apps/services/utils/license/README.md new file mode 100644 index 00000000..8f7bab10 --- /dev/null +++ b/apps/services/utils/license/README.md @@ -0,0 +1,208 @@ +# 授权码 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)` 组合同样单调、不重复 | diff --git a/apps/services/utils/license/license.go b/apps/services/utils/license/license.go new file mode 100644 index 00000000..267280cc --- /dev/null +++ b/apps/services/utils/license/license.go @@ -0,0 +1,143 @@ +// Package license 授权码编解码。 +// +// 授权码固定 10 字节(20 个 hex 字符),分 5 段:段0 是随机种子,段1-4 是被种子 +// 派生的混淆序列异或过的数据。码长与混淆结构在 v1/v2 之间保持不变。 +// +// v1 把序号切成 批次(8位) + 批内序号(20位),批次每次生产 +1、上限 255,用尽即废。 +// v2 取消批次字段,序号按产品累加、占满 32 位;批次改由独立的批次表承载。 +// +// 逻辑布局(异或前。密文经混淆后不可肉眼分辨版本): +// +// 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,历史 probatch 恒小于 +// V1ProbatchLimit,故不可能等于 v2 标记。productid 位于第 5-8 位,两版一致, +// 因此提取 productid 无需先判版本(见 ProductID)。 +package license + +import ( + cryptorand "crypto/rand" + "errors" + "fmt" + "math/rand" + "strconv" +) + +const ( + totalSegments = 5 // 5段,每段4字符 = 20字符 = 10字节 + segmentLength = 4 + + // v2Tag v2 格式标记,占第 9 个 hex 字符 + v2Tag = 0xF + + // V1ProbatchLimit v1 批次号上界(不含)。历史 probatch 必须全部小于该值, + // 否则越界的 v1 码会在判别位上撞上 v2 标记而被误判为 v2。 + // 迁移脚本必须先校验 MAX(probatch) < V1ProbatchLimit 再继续。 + V1ProbatchLimit = 0xF0 +) + +// Version 授权码格式版本 +type Version uint8 + +const ( + V1 Version = 1 // 批次(8位) + 批内序号(20位) + V2 Version = 2 // 产品级全局序号(32位) +) + +// Info 授权码解析结果 +type Info struct { + ProductID uint16 // 产品ID + Version Version // 格式版本 + Serial uint32 // v2:产品级全局序号(32位) v1:批内序号(20位) + Batch byte // 批次号,仅 v1 有效 +} + +// decode 拆出 5 个数据段,并用种子重建混淆序列。v1/v2 共用,不涉及版本判别。 +func decode(code string) (segments, confusion []int, err error) { + expectedLength := totalSegments * segmentLength + if len(code) != expectedLength { + return nil, nil, fmt.Errorf("授权码长度错误,期望%d字符,实际%d字符", expectedLength, len(code)) + } + segments = make([]int, totalSegments) + for i := 0; i < totalSegments; i++ { + value, perr := strconv.ParseUint(code[i*segmentLength:(i+1)*segmentLength], 16, 32) + if perr != nil { + return nil, nil, fmt.Errorf("第%d段包含无效字符", i+1) + } + segments[i] = int(value) + } + rng := rand.New(rand.NewSource(int64(segments[0]))) + confusion = make([]int, totalSegments-1) + for i := range confusion { + confusion[i] = rng.Intn(65536) + } + return segments, confusion, nil +} + +// GenerateV2 生成 v2 授权码。 +// serial 为产品级全局序号,占满 uint32,按产品累加,不随批次重置。 +func GenerateV2(productid uint16, serial uint32) (string, error) { + seedBytes := make([]byte, 2) + if _, err := cryptorand.Read(seedBytes); err != nil { + return "", fmt.Errorf("生成随机种子失败: %v", err) + } + seed := int(seedBytes[0])<<8 | int(seedBytes[1]) + rng := rand.New(rand.NewSource(int64(seed))) + + confusion := make([]int, totalSegments-1) + for i := range confusion { + confusion[i] = rng.Intn(65536) + } + + confusion[0] ^= int(productid) + confusion[1] ^= v2Tag<<12 | int(serial>>20) // 标记 + 序号[31:20] + confusion[2] ^= int(serial>>4) & 0xFFFF // 序号[19:4] + confusion[3] ^= int(serial&0xF)<<12 | rng.Intn(1<<12) // 序号[3:0] + 12位随机填充 + + code := fmt.Sprintf("%04X", seed) + for _, c := range confusion { + code += fmt.Sprintf("%04X", c) + } + return code, nil +} + +// Parse 解析授权码,自动识别 v1/v2 格式 +func Parse(code string) (*Info, error) { + segments, confusion, err := decode(code) + if err != nil { + return nil, err + } + + info := &Info{ProductID: uint16((segments[1] ^ confusion[0]) & 0xFFFF)} + data2 := segments[2] ^ confusion[1] + data3 := segments[3] ^ confusion[2] + + if (data2>>12)&0xF == v2Tag { + data4 := segments[4] ^ confusion[3] + info.Version = V2 + info.Serial = uint32(data2&0xFFF)<<20 | uint32(data3&0xFFFF)<<4 | uint32((data4>>12)&0xF) + return info, nil + } + + info.Version = V1 + info.Batch = byte((data2 >> 8) & 0xFF) + info.Serial = uint32(data2&0xFF)<<12 | uint32(data3&0xFFF) + if info.Batch < 1 { + return nil, errors.New("批次数据异常") + } + return info, nil +} + +// ProductID 提取授权码中的产品ID。 +// productid 在 v1/v2 中位置相同,故不做版本判别、也不校验批次, +// 新旧码混存期间恒可用(调用方通常只需要它来定位 license 分表)。 +func ProductID(code string) (uint16, error) { + segments, confusion, err := decode(code) + if err != nil { + return 0, err + } + return uint16((segments[1] ^ confusion[0]) & 0xFFFF), nil +} diff --git a/apps/services/utils/license/license_test.go b/apps/services/utils/license/license_test.go new file mode 100644 index 00000000..a836e491 --- /dev/null +++ b/apps/services/utils/license/license_test.go @@ -0,0 +1,172 @@ +package license + +import ( + cryptorand "crypto/rand" + "fmt" + "math/rand" + "testing" +) + +// 生产库里的真实 v1 授权码样本 +const legacySampleCode = "0D82F1CCD83D72B78FA9" + +// generateV1 复刻 comm.GenerateLicense 的 v1 编码,仅供测试构造老码。 +// 生产代码不再生成 v1,只解析。 +func generateV1(productid uint16, batch byte, serial uint32) (string, error) { + if batch < 1 { + return "", fmt.Errorf("批次必须 >= 1") + } + if serial > 0xFFFFF { + return "", fmt.Errorf("v1 序号上限 0xFFFFF") + } + seedBytes := make([]byte, 2) + if _, err := cryptorand.Read(seedBytes); err != nil { + return "", err + } + seed := int(seedBytes[0])<<8 | int(seedBytes[1]) + rng := rand.New(rand.NewSource(int64(seed))) + + confusion := make([]int, 4) + for i := range confusion { + confusion[i] = rng.Intn(65536) + } + confusion[0] ^= int(productid) + confusion[1] ^= (int(batch) << 8) | (int(serial>>12) & 0xFF) + confusion[2] ^= int(serial) & 0xFFF + confusion[3] ^= rng.Intn(65536) + + code := fmt.Sprintf("%04X", seed) + for _, c := range confusion { + code += fmt.Sprintf("%04X", c) + } + return code, nil +} + +func TestParse_LegacySample(t *testing.T) { + info, err := Parse(legacySampleCode) + if err != nil { + t.Fatalf("解析真实老码失败: %v", err) + } + if info.Version != V1 { + t.Errorf("版本应为 v1, 实际 v%d", info.Version) + } + if info.ProductID != 45069 || info.Batch != 1 || info.Serial != 260 { + t.Errorf("解析结果不符: pid=%d batch=%d serial=%d, 期望 45069/1/260", + info.ProductID, info.Batch, info.Serial) + } +} + +// v2 往返:序号必须能占满 uint32 +func TestGenerateV2_RoundTrip(t *testing.T) { + pids := []uint16{0, 1, 45058, 45069, 0xFFFF} + serials := []uint32{ + 0, 1, 15, 16, 0xFFF, 0x1000, 0xFFFFF, 0x100000, + 0xFFFFFFF, 0x10000000, 0x12345678, 0xFFFFFFFE, 0xFFFFFFFF, + } + for _, pid := range pids { + for _, serial := range serials { + code, err := GenerateV2(pid, serial) + if err != nil { + t.Fatalf("生成失败 pid=%d serial=%d: %v", pid, serial, err) + } + if len(code) != totalSegments*segmentLength { + t.Fatalf("码长变了: %d, 期望 %d", len(code), totalSegments*segmentLength) + } + info, err := Parse(code) + if err != nil { + t.Fatalf("解析失败 %s: %v", code, err) + } + if info.Version != V2 { + t.Errorf("%s 应识别为 v2, 实际 v%d", code, info.Version) + } + if info.ProductID != pid || info.Serial != serial { + t.Errorf("往返不一致: 写入 pid=%d serial=%d, 读出 pid=%d serial=%d", + pid, serial, info.ProductID, info.Serial) + } + } + } +} + +// probatch < 240 的 v1 码不能被误判成 v2,且 batch/serial 必须原样解出 +func TestParse_V1NotMisdetected(t *testing.T) { + const pid = 45069 + serials := []uint32{0, 1, 4095, 4096, 65535, 0xFFFFF} + for batch := 1; batch < V1ProbatchLimit; batch++ { + for _, serial := range serials { + code, err := generateV1(pid, byte(batch), serial) + if err != nil { + t.Fatalf("v1 生成失败 batch=%d serial=%d: %v", batch, serial, err) + } + info, err := Parse(code) + if err != nil { + t.Fatalf("v1 解析失败 batch=%d serial=%d: %v", batch, serial, err) + } + if info.Version != V1 { + t.Fatalf("batch=%d serial=%d 的 v1 码被误判为 v%d", batch, serial, info.Version) + } + if info.ProductID != pid || info.Batch != byte(batch) || info.Serial != serial { + t.Fatalf("v1 解析不一致 batch=%d serial=%d -> pid=%d batch=%d serial=%d", + batch, serial, info.ProductID, info.Batch, info.Serial) + } + } + } +} + +// 文档化约束:batch >= 240 的 v1 码必然被误判为 v2(判别位 = batch>>4 == 0xF)。 +// 迁移脚本必须先验证 MAX(probatch) < V1ProbatchLimit,否则老码序号会解出垃圾。 +// 本测试若开始失败,说明判别位挪了位置,前提校验需要跟着改。 +func TestParse_V1OverLimitIsMisdetected(t *testing.T) { + const pid = 45069 + for batch := V1ProbatchLimit; batch <= 0xFF; batch++ { + code, err := generateV1(pid, byte(batch), 1) + if err != nil { + t.Fatalf("v1 生成失败 batch=%d: %v", batch, err) + } + info, err := Parse(code) + if err != nil { + t.Fatalf("解析失败 batch=%d: %v", batch, err) + } + if info.Version != V2 { + t.Errorf("batch=%d 的 v1 码应当被误判为 v2(证明前提校验必要),实际 v%d", batch, info.Version) + } + } +} + +// productid 的提取与版本无关,也不受 v1 批次异常影响 +func TestProductID_VersionAgnostic(t *testing.T) { + const pid = 45069 + + v1, err := generateV1(pid, 3, 1234) + if err != nil { + t.Fatalf("v1 生成失败: %v", err) + } + v2, err := GenerateV2(pid, 0xDEADBEEF) + if err != nil { + t.Fatalf("v2 生成失败: %v", err) + } + for _, code := range []string{v1, v2, legacySampleCode} { + got, err := ProductID(code) + if err != nil { + t.Fatalf("%s 提取 pid 失败: %v", code, err) + } + if got != pid { + t.Errorf("%s 提取 pid=%d, 期望 %d", code, got, pid) + } + } +} + +// 同产品下连续序号生成的码互不相同(code <-> (seed, pid, serial) 双射) +func TestGenerateV2_Uniqueness(t *testing.T) { + const pid = 45069 + seen := make(map[string]uint32, 20000) + for serial := uint32(1); serial <= 20000; serial++ { + code, err := GenerateV2(pid, serial) + if err != nil { + t.Fatalf("生成失败 serial=%d: %v", serial, err) + } + if prev, dup := seen[code]; dup { + t.Fatalf("授权码碰撞: serial=%d 与 serial=%d 都生成 %s", serial, prev, code) + } + seen[code] = serial + } +}