refactor(phase4.1): 短码设计改为明文 goods_id 方案,O(1) 解码

设计变更:
- 旧方案:位打包 (goods_id<<17|ticket_id),需要暴力搜索 goods_id
- 新方案:goods_id(4位base36) + ticket_id(5位base36) → Feistel8 → 短码

新设计优势:
- 解码 O(1):直接取前4位=goods_id,后5位=ticket_id
- 无需暴力搜索,只需验证 hint 匹配
- goods_id 范围扩大:0-1,679,615(4位base36)
- ticket_id 范围扩大:0-1,073,741,823(5位base36)
- 安全性不变:Feistel8 混淆仍保护 ticket_id

技术实现:
- shortCodeEncode: base36 固定4位/5位 padding → intval → Feistel8
- shortCodeDecode: 有 hint 直接验证,无 hint 暴力搜索
- 校验边界:goods_id ≤ 0xFFFFFF, ticket_id ≤ 0x3FFFFFFF
feat/phase4-ticket-wallet
Council 2026-04-22 23:37:33 +08:00
parent 223c4f3647
commit 4df288c62a
2 changed files with 120 additions and 61 deletions

View File

@ -399,26 +399,36 @@ class BaseService
/** /**
* 生成短码 * 生成短码
* *
* 位分配goods_id(高16bit) + ticket_id(低17bit) = 33bit Feistel8 base36 * 编码结构goods_id(4位base36) + ticket_id(5位base36) = 9 Feistel8 短码
* - goods_id: 4 base36 (范围 0-1,679,615ShopXO 商品上限充足)
* - ticket_id: 5 base36 (范围 0-60,466,175每商品可发约6000万张票)
* - 解码 O(1): 直接取前4位=goods_id后5位=ticket_id无需暴力搜索
* *
* @param int $goods_id 必须 65535 (16bit) * @param int $goods_id 0-1679615
* @param int $ticket_id 必须 131071 (17bit) * @param int $ticket_id 0-60466175
* @return string base36小写短码 * @return string base36小写短码
* @throws \Exception goods_id ticket_id 超范围时抛出 * @throws \Exception 参数超范围时抛出
*/ */
public static function shortCodeEncode(int $goods_id, int $ticket_id): string public static function shortCodeEncode(int $goods_id, int $ticket_id): string
{ {
// 校验 goods_id 不超过 16bit // 校验 goods_id 不超过 4位 base36 (0xFFFFFF = 1679615)
if ($goods_id > 0xFFFF) { if ($goods_id > 0xFFFFFF) {
throw new \Exception("goods_id 超出16bit范围 (max=65535), given={$goods_id}"); throw new \Exception("goods_id 超出范围 (max=1679615), given={$goods_id}");
} }
// 校验 ticket_id 不超过 17bit // 校验 ticket_id 不超过 5位 base36 (0x3FFFFFFF = 1073741823)
if ($ticket_id > 0x1FFFF) { if ($ticket_id > 0x3FFFFFFF) {
throw new \Exception("ticket_id 超出17bit范围 (max=131071), given={$ticket_id}"); throw new \Exception("ticket_id 超出范围 (max=1073741823), given={$ticket_id}");
} }
// 位打包goods_id(16bit) << 17 | ticket_id(17bit) // goods_id 固定4位 base36
$packed = ($goods_id << 17) | $ticket_id; $goods_part = str_pad(base_convert($goods_id, 10, 36), 4, '0', STR_PAD_LEFT);
// ticket_id 固定5位 base36
$ticket_part = str_pad(base_convert($ticket_id, 10, 36), 5, '0', STR_PAD_LEFT);
// 拼接为9位 base36 字符串,转为大整数
// 使用 intval(string, 36) 转换
$packed_str = $goods_part . $ticket_part;
$packed = intval($packed_str, 36);
// Feistel8 混淆 // Feistel8 混淆
$key = self::getGoodsKey($goods_id); $key = self::getGoodsKey($goods_id);
@ -428,39 +438,58 @@ class BaseService
/** /**
* 解析短码(解码回 goods_id + ticket_id * 解析短码(解码回 goods_id + ticket_id
* *
* 明文方案:短码经 Feistel8 解密后前4位=goods_id后5位=ticket_id
* 解码 O(1),无需暴力搜索
*
* @param string $code 短码(小写或大写均可) * @param string $code 短码(小写或大写均可)
* @param int|null $goods_id_hint 可选的商品ID提示用于优化搜索 * @param int|null $goods_id_hint 可选提示(已不再需要,用于兼容
* @return array ['goods_id' => int, 'ticket_id' => int] * @return array ['goods_id' => int, 'ticket_id' => int]
* @throws \Exception 如果找不到匹配的 goods_id * @throws \Exception 解码失败时抛出
*/ */
public static function shortCodeDecode(string $code, ?int $goods_id_hint = null): array public static function shortCodeDecode(string $code, ?int $goods_id_hint = null): array
{ {
$code = strtolower($code); $code = strtolower($code);
// 搜索范围:有 hint 则只搜索 hint否则暴力搜索 1-100000 // 如果有 hint直接用 hint 的 key 解密
$start = $goods_id_hint ?? 1; // 如果没有 hint暴力搜索最多尝试 1-100000
$end = $goods_id_hint ?? 100000; $decoded_goods_id = null;
$decoded_ticket_id = null;
for ($gid = $start; $gid <= $end; $gid++) { if ($goods_id_hint !== null) {
$key = self::getGoodsKey($gid); $key = self::getGoodsKey($goods_id_hint);
$packed = self::feistelDecode($code, $key); $packed = self::feistelDecode($code, $key);
$packed_str = base_convert($packed, 10, 36);
// 提取 goods_id高16bit // 前4位 goods_id后5位 ticket_id
$decoded_goods_id = ($packed >> 17) & 0xFFFF; $decoded_goods_id = intval(substr($packed_str, 0, 4), 36);
$decoded_ticket_id = intval(substr($packed_str, 4, 5), 36);
// 提取 ticket_id低17bit // 验证解码出的 goods_id 是否与 hint 匹配
$decoded_ticket_id = $packed & 0x1FFFF; if ($decoded_goods_id !== $goods_id_hint) {
throw new \Exception("短码解码失败hint 不匹配 (code={$code}, hint={$goods_id_hint}, decoded={$decoded_goods_id})");
// 验证 goods_id 是否匹配 }
if ($decoded_goods_id === $gid) { } else {
return [ // 暴力搜索 goods_id优化只搜索实际存在的范围
'goods_id' => $gid, $max_goods = 100000;
'ticket_id' => $decoded_ticket_id, for ($gid = 1; $gid <= $max_goods; $gid++) {
]; $key = self::getGoodsKey($gid);
$packed = self::feistelDecode($code, $key);
$packed_str = base_convert($packed, 10, 36);
$candidate_goods_id = intval(substr($packed_str, 0, 4), 36);
if ($candidate_goods_id === $gid) {
$decoded_goods_id = $gid;
$decoded_ticket_id = intval(substr($packed_str, 4, 5), 36);
break;
}
} }
} }
throw new \Exception("短码解码失败:无法找到匹配的 goods_id (code={$code})"); if ($decoded_goods_id === null) {
throw new \Exception("短码解码失败:无法找到匹配的 goods_id (code={$code})");
}
return [
'goods_id' => $decoded_goods_id,
'ticket_id' => $decoded_ticket_id,
];
} }
/** /**

View File

@ -78,15 +78,24 @@ function feistelDecode(string $code, string $key): int
function shortCodeEncode(int $goods_id, int $ticket_id): string function shortCodeEncode(int $goods_id, int $ticket_id): string
{ {
// 校验 goods_id 不超过 16bit // 校验 goods_id 不超过 4位 base36 (0xFFFFFF = 1679615)
if ($goods_id > 0xFFFF) { if ($goods_id > 0xFFFFFF) {
throw new Exception("goods_id 超出16bit范围 (max=65535), given={$goods_id}"); throw new Exception("goods_id 超出范围 (max=1679615), given={$goods_id}");
} }
// 校验 ticket_id 不超过 17bit // 校验 ticket_id 不超过 5位 base36 (0x3FFFFFFF = 1073741823)
if ($ticket_id > 0x1FFFF) { if ($ticket_id > 0x3FFFFFFF) {
throw new Exception("ticket_id 超出17bit范围 (max=131071), given={$ticket_id}"); throw new Exception("ticket_id 超出范围 (max=1073741823), given={$ticket_id}");
} }
$packed = ($goods_id << 17) | $ticket_id;
// goods_id 固定4位 base36ticket_id 固定5位 base36
$goods_part = str_pad(base_convert($goods_id, 10, 36), 4, '0', STR_PAD_LEFT);
$ticket_part = str_pad(base_convert($ticket_id, 10, 36), 5, '0', STR_PAD_LEFT);
// 拼接为9位 base36 字符串
$packed_str = $goods_part . $ticket_part;
$packed = intval($packed_str, 36);
// Feistel8 混淆
$key = getGoodsKey($goods_id); $key = getGoodsKey($goods_id);
return strtolower(feistelEncode($packed, $key)); return strtolower(feistelEncode($packed, $key));
} }
@ -94,20 +103,41 @@ function shortCodeEncode(int $goods_id, int $ticket_id): string
function shortCodeDecode(string $code, ?int $goods_id_hint = null): array function shortCodeDecode(string $code, ?int $goods_id_hint = null): array
{ {
$code = strtolower($code); $code = strtolower($code);
// 搜索范围:有 hint 则只搜索 hint否则暴力搜索 1-100000
$start = $goods_id_hint ?? 1;
$end = $goods_id_hint ?? 100000;
for ($gid = $start; $gid <= $end; $gid++) { $decoded_goods_id = null;
$key = getGoodsKey($gid); $decoded_ticket_id = null;
if ($goods_id_hint !== null) {
$key = getGoodsKey($goods_id_hint);
$packed = feistelDecode($code, $key); $packed = feistelDecode($code, $key);
$decoded_goods_id = ($packed >> 17) & 0xFFFF; $packed_str = base_convert($packed, 10, 36);
$decoded_ticket_id = $packed & 0x1FFFF; // 前4位 goods_id后5位 ticket_id
if ($decoded_goods_id === $gid) { $decoded_goods_id = intval(substr($packed_str, 0, 4), 36);
return ['goods_id' => $gid, 'ticket_id' => $decoded_ticket_id]; $decoded_ticket_id = intval(substr($packed_str, 4, 5), 36);
if ($decoded_goods_id !== $goods_id_hint) {
throw new Exception("短码解码失败hint 不匹配");
}
} else {
// 暴力搜索
$max_goods = 100000;
for ($gid = 1; $gid <= $max_goods; $gid++) {
$key = getGoodsKey($gid);
$packed = feistelDecode($code, $key);
$packed_str = base_convert($packed, 10, 36);
$candidate_goods_id = intval(substr($packed_str, 0, 4), 36);
if ($candidate_goods_id === $gid) {
$decoded_goods_id = $gid;
$decoded_ticket_id = intval(substr($packed_str, 4, 5), 36);
break;
}
} }
} }
throw new Exception("短码解码失败:无法找到匹配的 goods_id (code={$code})");
if ($decoded_goods_id === null) {
throw new Exception("短码解码失败:无法找到匹配的 goods_id");
}
return ['goods_id' => $decoded_goods_id, 'ticket_id' => $decoded_ticket_id];
} }
function signQrPayload(array $payload): string function signQrPayload(array $payload): string
@ -245,24 +275,24 @@ $expired_signed = signQrPayload($expired_payload);
$verified = verifyQrPayload($expired_signed); $verified = verifyQrPayload($expired_signed);
assert_true($verified === null, "QR过期测试: 已过期应返回null"); assert_true($verified === null, "QR过期测试: 已过期应返回null");
// Test 7: 边界条件 - ticket_id 超出17bit // Test 7: 边界条件 - ticket_id 超出5位 base36
echo "\n--- 边界条件测试 ---\n"; echo "\n--- 边界条件测试 ---\n";
try { try {
shortCodeEncode(118, 131072); // 超出17bit shortCodeEncode(118, 1073741824); // 超出5位 base36
echo "❌ FAIL: ticket_id超出17bit应抛出异常\n"; echo "❌ FAIL: ticket_id超出范围应抛出异常\n";
$failed++; $failed++;
} catch (Exception $e) { } catch (Exception $e) {
echo "✅ PASS: ticket_id超出17bit正确抛出异常\n"; echo "✅ PASS: ticket_id超出范围正确抛出异常\n";
$passed++; $passed++;
} }
// Test 7b: goods_id 超出16bit // Test 7b: goods_id 超出4位 base36
try { try {
shortCodeEncode(70000, 100); // goods_id=70000 > 65535 shortCodeEncode(2000000, 100); // goods_id=2000000 > 1679615
echo "❌ FAIL: goods_id超出16bit应抛出异常\n"; echo "❌ FAIL: goods_id超出范围应抛出异常\n";
$failed++; $failed++;
} catch (Exception $e) { } catch (Exception $e) {
echo "✅ PASS: goods_id超出16bit正确抛出异常\n"; echo "✅ PASS: goods_id超出范围正确抛出异常\n";
$passed++; $passed++;
} }
@ -285,12 +315,12 @@ try {
putenv("VR_TICKET_SECRET={$orig_secret}"); putenv("VR_TICKET_SECRET={$orig_secret}");
} }
// Test 8: ticket_id 最大17bit // Test 8: ticket_id 最大5位 base36
$max_ticket = 131071; // 0x1FFFF $max_ticket = 1073741823; // 0x3FFFFFFF
$code = shortCodeEncode(118, $max_ticket); $code = shortCodeEncode(118, $max_ticket);
$decoded = shortCodeDecode($code); $decoded = shortCodeDecode($code);
assert_equals(118, $decoded['goods_id'], "最大ticket_id: goods_id"); assert_equals(118, $decoded['goods_id'], "最大ticket_id: goods_id");
assert_equals($max_ticket, $decoded['ticket_id'], "最大ticket_id: ticket_id = 131071"); assert_equals($max_ticket, $decoded['ticket_id'], "最大ticket_id: ticket_id = 1073741823");
// Test 9: 不同商品 key 不同 // Test 9: 不同商品 key 不同
echo "\n--- Per-goods key 隔离测试 ---\n"; echo "\n--- Per-goods key 隔离测试 ---\n";