fix(phase4.1): 修正短码为变长 ticket_id 设计

设计变更:
- ticket_id 不再填充固定5位,改为可变长度
- 编码:goods_id(4位明文) + ticket_id(变长base36) → Feistel8 → 短码
- 解码:前4位=goods_id,剩余全部=ticket_id

ticket_id 范围示例:
- ticket_id=100 → 短码长度=4+2=6位
- ticket_id=10亿 → 短码长度=4+7=11位
- ticket_id=28亿 → 短码长度=4+7=11位

无需修改数据库,所有数据可动态计算。
feat/phase4-ticket-wallet
Council 2026-04-23 08:00:56 +08:00
parent 969a667928
commit 4c1192d491
2 changed files with 52 additions and 58 deletions

View File

@ -364,9 +364,8 @@ class BaseService
$R = $R_new;
}
// 合并为36bit整数
// 合并为 base36 字符串
$result = ($L & 0x7FFFF) | (($R & 0x1FFFF) << 17);
return base_convert($result, 10, 36);
}
@ -401,15 +400,15 @@ class BaseService
/**
* 生成短码
*
* 编码结构【明文4位 goods_id】【混淆5位 ticket_id】短码
* - 前4位goods_id 明文 base36 (范围 0-1,679,615)
* - 5位ticket_id Feistel8 混淆 (范围 0-60,466,175)
* - 解码 O(1)直接读前4位=goods_id用key解密后5位=ticket_id
* 编码结构【明文4位 goods_id】【变长混淆 ticket_id】
* - 前4位goods_id 明文 base36固定4位范围 0-1,679,615
* - ticket_id 可变长度 base36 ticket_id 增长自动变长
* - 解码 O(1)前4位=goods_id剩余全部=ticket_id无需固定分隔
*
* @param int $goods_id 0-1679615
* @param int $ticket_id 0-60466175
* @return string base36小写短码9位
* @throws \Exception 参数超范围时抛出
* @param int $ticket_id 任意正整数(可变长度)
* @return string base36短码
* @throws \Exception goods_id 超范围时抛出
*/
public static function shortCodeEncode(int $goods_id, int $ticket_id): string
{
@ -417,35 +416,35 @@ class BaseService
if ($goods_id > 0xFFFFFF) {
throw new \Exception("goods_id 超出范围 (max=1679615), given={$goods_id}");
}
// 校验 ticket_id 不超过 5位 base36 (0x3FFFFFFF = 1073741823)
if ($ticket_id > 0x3FFFFFFF) {
throw new \Exception("ticket_id 超出范围 (max=1073741823), given={$ticket_id}");
if ($ticket_id <= 0) {
throw new \Exception("ticket_id 必须为正整数, given={$ticket_id}");
}
// goods_id 固定4位 base36明文
$goods_part = str_pad(base_convert($goods_id, 10, 36), 4, '0', STR_PAD_LEFT);
// ticket_id 可变长度(不填充)
$ticket_part = base_convert($ticket_id, 10, 36);
// ticket_id 混淆
// ticket_id 填满5位 base36用 Feistel8 混淆
$ticket_int = intval(str_pad(base_convert($ticket_id, 10, 36), 5, '0', STR_PAD_LEFT), 36);
$ticket_int = intval($ticket_part, 36);
$key = self::getGoodsKey($goods_id);
$obfuscated = self::feistelEncode($ticket_int, $key);
// 确保混淆结果也是5位
$ticket_part = str_pad($obfuscated, 5, '0', STR_PAD_LEFT);
// 拼接前4位明文 goods_id + 后5位混淆 ticket_id
return strtolower($goods_part . $ticket_part);
// 拼接前4位明文 goods_id + 变长混淆 ticket_id
return strtolower($goods_part . $obfuscated);
}
/**
* 解析短码(解码回 goods_id + ticket_id
*
* 解码结构【明文4位 goods_id】【混淆5位 ticket_id】
* 解码结构【明文4位 goods_id】【变长混淆 ticket_id】
* - 前4位直接 base36_decode = goods_id
* - 后5位:用 goods_id 派生 key Feistel 解密 = ticket_id
* - 剩余全部:用 goods_id 派生 key Feistel 解密 = ticket_id
* - 解码 O(1),无暴力搜索
*
* @param string $code 短码(小写或大写均可9位
* @param string $code 短码(小写或大写均可
* @param int|null $goods_id_hint 可选提示(已不需要,用于兼容)
* @return array ['goods_id' => int, 'ticket_id' => int]
* @throws \Exception 解码失败时抛出
@ -453,7 +452,6 @@ class BaseService
public static function shortCodeDecode(string $code, ?int $goods_id_hint = null): array
{
$code = strtolower($code);
// 前4位明文 goods_id
$goods_part = substr($code, 0, 4);
$goods_id = intval($goods_part, 36);
@ -466,17 +464,11 @@ class BaseService
// 用 goods_id 派生 key
$key = self::getGoodsKey($goods_id);
// 后5位混淆的 ticket_id → Feistel 解密
$ticket_part = substr($code, 4, 5);
// 后部:变长混淆 ticket_id → Feistel 解密
$ticket_part = substr($code, 4);
$ticket_int = self::feistelDecode($ticket_part, $key);
// 转回字符串确保5位然后 decode
$ticket_id = intval(str_pad(base_convert($ticket_int, 10, 36), 5, '0', STR_PAD_LEFT), 36);
return [
'goods_id' => $goods_id,
'ticket_id' => $ticket_id,
];
}
// 转回 base36 字符串(不填充)
$ticket_id = intval(base_convert($ticket_int, 10, 36), 36);
/**
* 签名 QR payloadHMAC-SHA256 防篡改)

View File

@ -82,22 +82,23 @@ function shortCodeEncode(int $goods_id, int $ticket_id): string
if ($goods_id > 0xFFFFFF) {
throw new Exception("goods_id 超出范围 (max=1679615), given={$goods_id}");
}
// 校验 ticket_id 不超过 5位 base36 (0x3FFFFFFF = 1073741823)
if ($ticket_id > 0x3FFFFFFF) {
throw new Exception("ticket_id 超出范围 (max=1073741823), given={$ticket_id}");
if ($ticket_id <= 0) {
throw new Exception("ticket_id 必须为正整数, given={$ticket_id}");
}
// goods_id 固定4位 base36明文
$goods_part = str_pad(base_convert($goods_id, 10, 36), 4, '0', STR_PAD_LEFT);
// ticket_id 可变长度(不填充)
$ticket_part = base_convert($ticket_id, 10, 36);
// ticket_id 混淆
$ticket_int = intval(str_pad(base_convert($ticket_id, 10, 36), 5, '0', STR_PAD_LEFT), 36);
$ticket_int = intval($ticket_part, 36);
$key = getGoodsKey($goods_id);
$obfuscated = feistelEncode($ticket_int, $key);
$ticket_part = str_pad($obfuscated, 5, '0', STR_PAD_LEFT);
// 拼接前4位明文 goods_id + 后5位混淆 ticket_id
return strtolower($goods_part . $ticket_part);
// 拼接前4位明文 goods_id + 变长混淆 ticket_id
return strtolower($goods_part . $obfuscated);
}
function shortCodeDecode(string $code, ?int $goods_id_hint = null): array
@ -116,10 +117,10 @@ function shortCodeDecode(string $code, ?int $goods_id_hint = null): array
// 用 goods_id 派生 key
$key = getGoodsKey($goods_id);
// 后5位混淆的 ticket_id → Feistel 解密
$ticket_part = substr($code, 4, 5);
// 后部:变长混淆 ticket_id → Feistel 解密
$ticket_part = substr($code, 4);
$ticket_int = feistelDecode($ticket_part, $key);
$ticket_id = intval(str_pad(base_convert($ticket_int, 10, 36), 5, '0', STR_PAD_LEFT), 36);
$ticket_id = intval(base_convert($ticket_int, 10, 36), 36);
return ['goods_id' => $goods_id, 'ticket_id' => $ticket_id];
}
@ -259,18 +260,8 @@ $expired_signed = signQrPayload($expired_payload);
$verified = verifyQrPayload($expired_signed);
assert_true($verified === null, "QR过期测试: 已过期应返回null");
// Test 7: 边界条件 - ticket_id 超出5位 base36
// Test 7: goods_id 超出4位 base36
echo "\n--- 边界条件测试 ---\n";
try {
shortCodeEncode(118, 1073741824); // 超出5位 base36
echo "❌ FAIL: ticket_id超出范围应抛出异常\n";
$failed++;
} catch (Exception $e) {
echo "✅ PASS: ticket_id超出范围正确抛出异常\n";
$passed++;
}
// Test 7b: goods_id 超出4位 base36
try {
shortCodeEncode(2000000, 100); // goods_id=2000000 > 1679615
echo "❌ FAIL: goods_id超出范围应抛出异常\n";
@ -280,6 +271,16 @@ try {
$passed++;
}
// Test 7b: ticket_id 最小值
try {
shortCodeEncode(118, 0); // ticket_id=0 无效
echo "❌ FAIL: ticket_id=0应抛出异常\n";
$failed++;
} catch (Exception $e) {
echo "✅ PASS: ticket_id=0正确抛出异常\n";
$passed++;
}
// Test 7c: 默认密钥异常
echo "\n--- 默认密钥异常测试 ---\n";
// 临时清除环境变量
@ -299,12 +300,13 @@ try {
putenv("VR_TICKET_SECRET={$orig_secret}");
}
// Test 8: ticket_id 最大5位 base36值
$max_ticket = 1073741823; // 0x3FFFFFFF
$code = shortCodeEncode(118, $max_ticket);
// Test 8: ticket_id 变长展示不受5位限制
$big_ticket = 1000000000; // 10亿
$code = shortCodeEncode(118, $big_ticket);
echo "短码长度: " . strlen($code) . "\n";
$decoded = shortCodeDecode($code);
assert_equals(118, $decoded['goods_id'], "大ticket_id: goods_id");
assert_equals($max_ticket, $decoded['ticket_id'], "最大ticket_id: ticket_id = 1073741823");
assert_equals(118, $decoded['goods_id'], "变长ticket_id: goods_id");
assert_equals($big_ticket, $decoded['ticket_id'], "大变长ticket_id: ticket_id = 1000000000");
// Test 9: 不同商品 key 不同
echo "\n--- Per-goods key 隔离测试 ---\n";