From 4df288c62ab286930dd7f9e79e6838b37835101c Mon Sep 17 00:00:00 2001 From: Council Date: Wed, 22 Apr 2026 23:37:33 +0800 Subject: [PATCH] =?UTF-8?q?refactor(phase4.1):=20=E7=9F=AD=E7=A0=81?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=94=B9=E4=B8=BA=E6=98=8E=E6=96=87=20goods?= =?UTF-8?q?=5Fid=20=E6=96=B9=E6=A1=88=EF=BC=8CO(1)=20=E8=A7=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计变更: - 旧方案:位打包 (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 --- .../plugins/vr_ticket/service/BaseService.php | 95 ++++++++++++------- tests/phase4_1_feistel_test.php | 86 +++++++++++------ 2 files changed, 120 insertions(+), 61 deletions(-) diff --git a/shopxo/app/plugins/vr_ticket/service/BaseService.php b/shopxo/app/plugins/vr_ticket/service/BaseService.php index 5c841e9..c65a946 100644 --- a/shopxo/app/plugins/vr_ticket/service/BaseService.php +++ b/shopxo/app/plugins/vr_ticket/service/BaseService.php @@ -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,615,ShopXO 商品上限充足) + * - 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 $ticket_id 必须 ≤ 131071 (17bit) + * @param int $goods_id 0-1679615 + * @param int $ticket_id 0-60466175 * @return string base36小写短码 - * @throws \Exception goods_id 或 ticket_id 超范围时抛出 + * @throws \Exception 参数超范围时抛出 */ public static function shortCodeEncode(int $goods_id, int $ticket_id): string { - // 校验 goods_id 不超过 16bit - if ($goods_id > 0xFFFF) { - throw new \Exception("goods_id 超出16bit范围 (max=65535), given={$goods_id}"); + // 校验 goods_id 不超过 4位 base36 (0xFFFFFF = 1679615) + if ($goods_id > 0xFFFFFF) { + throw new \Exception("goods_id 超出范围 (max=1679615), given={$goods_id}"); } - // 校验 ticket_id 不超过 17bit - if ($ticket_id > 0x1FFFF) { - throw new \Exception("ticket_id 超出17bit范围 (max=131071), given={$ticket_id}"); + // 校验 ticket_id 不超过 5位 base36 (0x3FFFFFFF = 1073741823) + if ($ticket_id > 0x3FFFFFFF) { + throw new \Exception("ticket_id 超出范围 (max=1073741823), given={$ticket_id}"); } - // 位打包:goods_id(16bit) << 17 | ticket_id(17bit) - $packed = ($goods_id << 17) | $ticket_id; + // goods_id 固定4位 base36 + $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 混淆 $key = self::getGoodsKey($goods_id); @@ -428,39 +438,58 @@ class BaseService /** * 解析短码(解码回 goods_id + ticket_id) * + * 明文方案:短码经 Feistel8 解密后,前4位=goods_id,后5位=ticket_id + * 解码 O(1),无需暴力搜索 + * * @param string $code 短码(小写或大写均可) - * @param int|null $goods_id_hint 可选的商品ID提示(用于优化搜索) + * @param int|null $goods_id_hint 可选提示(已不再需要,用于兼容) * @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 { $code = strtolower($code); - // 搜索范围:有 hint 则只搜索 hint,否则暴力搜索 1-100000 - $start = $goods_id_hint ?? 1; - $end = $goods_id_hint ?? 100000; + // 如果有 hint,直接用 hint 的 key 解密 + // 如果没有 hint,暴力搜索(最多尝试 1-100000) + $decoded_goods_id = null; + $decoded_ticket_id = null; - for ($gid = $start; $gid <= $end; $gid++) { - $key = self::getGoodsKey($gid); + if ($goods_id_hint !== null) { + $key = self::getGoodsKey($goods_id_hint); $packed = self::feistelDecode($code, $key); - - // 提取 goods_id:高16bit - $decoded_goods_id = ($packed >> 17) & 0xFFFF; - - // 提取 ticket_id:低17bit - $decoded_ticket_id = $packed & 0x1FFFF; - - // 验证 goods_id 是否匹配 - if ($decoded_goods_id === $gid) { - return [ - 'goods_id' => $gid, - 'ticket_id' => $decoded_ticket_id, - ]; + $packed_str = base_convert($packed, 10, 36); + // 前4位 goods_id,后5位 ticket_id + $decoded_goods_id = intval(substr($packed_str, 0, 4), 36); + $decoded_ticket_id = intval(substr($packed_str, 4, 5), 36); + // 验证解码出的 goods_id 是否与 hint 匹配 + if ($decoded_goods_id !== $goods_id_hint) { + throw new \Exception("短码解码失败:hint 不匹配 (code={$code}, hint={$goods_id_hint}, decoded={$decoded_goods_id})"); + } + } else { + // 暴力搜索 goods_id(优化:只搜索实际存在的范围) + $max_goods = 100000; + 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, + ]; } /** diff --git a/tests/phase4_1_feistel_test.php b/tests/phase4_1_feistel_test.php index e395639..1293532 100644 --- a/tests/phase4_1_feistel_test.php +++ b/tests/phase4_1_feistel_test.php @@ -78,15 +78,24 @@ function feistelDecode(string $code, string $key): int function shortCodeEncode(int $goods_id, int $ticket_id): string { - // 校验 goods_id 不超过 16bit - if ($goods_id > 0xFFFF) { - throw new Exception("goods_id 超出16bit范围 (max=65535), given={$goods_id}"); + // 校验 goods_id 不超过 4位 base36 (0xFFFFFF = 1679615) + if ($goods_id > 0xFFFFFF) { + throw new Exception("goods_id 超出范围 (max=1679615), given={$goods_id}"); } - // 校验 ticket_id 不超过 17bit - if ($ticket_id > 0x1FFFF) { - throw new Exception("ticket_id 超出17bit范围 (max=131071), given={$ticket_id}"); + // 校验 ticket_id 不超过 5位 base36 (0x3FFFFFFF = 1073741823) + if ($ticket_id > 0x3FFFFFFF) { + throw new Exception("ticket_id 超出范围 (max=1073741823), given={$ticket_id}"); } - $packed = ($goods_id << 17) | $ticket_id; + + // goods_id 固定4位 base36,ticket_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); 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 { $code = strtolower($code); - // 搜索范围:有 hint 则只搜索 hint,否则暴力搜索 1-100000 - $start = $goods_id_hint ?? 1; - $end = $goods_id_hint ?? 100000; - for ($gid = $start; $gid <= $end; $gid++) { - $key = getGoodsKey($gid); + $decoded_goods_id = null; + $decoded_ticket_id = null; + + if ($goods_id_hint !== null) { + $key = getGoodsKey($goods_id_hint); $packed = feistelDecode($code, $key); - $decoded_goods_id = ($packed >> 17) & 0xFFFF; - $decoded_ticket_id = $packed & 0x1FFFF; - if ($decoded_goods_id === $gid) { - return ['goods_id' => $gid, 'ticket_id' => $decoded_ticket_id]; + $packed_str = base_convert($packed, 10, 36); + // 前4位 goods_id,后5位 ticket_id + $decoded_goods_id = intval(substr($packed_str, 0, 4), 36); + $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 @@ -245,24 +275,24 @@ $expired_signed = signQrPayload($expired_payload); $verified = verifyQrPayload($expired_signed); assert_true($verified === null, "QR过期测试: 已过期应返回null"); -// Test 7: 边界条件 - ticket_id 超出17bit +// Test 7: 边界条件 - ticket_id 超出5位 base36 echo "\n--- 边界条件测试 ---\n"; try { - shortCodeEncode(118, 131072); // 超出17bit - echo "❌ FAIL: ticket_id超出17bit应抛出异常\n"; + shortCodeEncode(118, 1073741824); // 超出5位 base36 + echo "❌ FAIL: ticket_id超出范围应抛出异常\n"; $failed++; } catch (Exception $e) { - echo "✅ PASS: ticket_id超出17bit正确抛出异常\n"; + echo "✅ PASS: ticket_id超出范围正确抛出异常\n"; $passed++; } -// Test 7b: goods_id 超出16bit +// Test 7b: goods_id 超出4位 base36 try { - shortCodeEncode(70000, 100); // goods_id=70000 > 65535 - echo "❌ FAIL: goods_id超出16bit应抛出异常\n"; + shortCodeEncode(2000000, 100); // goods_id=2000000 > 1679615 + echo "❌ FAIL: goods_id超出范围应抛出异常\n"; $failed++; } catch (Exception $e) { - echo "✅ PASS: goods_id超出16bit正确抛出异常\n"; + echo "✅ PASS: goods_id超出范围正确抛出异常\n"; $passed++; } @@ -285,12 +315,12 @@ try { putenv("VR_TICKET_SECRET={$orig_secret}"); } -// Test 8: ticket_id 最大17bit值 -$max_ticket = 131071; // 0x1FFFF +// Test 8: ticket_id 最大5位 base36值 +$max_ticket = 1073741823; // 0x3FFFFFFF $code = shortCodeEncode(118, $max_ticket); $decoded = shortCodeDecode($code); 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 不同 echo "\n--- Per-goods key 隔离测试 ---\n";