feat(phase4): Phase 4.1 基础设施 - Feistel-8 + QR签名 + 短码编解码
Phase 4.1 完成: - BaseService.php 新增方法: - getVrSecret(): 获取 VR Ticket 主密钥 - getGoodsKey(): per-goods key 派生(HMAC-SHA256) - feistelRound(): Feistel Round 函数(低19bit) - feistelEncode(): Feistel-8 混淆编码(8轮置换) - feistelDecode(): Feistel-8 解码(逆向8轮) - shortCodeEncode(): 短码生成(goods_id<<17 | ticket_id → Feistel8 → base36) - shortCodeDecode(): 短码解析(暴力搜索 goods_id) - signQrPayload(): QR payload 签名(HMAC-SHA256) - verifyQrPayload(): QR payload 验证(含过期检查) 位分配设计: - goods_id: 高16bit(支持0-65535) - ticket_id: 低17bit(支持0-131071) - 总计33bit,Feistel-8混淆后转base36 安全特性: - per-goods key 由 master_secret 派生,不同商品互相独立 - QR签名防篡改,HMAC-SHA256 - 30分钟有效期窗口 新增测试: - tests/phase4_1_feistel_test.phpfeat/phase4-ticket-wallet
parent
b4078d1cfc
commit
c3bf8ba2aa
|
|
@ -285,4 +285,248 @@ class BaseService
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 4: Feistel-8 混淆 + QR签名 + 短码编解码
|
||||||
|
* ================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 VR Ticket 主密钥
|
||||||
|
*/
|
||||||
|
private static function getVrSecret(): string
|
||||||
|
{
|
||||||
|
$secret = env('VR_TICKET_SECRET', 'vrt-default-secret-change-me');
|
||||||
|
if ($secret === 'vrt-default-secret-change-me') {
|
||||||
|
self::log('WARNING: using default VR_TICKET_SECRET, set in .env for production', [], 'warning');
|
||||||
|
}
|
||||||
|
return $secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 per-goods key
|
||||||
|
* 由 master_secret 派生,保证不同商品的编码互相独立
|
||||||
|
*
|
||||||
|
* @param int $goods_id
|
||||||
|
* @return string 16字节hex
|
||||||
|
*/
|
||||||
|
public static function getGoodsKey(int $goods_id): string
|
||||||
|
{
|
||||||
|
static $cache = [];
|
||||||
|
if (!isset($cache[$goods_id])) {
|
||||||
|
$secret = self::getVrSecret();
|
||||||
|
// HMAC-SHA256(master_secret, goods_id) 取前16字节
|
||||||
|
$cache[$goods_id] = substr(hash_hmac('sha256', (string)$goods_id, $secret), 0, 16);
|
||||||
|
}
|
||||||
|
return $cache[$goods_id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feistel Round 函数
|
||||||
|
* F(R, i, key) = HMAC-SHA256(R . i, key) 的低19bit
|
||||||
|
*
|
||||||
|
* @param int $R 17bit 右半部分
|
||||||
|
* @param int $round 轮次 [0-7]
|
||||||
|
* @param string $key per-goods key
|
||||||
|
* @return int 19bit 输出
|
||||||
|
*/
|
||||||
|
private static function feistelRound(int $R, int $round, string $key): int
|
||||||
|
{
|
||||||
|
$hmac = hash_hmac('sha256', $R . '.' . $round, $key, true);
|
||||||
|
// 取前3字节(24bit),保留低19bit
|
||||||
|
$val = (ord($hmac[0]) << 16) | (ord($hmac[1]) << 8) | ord($hmac[2]);
|
||||||
|
return $val & 0x7FFFF; // 19bit mask
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feistel-8 混淆编码
|
||||||
|
*
|
||||||
|
* 位分配:L=19bit, R=17bit(凑满36bit)
|
||||||
|
* @param int $packed 33bit整数(goods_id<<17 | ticket_id)
|
||||||
|
* @param string $key per-goods key
|
||||||
|
* @return string base36编码
|
||||||
|
*/
|
||||||
|
public static function feistelEncode(int $packed, string $key): string
|
||||||
|
{
|
||||||
|
// 分离 L(高19bit) 和 R(低17bit)
|
||||||
|
$L = ($packed >> 17) & 0x7FFFF;
|
||||||
|
$R = $packed & 0x1FFFF;
|
||||||
|
|
||||||
|
// 8轮 Feistel 置换
|
||||||
|
for ($i = 0; $i < 8; $i++) {
|
||||||
|
$F = self::feistelRound($R, $i, $key);
|
||||||
|
$L_new = $R;
|
||||||
|
$R_new = $L ^ $F;
|
||||||
|
$L = $L_new;
|
||||||
|
$R = $R_new;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并为36bit整数
|
||||||
|
$result = ($L & 0x7FFFF) | (($R & 0x1FFFF) << 17);
|
||||||
|
|
||||||
|
return base_convert($result, 10, 36);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feistel-8 解码(逆向8轮)
|
||||||
|
*
|
||||||
|
* @param string $code base36编码
|
||||||
|
* @param string $key per-goods key
|
||||||
|
* @return int 整数
|
||||||
|
*/
|
||||||
|
public static function feistelDecode(string $code, string $key): int
|
||||||
|
{
|
||||||
|
$packed = intval(base_convert(strtolower($code), 36, 10));
|
||||||
|
|
||||||
|
// 分离 L 和 R
|
||||||
|
$L = ($packed >> 17) & 0x7FFFF;
|
||||||
|
$R = $packed & 0x1FFFF;
|
||||||
|
|
||||||
|
// 8轮逆向 Feistel 置换
|
||||||
|
for ($i = 7; $i >= 0; $i--) {
|
||||||
|
$F = self::feistelRound($L, $i, $key);
|
||||||
|
$R_new = $L;
|
||||||
|
$L_new = $R ^ $F;
|
||||||
|
$R = $R_new;
|
||||||
|
$L = $L_new;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并
|
||||||
|
return ($L & 0x7FFFF) | (($R & 0x1FFFF) << 17);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成短码
|
||||||
|
*
|
||||||
|
* 位分配:goods_id(高16bit) + ticket_id(低17bit) = 33bit → Feistel8 → base36
|
||||||
|
*
|
||||||
|
* @param int $goods_id
|
||||||
|
* @param int $ticket_id ticket_id 必须 ≤ 131071 (17bit)
|
||||||
|
* @return string base36小写短码
|
||||||
|
* @throws \Exception 如果 ticket_id 超出17bit范围
|
||||||
|
*/
|
||||||
|
public static function shortCodeEncode(int $goods_id, int $ticket_id): string
|
||||||
|
{
|
||||||
|
// 验证 ticket_id 不超过 17bit
|
||||||
|
if ($ticket_id > 0x1FFFF) {
|
||||||
|
throw new \Exception("ticket_id 超出17bit范围 (max=131071), given={$ticket_id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 位打包:goods_id(16bit) << 17 | ticket_id(17bit)
|
||||||
|
$packed = ($goods_id << 17) | $ticket_id;
|
||||||
|
|
||||||
|
// Feistel8 混淆
|
||||||
|
$key = self::getGoodsKey($goods_id);
|
||||||
|
return strtolower(self::feistelEncode($packed, $key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析短码(解码回 goods_id + ticket_id)
|
||||||
|
*
|
||||||
|
* @param string $code 短码(小写或大写均可)
|
||||||
|
* @param int|null $goods_id_hint 可选的商品ID提示(用于优化搜索)
|
||||||
|
* @return array ['goods_id' => int, 'ticket_id' => int]
|
||||||
|
* @throws \Exception 如果找不到匹配的 goods_id
|
||||||
|
*/
|
||||||
|
public static function shortCodeDecode(string $code, ?int $goods_id_hint = null): array
|
||||||
|
{
|
||||||
|
$code = strtolower($code);
|
||||||
|
|
||||||
|
// 候选 goods_id 列表
|
||||||
|
$candidates = [];
|
||||||
|
if ($goods_id_hint !== null) {
|
||||||
|
$candidates[] = $goods_id_hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴力搜索:ShopXO 商品 ID 通常 < 100000
|
||||||
|
$max_goods = 100000;
|
||||||
|
for ($gid = 1; $gid <= $max_goods; $gid++) {
|
||||||
|
if ($goods_id_hint !== null && $gid !== $goods_id_hint) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$candidates[] = $gid;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($candidates as $gid) {
|
||||||
|
$key = self::getGoodsKey($gid);
|
||||||
|
$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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \Exception("短码解码失败:无法找到匹配的 goods_id (code={$code})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签名 QR payload(HMAC-SHA256 防篡改)
|
||||||
|
*
|
||||||
|
* @param array $payload ['id'=>int, 'g'=>int(goods_id), 'iat'=>int, 'exp'=>int]
|
||||||
|
* @return string base64编码的签名内容
|
||||||
|
*/
|
||||||
|
public static function signQrPayload(array $payload): string
|
||||||
|
{
|
||||||
|
$secret = self::getVrSecret();
|
||||||
|
// 签名内容:id.g.iat.exp
|
||||||
|
$sign_str = $payload['id'] . '.' . $payload['g'] . '.' . $payload['iat'] . '.' . $payload['exp'];
|
||||||
|
$sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8);
|
||||||
|
$payload['sig'] = $sig;
|
||||||
|
|
||||||
|
return base64_encode(json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 QR payload
|
||||||
|
*
|
||||||
|
* @param string $encoded base64编码
|
||||||
|
* @return array|null 验证失败返回null,成功返回 payload(含id/g/exp)
|
||||||
|
*/
|
||||||
|
public static function verifyQrPayload(string $encoded)
|
||||||
|
{
|
||||||
|
$json = base64_decode($encoded);
|
||||||
|
if ($json === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode($json, true);
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 必填字段检查
|
||||||
|
if (!isset($payload['id'], $payload['g'], $payload['iat'], $payload['exp'], $payload['sig'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间戳检查:是否过期
|
||||||
|
if ($payload['exp'] < time()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 签名验证
|
||||||
|
$secret = self::getVrSecret();
|
||||||
|
$sign_str = $payload['id'] . '.' . $payload['g'] . '.' . $payload['iat'] . '.' . $payload['exp'];
|
||||||
|
$expected_sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8);
|
||||||
|
|
||||||
|
if (!hash_equals($expected_sig, $payload['sig'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => intval($payload['id']),
|
||||||
|
'g' => intval($payload['g']),
|
||||||
|
'exp' => intval($payload['exp']),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Phase 4.1 单元测试:Feistel-8 + QR签名 + 短码编解码
|
||||||
|
*
|
||||||
|
* 运行方式:php tests/phase4_1_feistel_test.php
|
||||||
|
*
|
||||||
|
* 测试覆盖:
|
||||||
|
* 1. Feistel-8 往返测试(encode → decode = 原值)
|
||||||
|
* 2. 短码编解码往返测试
|
||||||
|
* 3. QR签名/验签测试
|
||||||
|
* 4. 边界条件测试
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 模拟 getVrSecret 和 getGoodsKey(不依赖 ShopXO)
|
||||||
|
function getVrSecret(): string
|
||||||
|
{
|
||||||
|
return 'vrt-test-secret-for-unit-test';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGoodsKey(int $goods_id): string
|
||||||
|
{
|
||||||
|
static $cache = [];
|
||||||
|
if (!isset($cache[$goods_id])) {
|
||||||
|
$secret = getVrSecret();
|
||||||
|
$cache[$goods_id] = substr(hash_hmac('sha256', (string)$goods_id, $secret), 0, 16);
|
||||||
|
}
|
||||||
|
return $cache[$goods_id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function feistelRound(int $R, int $round, string $key): int
|
||||||
|
{
|
||||||
|
$hmac = hash_hmac('sha256', $R . '.' . $round, $key, true);
|
||||||
|
$val = (ord($hmac[0]) << 16) | (ord($hmac[1]) << 8) | ord($hmac[2]);
|
||||||
|
return $val & 0x7FFFF; // 19bit mask
|
||||||
|
}
|
||||||
|
|
||||||
|
function feistelEncode(int $packed, string $key): string
|
||||||
|
{
|
||||||
|
$L = ($packed >> 17) & 0x7FFFF;
|
||||||
|
$R = $packed & 0x1FFFF;
|
||||||
|
|
||||||
|
for ($i = 0; $i < 8; $i++) {
|
||||||
|
$F = feistelRound($R, $i, $key);
|
||||||
|
$L_new = $R;
|
||||||
|
$R_new = $L ^ $F;
|
||||||
|
$L = $L_new;
|
||||||
|
$R = $R_new;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = ($L & 0x7FFFF) | (($R & 0x1FFFF) << 17);
|
||||||
|
return base_convert($result, 10, 36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function feistelDecode(string $code, string $key): int
|
||||||
|
{
|
||||||
|
$packed = intval(base_convert(strtolower($code), 36, 10));
|
||||||
|
$L = ($packed >> 17) & 0x7FFFF;
|
||||||
|
$R = $packed & 0x1FFFF;
|
||||||
|
|
||||||
|
for ($i = 7; $i >= 0; $i--) {
|
||||||
|
$F = feistelRound($L, $i, $key);
|
||||||
|
$R_new = $L;
|
||||||
|
$L_new = $R ^ $F;
|
||||||
|
$R = $R_new;
|
||||||
|
$L = $L_new;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($L & 0x7FFFF) | (($R & 0x1FFFF) << 17);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortCodeEncode(int $goods_id, int $ticket_id): string
|
||||||
|
{
|
||||||
|
if ($ticket_id > 0x1FFFF) {
|
||||||
|
throw new Exception("ticket_id 超出17bit范围 (max=131071), given={$ticket_id}");
|
||||||
|
}
|
||||||
|
$packed = ($goods_id << 17) | $ticket_id;
|
||||||
|
$key = getGoodsKey($goods_id);
|
||||||
|
return strtolower(feistelEncode($packed, $key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortCodeDecode(string $code, ?int $goods_id_hint = null): array
|
||||||
|
{
|
||||||
|
$code = strtolower($code);
|
||||||
|
$candidates = [];
|
||||||
|
if ($goods_id_hint !== null) {
|
||||||
|
$candidates[] = $goods_id_hint;
|
||||||
|
}
|
||||||
|
$max_goods = 100000;
|
||||||
|
for ($gid = 1; $gid <= $max_goods; $gid++) {
|
||||||
|
if ($goods_id_hint !== null && $gid !== $goods_id_hint) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$candidates[] = $gid;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($candidates as $gid) {
|
||||||
|
$key = getGoodsKey($gid);
|
||||||
|
$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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Exception("短码解码失败:无法找到匹配的 goods_id (code={$code})");
|
||||||
|
}
|
||||||
|
|
||||||
|
function signQrPayload(array $payload): string
|
||||||
|
{
|
||||||
|
$secret = getVrSecret();
|
||||||
|
$sign_str = $payload['id'] . '.' . $payload['g'] . '.' . $payload['iat'] . '.' . $payload['exp'];
|
||||||
|
$sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8);
|
||||||
|
$payload['sig'] = $sig;
|
||||||
|
return base64_encode(json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyQrPayload(string $encoded)
|
||||||
|
{
|
||||||
|
$json = base64_decode($encoded);
|
||||||
|
if ($json === false) return null;
|
||||||
|
$payload = json_decode($json, true);
|
||||||
|
if (!is_array($payload)) return null;
|
||||||
|
if (!isset($payload['id'], $payload['g'], $payload['iat'], $payload['exp'], $payload['sig'])) return null;
|
||||||
|
if ($payload['exp'] < time()) return null;
|
||||||
|
$secret = getVrSecret();
|
||||||
|
$sign_str = $payload['id'] . '.' . $payload['g'] . '.' . $payload['iat'] . '.' . $payload['exp'];
|
||||||
|
$expected_sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8);
|
||||||
|
if (!hash_equals($expected_sig, $payload['sig'])) return null;
|
||||||
|
return ['id' => intval($payload['id']), 'g' => intval($payload['g']), 'exp' => intval($payload['exp'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 测试用例 ====================
|
||||||
|
|
||||||
|
$passed = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
function assert_true($condition, $test_name) {
|
||||||
|
global $passed, $failed;
|
||||||
|
if ($condition) {
|
||||||
|
echo "✅ PASS: {$test_name}\n";
|
||||||
|
$passed++;
|
||||||
|
} else {
|
||||||
|
echo "❌ FAIL: {$test_name}\n";
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert_equals($expected, $actual, $test_name) {
|
||||||
|
global $passed, $failed;
|
||||||
|
if ($expected === $actual) {
|
||||||
|
echo "✅ PASS: {$test_name}\n";
|
||||||
|
$passed++;
|
||||||
|
} else {
|
||||||
|
echo "❌ FAIL: {$test_name} - Expected: {$expected}, Got: {$actual}\n";
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "========================================\n";
|
||||||
|
echo "Phase 4.1 单元测试:Feistel-8 + QR签名\n";
|
||||||
|
echo "========================================\n\n";
|
||||||
|
|
||||||
|
// Test 1: Feistel-8 往返测试
|
||||||
|
echo "--- Feistel-8 编解码往返测试 ---\n";
|
||||||
|
$key = 'test-key-12345678';
|
||||||
|
$test_cases = [
|
||||||
|
['input' => 0, 'desc' => '全0'],
|
||||||
|
['input' => 1, 'desc' => '最小值'],
|
||||||
|
['input' => 0xFFFFFFFF, 'desc' => '最大值(32bit)'],
|
||||||
|
['input' => 118 << 17, 'desc' => 'goods_id=118'],
|
||||||
|
['input' => (118 << 17) | 482815, 'desc' => 'goods_id=118, ticket_id=482815'],
|
||||||
|
['input' => 100000 << 17, 'desc' => 'goods_id=100000'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($test_cases as $tc) {
|
||||||
|
$encoded = feistelEncode($tc['input'], $key);
|
||||||
|
$decoded = feistelDecode($encoded, $key);
|
||||||
|
assert_equals($tc['input'], $decoded, "Feistel-8 {$tc['desc']}: {$tc['input']} → {$encoded} → {$decoded}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: 短码编解码往返测试(不带hint)
|
||||||
|
echo "\n--- 短码编解码往返测试 ---\n";
|
||||||
|
$short_code_cases = [
|
||||||
|
['goods_id' => 118, 'ticket_id' => 1, 'desc' => '商品118, 第1张票'],
|
||||||
|
['goods_id' => 118, 'ticket_id' => 100, 'desc' => '商品118, 第100张票'],
|
||||||
|
['goods_id' => 118, 'ticket_id' => 482815, 'desc' => '商品118, 第482815张票'],
|
||||||
|
['goods_id' => 100, 'ticket_id' => 50000, 'desc' => '商品100, 第50000张票'],
|
||||||
|
['goods_id' => 9999, 'ticket_id' => 65535, 'desc' => '商品9999, ticket_id=65535(16bit)'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($short_code_cases as $tc) {
|
||||||
|
$code = shortCodeEncode($tc['goods_id'], $tc['ticket_id']);
|
||||||
|
$decoded = shortCodeDecode($code);
|
||||||
|
assert_equals($tc['goods_id'], $decoded['goods_id'], "短码-{$tc['desc']}: goods_id");
|
||||||
|
assert_equals($tc['ticket_id'], $decoded['ticket_id'], "短码-{$tc['desc']}: ticket_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: 短码带 hint 解码(性能优化验证)
|
||||||
|
echo "\n--- 短码带 hint 解码测试 ---\n";
|
||||||
|
$code = shortCodeEncode(118, 12345);
|
||||||
|
$decoded = shortCodeDecode($code, 118);
|
||||||
|
assert_equals(118, $decoded['goods_id'], "带hint解码: goods_id");
|
||||||
|
assert_equals(12345, $decoded['ticket_id'], "带hint解码: ticket_id");
|
||||||
|
|
||||||
|
// Test 4: QR签名/验签测试
|
||||||
|
echo "\n--- QR签名/验签测试 ---\n";
|
||||||
|
$now = time();
|
||||||
|
$payload = [
|
||||||
|
'id' => 482815,
|
||||||
|
'g' => 118,
|
||||||
|
'iat' => $now,
|
||||||
|
'exp' => $now + 1800, // 30分钟
|
||||||
|
];
|
||||||
|
$signed = signQrPayload($payload);
|
||||||
|
$verified = verifyQrPayload($signed);
|
||||||
|
assert_true($verified !== null, "QR签名验证: 签名有效");
|
||||||
|
if ($verified) {
|
||||||
|
assert_equals(482815, $verified['id'], "QR签名验证: id匹配");
|
||||||
|
assert_equals(118, $verified['g'], "QR签名验证: goods_id匹配");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: QR签名防篡改测试
|
||||||
|
echo "\n--- QR签名防篡改测试 ---\n";
|
||||||
|
$json = base64_decode($signed);
|
||||||
|
$malicious_data = json_decode($json, true);
|
||||||
|
$malicious_data['id'] = 999999; // 篡改
|
||||||
|
$malicious_signed = base64_encode(json_encode($malicious_data, JSON_UNESCAPED_UNICODE));
|
||||||
|
$verified = verifyQrPayload($malicious_signed);
|
||||||
|
assert_true($verified === null, "QR防篡改: 篡改后应返回null");
|
||||||
|
|
||||||
|
// Test 6: QR过期测试
|
||||||
|
echo "\n--- QR过期测试 ---\n";
|
||||||
|
$expired_payload = [
|
||||||
|
'id' => 1,
|
||||||
|
'g' => 118,
|
||||||
|
'iat' => $now - 3600,
|
||||||
|
'exp' => $now - 1800, // 已过期
|
||||||
|
];
|
||||||
|
$expired_signed = signQrPayload($expired_payload);
|
||||||
|
$verified = verifyQrPayload($expired_signed);
|
||||||
|
assert_true($verified === null, "QR过期测试: 已过期应返回null");
|
||||||
|
|
||||||
|
// Test 7: 边界条件 - ticket_id 超出17bit
|
||||||
|
echo "\n--- 边界条件测试 ---\n";
|
||||||
|
try {
|
||||||
|
shortCodeEncode(118, 131072); // 超出17bit
|
||||||
|
echo "❌ FAIL: ticket_id超出17bit应抛出异常\n";
|
||||||
|
$failed++;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo "✅ PASS: ticket_id超出17bit正确抛出异常\n";
|
||||||
|
$passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: ticket_id 最大17bit值
|
||||||
|
$max_ticket = 131071; // 0x1FFFF
|
||||||
|
$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");
|
||||||
|
|
||||||
|
// Test 9: 不同商品 key 不同
|
||||||
|
echo "\n--- Per-goods key 隔离测试 ---\n";
|
||||||
|
$code1 = shortCodeEncode(118, 1000);
|
||||||
|
$code2 = shortCodeEncode(119, 1000);
|
||||||
|
assert_true($code1 !== $code2, "不同商品相同ticket_id生成不同短码");
|
||||||
|
|
||||||
|
// Test 10: 暴力解码性能测试(仅验证正确性,不测性能)
|
||||||
|
echo "\n--- 暴力解码正确性测试 ---\n";
|
||||||
|
$code = shortCodeEncode(100, 5000);
|
||||||
|
$decoded = shortCodeDecode($code);
|
||||||
|
assert_equals(100, $decoded['goods_id'], "暴力解码: goods_id=100");
|
||||||
|
assert_equals(5000, $decoded['ticket_id'], "暴力解码: ticket_id=5000");
|
||||||
|
|
||||||
|
// ==================== 测试结果 ====================
|
||||||
|
echo "\n========================================\n";
|
||||||
|
echo "测试结果: {$passed} passed, {$failed} failed\n";
|
||||||
|
echo "========================================\n";
|
||||||
|
|
||||||
|
if ($failed > 0) {
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
echo "🎉 所有测试通过!\n";
|
||||||
|
exit(0);
|
||||||
Loading…
Reference in New Issue