4 changed files with 523 additions and 1 deletions
@ -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 重新加密落库 |
|
||||
@ -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_<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++` 的读-改-写竞态: |
||||
|
```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_<pid_hex> 分表执行,任一命中即中止整个迁移 |
||||
|
SELECT count(*) FROM license_b00d WHERE probatch >= 240; -- 必须为 0 |
||||
|
``` |
||||
|
加列(Postgres 无 uint32,序号列必须 `BIGINT`,`INT` 放不下 `0xFFFFFFFF`): |
||||
|
```sql |
||||
|
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; |
||||
|
``` |
||||
|
批次表: |
||||
|
```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_<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` 的时间窗辅助拆分。 |
||||
|
**迁移前应先跑体检**: |
||||
|
```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)` 组合同样单调、不重复 | |
||||
@ -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 |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue