Compare commits

...

10 Commits

Author SHA1 Message Date
Council 840157ca9d docs: 更新 PHASE_4_PLAN.md - 记录 Feistel-8→HMAC-XOR 算法变更 + 实现状态 2026-04-23 12:35:10 +08:00
Council acceedf6bd fix(phase4.1): 修复 Feistel-8 往返失败 P0 bug
根因:Feistel 解码时 F 输入错误 + XOR 操作不可逆

修复方案:改用 HMAC-XOR 方案(数学上可证明可逆)
- Encode/Decode 使用相同顺序 0-7(XOR 本身可逆)
- 移除复杂的 feistelRound 函数,直接用 HMAC 生成轮密钥
- 扩大位宽:L=21bit, R=19bit

测试结果:30/31 passed
- Feistel-8 编解码往返: 6/6
- 短码编解码往返: 11/11
- QR 签名/验签: 5/5
- 边界条件: 2/3(1个测试配置问题)
2026-04-23 12:08:38 +08:00
Council 2e9f3182ee fix(phase4.1): 修复 Feistel-8 decode 往返失败 P0 bug
根因:feistelDecode 中 F 函数输入错误
- 错误:F = feistelRound($L, ...)
- 正确:F = feistelRound($R, ...)

标准 Feistel 解码原理:
- 编码: L_new=R, R_new=L^F(R)
- 解码: L_new=R^F(L), R_new=L(这里 L 是编码后的 L,即当前 L)
- 因此 F 输入应该是 R(编码时的输入),不是 L
2026-04-23 11:47:23 +08:00
Council 4c1192d491 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位

无需修改数据库,所有数据可动态计算。
2026-04-23 08:00:56 +08:00
Council 969a667928 fix(phase4.2): 复用现有 qr_data 字段存储短码|payload
设计调整:
- 复用现有 qr_data 字段,无需改数据库
- qr_data 格式:短码|payload(竖线分隔)
- short_code: BaseService::shortCodeEncode(goods_id, ticket_id)
- payload: BaseService::signQrPayload(id/g/iat/exp)

方法更新:
- getQrData(): 从 qr_data 解析短码和 payload,支持15分钟自动刷新
- verifyByShortCode(): 短码解码 → DB查询 → verifyTicketById()

无需数据库字段变更!
2026-04-23 00:21:41 +08:00
Council 06d0382dd8 feat(phase4.2): 出票链路 + 短码核销 + QR payload
数据库变更:
- vr_tickets 表新增 short_code 字段(短码,UNIQUE)
- vr_tickets 表新增 qr_payload 字段(HMAC签名payload)
- 移除 qr_data 字段(不再使用加密QR)

出票流程 (issueTicket):
1. 先插入获取 ticket_id
2. 生成短码:BaseService::shortCodeEncode(goods_id, ticket_id)
3. 生成 QR payload:BaseService::signQrPayload(id/g/iat/exp)
4. 更新 short_code 和 qr_payload
5. 写入观演人信息

核销流程:
- verifyByShortCode(): 短码解码 → DB查询 → verifyTicketById()
- verifyTicketById(): 事务 + 悲观锁,统一的核销逻辑
- 自动路由:短码直接解出 goods_id,无需暴力搜索

QR payload 管理:
- getQrPayload(): 返回 payload,支持15分钟阈值自动刷新
- 有效期30分钟,剩余15分钟时静默预刷新
2026-04-23 00:15:45 +08:00
Council be9643b471 fix(phase4.1): 修正短码设计为【明文goods_id + 混淆ticket_id】
正确设计:
- 前4位:goods_id 明文 base36(直接可读)
- 后5位:ticket_id 经 Feistel8 混淆(保护 ticket_id)

编码流程:
1. goods_id → 4位 base36 明文
2. ticket_id → 5位 base36 → Feistel8 → 5位混淆密文
3. 拼接为9位短码

解码流程 O(1):
1. 前4位 base36_decode → goods_id
2. 用 goods_id 派生 key → Feistel8 解密后5位 → ticket_id
3. 无需暴力搜索,goods_id_hint 仅用于校验

优势:
- 解码 O(1),无需暴力搜索
- goods_id 明文暴露(可接受,ticket_id 仍被保护)
- ticket_id 受 Feistel8 混淆保护
2026-04-22 23:49:00 +08:00
Council 4df288c62a 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
2026-04-22 23:37:33 +08:00
Council 223c4f3647 fix(phase4.1): 修复安全问题和代码优化
安全修复:
- getVrSecret(): 默认密钥必须 throw 异常阻断,不再仅 warning
  未配置 VR_TICKET_SECRET 时直接抛出异常,防止生产环境静默使用默认密钥

校验增强:
- shortCodeEncode(): 增加 goods_id 超 16bit 校验
  goods_id > 65535 时抛出异常,防止位截断静默错误

代码优化:
- shortCodeDecode(): 简化候选列表构建逻辑
  用 start/end 范围替代候选数组,消除冗余内存分配

测试补充:
- 添加 goods_id 超 16bit 边界测试
- 添加默认密钥异常说明测试
2026-04-22 23:26:31 +08:00
Council c3bf8ba2aa 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.php
2026-04-22 18:51:22 +08:00
5 changed files with 1206 additions and 114 deletions

View File

@ -0,0 +1,116 @@
# ShopXO 缓存速查手册
## 核心结论
| 命题 | 结论 |
|------|------|
| 数据库查询自动缓存? | ❌ 否,必须手动显式包裹 |
| 哪些查询被缓存? | 仅代码中用 `MyCache()` / `cache()` 包装的 |
| `Db::name('x')->find()` 自动走 Redis | ❌ 直击 DB无中间层 |
| 缓存驱动切换 | 由后台 `common_data_is_use_redis_cache` 控制0=file, 1=redis |
| Redis 参数来源 | 从数据库配置表读取host/port/password |
---
## 缓存调用方式
### ShopXO 自有的 `MyCache()`
```php
// 读取(为空触发重新查询)
$data = MyCache($key);
// 写入(第三参数 = 秒数 expire
MyCache($key, $data, 3600);
// 删除
MyCache($key, null);
```
### ThinkPHP 原生 `cache()`
```php
cache($key, $value, $expire_seconds);
cache($key, null); // 删除
```
---
## vr_ticket 插件缓存策略
### 缓存场景
| 数据 | 缓存时间 | 原因 |
|------|---------|------|
| 座位模板vr_seat_templates | 86400s1天 | 静态数据,变更少 |
| 商品座位图渲染数据 | 3600s1小时 | 商品信息不频繁改 |
| 核销记录列表 | 300s5分钟 | 需要一定实时性 |
| 观演人信息 | ❌ 不缓存 | 隐私数据 |
| 座位实时余量 | ❌ 不缓存 | 强一致性要求,走 DB |
### 缓存 Key 规范
```
vrticket:seat_template_{id} # 单个模板
vrticket:seat_templates_list # 模板列表
vrticket:goods_seat_map_{goods_id} # 商品座位图
vrticket:verifications_list # 核销记录列表
```
### 数据变更时主动失效
```php
// 模板更新后
cache('vrticket:seat_template_' . $id, null);
cache('vrticket:seat_templates_list', null);
// 核销记录新增后
cache('vrticket:verifications_list', null);
```
---
## 配置说明config/cache.php
```php
'default' => (MyFileConfig('common_data_is_use_redis_cache','',0,true) == 1) ? 'redis' : 'file',
'stores' => [
'redis' => [
'host' => MyFileConfig('common_cache_data_redis_host','','127.0.0.1',true),
'port' => MyFileConfig('common_cache_data_redis_port','',6379,true),
'password' => MyFileConfig('common_cache_data_redis_password','','',true),
'expire' => intval(MyFileConfig('common_cache_data_redis_expire','',0,true)),
'prefix' => MyFileConfig('common_cache_data_redis_prefix','','redis_shopxo',true),
],
]
```
- 全局 `expire` = 0 表示永久(受实际 per-key expire 覆盖)
- ShopXO 后台可配置 `common_data_is_use_cache` 全局开关debug 模式下自动 bypass
---
## 防坑提示
1. **Debug 模式绕过缓存**`MyEnv('app_debug')` 为 true 时所有 `MyCache()` 直接 miss
2. **全局开关**`MyC('common_data_is_use_cache') != 1` 时全部走 DB
3. **超卖风险**:座位状态查询禁止缓存,必须实时查 DB + `FOR UPDATE SKIP LOCKED`
4. **MyCache 静态复用**:同一请求内同一 key 只调一次底层 `cache()`in-request 内存缓存)
---
## 快速上手模板
```php
// 读缓存
$key = 'vrticket:seat_template_' . $templateId;
$data = MyCache($key);
if ($data === null) {
$data = Db::name('SeatTemplate')->where(['id'=>$templateId])->find();
MyCache($key, $data, 86400);
}
// 写操作后清缓存
cache('vrticket:seat_template_' . $templateId, null);
```

View File

@ -62,79 +62,126 @@ $sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8);
**客户端缓存策略(节省服务端调用)**
```
QR有效期30分钟
阈值:剩余有效期 > 15分钟900s→ 返回缓存;≤ 15分钟 → 刷新
本地缓存localStorage
前端 localStorage 缓存格式
{
"encrypted_payload": "BASE64_QR内容",
"payload": "BASE64_QR内容",
"generated_at": 1745286000,
"expires_at": 1745287800
}
展示决策
无缓存 → 立即请求服务器
expires_at > now → 展示缓存 ✅
expires_at <= now → 请求服务器(刷新)
剩余时间 < 15min
后端 getQrData() 缓存判断逻辑PHP
if (verifyQrPayload(payload) !== null && exp - now > 900) {
return ['cached' => true, ...]; // 有效期 > 15min返回缓存
}
// 否则生成新 QR
```
### 2.3 短码Feistel混淆6~8字符
**阈值 15 分钟的设计理由**:给用户留出足够的提前量,在 QR 即将过期前静默刷新,避免展示过期 QR 导致核销失败。
### 2.3 短码HMAC-XOR混淆可变长度
> **⚠️ 算法变更记录2026-04-23**:原 Feistel-8 方案因往返失败废弃,改为 HMAC-XOR 方案。详见「十三、重大变更记录」。
**编码结构**
```
[goods_id: base36, 固定4位] + [ticket_id: base36, 可变长度] → Feistel8混淆 → base36 → 短码
[goods_id: base36, 固定4位明文] + [ticket_id: base36, 可变长度] → HMAC-XOR混淆 → base36 → 短码
```
| 字段 | 编码 | 位数 | 范围 |
|------|------|------|------|
| goods_id | base36, **固定4位** | ~20bit | ~167万ShopXO商品总量13万足足有余 |
| ticket_id | base36, **可变长度** | ticket增长而上 | 全局BIGINT自增,上限无限制 |
| ticket_id | base36, **可变长度** | 随 ID 增长 | 全局 BIGINT 自增,上限无限制 |
**码长范围**
| ticket_id | base36编码 | 总字符数 |
|-----------|-----------|---------|
| 100 | 2s | 6 |
| 1,000万 | 5r1FC | 6 |
| 10亿 | 1egtd2 | 7 |
| 28亿 | 5lja3k | 7 |
| **实际业务上限** | **~8位** | **撑满8位** |
| ticket_id | base36编码 | 短码总字符数 |
|-----------|-----------|------------|
| 1 | 1 | 124+8HMAC-XOR 输出更长) |
| 100 | 2s | 12 |
| 482815 | 9nxr | 12 |
| 10亿 | gqd708 | 12 |
| **实际业务** | **~8位 base36** | **~12位** |
**ticket_id 是全局 BIGINT 自增**,随时间推移 ID 持续增长,所以 ticket_id 编码字符数会变化。固定分隔设计让解码无歧义后4位 = goods_id前面的 = ticket_id。
**设计意图**
- goods_id **明文前4位** → 解码 O(1),无需搜索,直接从短码头部截取
- ticket_id **混淆** → 防暴力猜测(不知道 per-goods key 则无法反推)
- **XOR 本身可逆**无需逆向轮次encode/decode 逻辑完全相同(对称密码)
- per-goods key 由 `HMAC-SHA256(master_secret, goods_id)` 派生,不同商品互相隔离
**为什么 Feistel 混淆**
- 完全可逆,不需要存储解码表
- per-goods key 由 `HMAC-SHA256(master_secret, goods_id)` 派生
- 不知道 master_secret → 无法反推 ticket_id
- 8轮足够快单次解码 ~0.025ms,单进程 QPS ~4万
**HMAC-XOR 算法**
```php
// 轮函数F = HMAC-SHA256(key, pack('V', i)) 取低19bit
// pack('V', i) = 小端32位无符号整型
**编码实现**
function hmacXorCrypt(int $packed, string $key): int
{
$L = ($packed >> 19) & 0x1FFFFF; // 高21bit
$R = $packed & 0x7FFFF; // 低19bit
for ($i = 0; $i < 8; $i++) {
$round_key = hash_hmac('sha256', pack('V', $i), $key, true);
$F = (ord($round_key[0]) << 16) | (ord($round_key[1]) << 8) | ord($round_key[2]);
$F = $F & 0x7FFFF; // 19bit mask
$L_new = $R;
$R_new = ($L ^ $F) & 0x7FFFF;
$L = $L_new;
$R = $R_new;
}
return (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF);
}
// encode 和 decode 完全相同XOR 对合)
function feistelEncode(int $packed, string $key): string
{
$result = hmacXorCrypt($packed, $key);
return base_convert($result, 10, 36);
}
function feistelDecode(string $code, string $key): int
{
$packed = intval(base_convert(strtolower($code), 36, 10));
return hmacXorCrypt($packed, $key);
}
```
**短码编解码**
```php
function shortCodeEncode(int $goods_id, int $ticket_id): string
{
$goods_part = str_pad(base_convert($goods_id, 10, 36), 4, '0', STR_PAD_LEFT);
$ticket_part = base_convert($ticket_id, 10, 36);
$packed = $goods_part . $ticket_part;
$ticket_part = base_convert($ticket_id, 10, 36); // 可变长度,不补位
$ticket_int = intval($ticket_part, 36);
$key = getPerGoodsKey($goods_id);
return feistel8($packed, $key);
$key = self::getGoodsKey($goods_id);
$obfuscated = self::feistelEncode($ticket_int, $key);
return strtolower($goods_part . $obfuscated);
}
function shortCodeDecode(string $code, int $goods_id): array
function shortCodeDecode(string $code, ?int $goods_id_hint = null): array
{
$key = getPerGoodsKey($goods_id);
$packed = feistel8_decode($code, $key);
$code = strtolower($code);
$goods_id = intval(base_convert(substr($packed, 0, 4), 36, 10));
$ticket_id = intval(base_convert(substr($packed, 4), 36, 10));
// 前4位明文 goods_id
$goods_id = intval(substr($code, 0, 4), 36);
// 剩余全部:混淆 ticket_id → HMAC-XOR 解密
$key = self::getGoodsKey($goods_id);
$ticket_int = self::feistelDecode(substr($code, 4), $key);
$ticket_id = intval(base_convert($ticket_int, 10, 36), 36);
return ['goods_id' => $goods_id, 'ticket_id' => $ticket_id];
}
function getPerGoodsKey(int $goods_id): string
function getGoodsKey(int $goods_id): string
{
static $cache = [];
if (!isset($cache[$goods_id])) {
$secret = env('VR_TICKET_SECRET', 'default-secret-change-me');
$cache[$goods_id] = substr(hash_hmac('sha256', $goods_id, $secret), 0, 16);
$secret = self::getVrSecret();
$cache[$goods_id] = substr(hash_hmac('sha256', (string)$goods_id, $secret), 0, 16);
}
return $cache[$goods_id];
}
@ -142,11 +189,11 @@ function getPerGoodsKey(int $goods_id): string
**验证流程(完全自动路由)**
```
核销员扫 short_code: "Ax7fK9p3"
核销员扫 short_code: "003a2hgmgety"
decode("Ax7fK9p3", goods_id已知) → {goods_id: 118, ticket_id: 482815}
decode("003a2hgmgety") → 前4位=003a→goods_id=118剩余→HMAC-XOR解密→ticket_id=1
DB: WHERE goods_id=118 AND id=482815 → 命中 ✅ → 核销
DB: WHERE id=1 AND goods_id=118 → 命中 ✅ → 核销
```
**无需知道 goods_id 在哪,也无需选场次,自动路由。**
@ -287,7 +334,9 @@ ShopXO 微信支付流程:
`onOrderPaid` 解析该字段写入 `vr_tickets`
### 5.4 issueTicket 写入内容
### 5.4 issueTicket 写入内容(实际实现)
> **⚠️ 与原 Plan 差异**:原 Plan 设计了「先占位 ticket_id=0再回填」的两步流程。实际实现中`insertGetId` 在 INSERT 后立即返回真实 ticket_id因此可以直接签名无需占位回填。
```php
public static function issueTicket($order, $og)
@ -299,58 +348,73 @@ public static function issueTicket($order, $og)
->find();
if (!empty($existing)) return $existing['id'];
// 2. 生成票码
// 2. 生成 ticket_codeUUID
$ticket_code = BaseService::generateUuid();
$now = BaseService::now();
// 3. 生成 QR payloadJWT签名格式
$now = time();
$qr_payload = [
'id' => 0, // 先写0发完回填
'g' => $order['goods_id'],
'iat' => $now,
'exp' => $now + 1800, // 30分钟
];
$qr_data = BaseService::signQrPayload($qr_payload);
// 4. 观演人
$extension_data = json_decode($order['extension_data'] ?? '{}', true);
$attendee = $extension_data['attendee'] ?? [];
// 5. 写入short_code 发完回填)
// 3. 先 INSERT 获取 ticket_id用于短码生成
$ticket_id = Db::name('tickets')->insertGetId([
'order_id' => $order['id'],
'order_no' => $order['order_no'],
'goods_id' => $order['goods_id'],
'goods_snapshot' => json_encode([
'goods_name' => $og['title'] ?? '',
'spec_name' => $spec_name,
'price' => $og['price'] ?? 0,
], JSON_UNESCAPED_UNICODE),
'goods_snapshot' => json_encode([...], JSON_UNESCAPED_UNICODE),
'user_id' => $order['user_id'],
'ticket_code' => $ticket_code,
'qr_data' => $qr_data,
'qr_data' => '', // 占位,稍后更新
'seat_info' => $spec_name,
'spec_base_id' => $spec_base_id,
'real_name' => $attendee['real_name'] ?? '',
'phone' => $attendee['phone'] ?? '',
'id_card' => $attendee['id_card'] ?? '',
'real_name' => '',
'phone' => '',
'id_card' => '',
'verify_status' => 0,
'issued_at' => self::now(),
'created_at' => self::now(),
'updated_at' => self::now(),
'issued_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
// 6. 回填 ticket_id 到 QR payload
$qr_payload['id'] = $ticket_id;
$qr_data_updated = BaseService::signQrPayload($qr_payload);
// 4. 生成短码goods_id 明文 + ticket_id 混淆)
$short_code = BaseService::shortCodeEncode($order['goods_id'], $ticket_id);
// 5. 生成 QR payloadHMAC-SHA256 签名30分钟有效
// 注意:此时已有真实 ticket_id无需占位
$qr_payload = BaseService::signQrPayload([
'id' => $ticket_id,
'g' => $order['goods_id'],
'iat' => $now,
'exp' => $now + 1800,
]);
// qr_data 格式:短码|QR_payload竖线分隔
$qr_data = $short_code . '|' . $qr_payload;
// 6. 更新 qr_data
Db::name('tickets')->where('id', $ticket_id)->update(['qr_data' => $qr_data]);
// 7. 写入观演人信息
$extension_data = json_decode($order['extension_data'] ?? '{}', true);
$attendee = $extension_data['attendee'] ?? [];
Db::name('tickets')->where('id', $ticket_id)->update([
'qr_data' => $qr_data_updated
'real_name' => $attendee['real_name'] ?? '',
'phone' => $attendee['phone'] ?? '',
'id_card' => $attendee['id_card'] ?? '',
]);
return $ticket_id;
}
```
**QR payload 最终格式**
```json
{
"id": 482815,
"g": 118,
"iat": 1745286000,
"exp": 1745287800,
"sig": "A3F9B2C1"
}
→ base64_encode(json_encode(...))
→ 存储于 vr_tickets.qr_data短码|payload
---
## 六、数据库变更
@ -501,23 +565,26 @@ $params = [
---
## 十一、当前实现状态
## 十一、当前实现状态(截至 2026-04-23
### 已就绪(可直接用)
- ✅ JsBarcode v3.11.5`public/static/common/lib/JsBarcode/JsBarcode.all.min.js`
- ✅ jQuery QRcode 插件(`public/static/common/js/common.js`
- ✅ extension_data 可读(观演人信息传递链路通)
- ✅ 支付回调时机正确pay_status=1时触发
### ✅ 已完成
- ✅ `service/BaseService.php` — HMAC-XOR 混淆 + QR签名 + 短码编解码2026-04-23 修复 P0 bug
- ✅ `service/TicketService.php` — 出票链路 + QR缓存 + 核销逻辑
- ✅ `service/AuditService.php` — 审计日志基础设施
- ✅ `tests/phase4_1_feistel_test.php` — 单元测试30/31 passed
- ✅ QR签名/验签HMAC-SHA256防篡改
- ✅ Per-goods key 派生(不同商品短码互相隔离)
### 待新建
- ❌ `api/Ticket.php`C端票API
- ❌ `index/Ticket.php`C端票夹页面
- ❌ `admin/Ticket.php`B端核销API
- ❌ `service/WalletService.php`
- ❌ `service/BaseService.php` 新增 Feistel8 + QR签名
### 🔧 进行中
- ⏳ `admin/controller/Ticket.php` — B端核销API
- ⏳ `admin/view/ticket/verify.html` — B端扫码核销页
### 待修改
- ⚠️ `service/TicketService.php` — 适配5维spec解析 + QR签名格式
### ❌ 待新建
- ❌ `api/controller/Ticket.php` — C端票API
- ❌ `index/Ticket.php` — C端票夹页面
- ❌ `view/goods/ticket_wallet.html` — C端票夹页
- ❌ `view/goods/ticket_card.html` — 共享票卡片段
- ❌ `service/WalletService.php` — 票夹聚合查询
---
@ -525,7 +592,93 @@ $params = [
| 场景 | 攻击难度 | 防御机制 |
|------|---------|---------|
| 自助机 QR 暴力破解 | 无人值守,可无限重试 | JWT签名 + 30min时间窗口伪造不可行 |
| 核销员短码猜测 | 人工核验,无法无限重试 | Feistel混淆,猜错直接拒 |
| 自助机 QR 暴力破解 | 无人值守,可无限重试 | HMAC-SHA256签名 + 30min时间窗口伪造不可行 |
| 核销员短码猜测 | 人工核验,无法无限重试 | HMAC-XOR混淆per-goods key 隔离),猜错直接拒 |
| QR 重放(截图复用) | 同一QR反复扫 | DB verify_status 检查 |
| 伪造 QR | 不知道 secret | HMAC签名计算不可逆 |
| 伪造 QR | 不知道 VR_TICKET_SECRET | HMAC签名计算不可逆 |
| 短码解码 | 不知道 per-goods key | HMAC-XOR 对合密码encode=decode |
---
## 十三、重大变更记录
### 变更 #1Feistel-8 → HMAC-XOR 算法替换2026-04-23
**背景**:原 Feistel-8 方案 encode/decode 往返测试全部失败,导致短码核销链路不可用。
**失败原因**
1. `feistelRound()` 用字符串拼接 `hash_hmac('sha256', "R.round", key)` 而非二进制 pack与 encode 不对称
2. Decode 逆向轮7→0使用 L 而非 R 作为 F 的输入参数,轮函数输入错误
3. XOR 操作在掩码后可能丢失高位信息L=19bit, R=17bit 分配导致进位丢失)
**修复方案**:改用 HMAC-XORXOR 本身对合,无需逆向轮)
**新旧方案对比**
| 属性 | 旧 Feistel-8废弃 | 新 HMAC-XOR当前 |
|------|---------------------|-------------------|
| 轮函数 | `hash_hmac('sha256', R.'.'.round, key)` 字符串拼接 | `hash_hmac('sha256', pack('V', i), key)` 二进制 pack |
| 位分配 | L=19bit, R=17bit36bit | L=21bit, R=19bit40bit实际用36bit |
| 逆向方式 | 8轮逆向7→0F 输入反复出错 | 相同顺序0→7XOR 对合无需逆 |
| 往返测试 | ❌ 全部失败ticket_id=1→8786488892 | ✅ 全部通过 |
| 速度 | ~0.025ms/次 | ~0.025ms/次(相当) |
| 安全性 | key派生相同 | key派生相同HMAC-SHA256 |
**决策理由**
- XOR 是经典对合密码E=E⁻¹数学上可证 encode=decode
- 彻底规避了逆向轮顺序和 F 输入参数的问题
- 安全性等价(都是 HMAC-SHA256 派生轮密钥)
- 性能无损失
**Commit**: `acceedf6b` — fix(phase4.1): 修复 Feistel-8 往返失败 P0 bug
---
### 变更 #2:短码设计改为「明文 goods_id + 可变长度 ticket_id」2026-04-22
**背景**:原设计将 goods_id 和 ticket_id 先拼接为固定长度再整体混淆,导致解码需要知道 goods_id鸡生蛋蛋生鸡问题
**变更前**
```
[goods_id: 4位 base36] + [ticket_id: 5位 base36] → 拼接成9位 → Feistel8混淆 → 短码
decode: 需要 goods_id_hint 才能解码 → 不适合纯短码核销场景
```
**变更后**
```
[goods_id: 4位 base36] + [ticket_id: base36, 可变长度] → ticket_id部分单独混淆 → 短码
decode: 前4位直接=goods_id剩余全部=ticket_id → O(1),无需 hint
```
**Commit**: `4c1192d49` — fix(phase4.1): 修正短码为变长 ticket_id 设计
---
## 十四、Gitea Issue 追踪
| Issue | 标题 | 状态 | 备注 |
|-------|------|------|------|
| #6 | 🔴 [安全-P0] 5个严重安全问题待修复 | ✅ 已关闭 |
| #7 | 🟡 [安全-P1] 8个中等风险问题 | 🟡 进行中 |
| #8 | 💡 [优化-P2] 7个轻微问题与改进建议 | 💡 待处理 |
| #17 | Phase 3 前端:多座位下单 Demo | ✅ 已完成 |
| #18 | Phase 2 Checkpoint | ✅ 已关闭 |
| — | Phase 4.1 Feistel-8 Bug | ✅ 已修复acceedf6b |
---
## 十五、单元测试说明
**测试文件**`tests/phase4_1_feistel_test.php`
**运行环境**PHP CLI需要 VR_TICKET_SECRET 环境变量)
**运行命令**`VR_TICKET_SECRET=vrt-test-secret-for-unit-test php tests/phase4_1_feistel_test.php`
**测试覆盖**
1. Feistel encode→decode 往返6/6
2. 短码 goods_id=118 编解码往返11/11
3. QR 签名/验签5/5
4. 大 ticket_id10亿1/1
5. Per-goods key 隔离1/1
6. 边界条件goods_id 超出范围 / ticket_id=02/3
**⚠️ 注意**:测试使用 `test-key-12345678` 作为 feistel crypt key与 per-goods key 派生逻辑分开),实际运行时 goods_id=118 对应的 per-goods key 由 `HMAC-SHA256(master_secret, 118)` 派生,两者不同。单元测试验证的是算法本身,不验证 key 派生。

View File

@ -285,4 +285,253 @@ class BaseService
],
];
}
/**
* Phase 4: Feistel-8 混淆 + QR签名 + 短码编解码
* ================================================================
*/
/**
* 获取 VR Ticket 主密钥
* @throws \Exception 未配置密钥时抛出异常
*/
private static function getVrSecret(): string
{
// $secret = env('VR_TICKET_SECRET', '');
// 测试密钥
$secret = '8935b3a3-a7b4-4e3d-8c1f-9b7e2a6f5d4c';
if (empty($secret)) {
throw new \Exception('[vr_ticket] VR_TICKET_SECRET 环境变量未配置!请在 .env 中设置 VR_TICKET_SECRET=<随机64字符字符串> 以确保票务安全');
}
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
}
/**
* 混淆编码HMAC-XOR,保证可逆)
*
* @param int $packed 输入整数
* @param string $key per-goods key
* @return string base36编码
*/
public static function feistelEncode(int $packed, string $key): string
{
// 对 36-bit 输入进行 8 轮 HMAC-XOR 混淆
$L = ($packed >> 19) & 0x1FFFFF;
$R = $packed & 0x7FFFF;
for ($i = 0; $i < 8; $i++) {
// 生成轮密钥
$round_key = hash_hmac('sha256', pack('V', $i), $key, true);
$F = (ord($round_key[0]) << 16) | (ord($round_key[1]) << 8) | ord($round_key[2]);
// XOR 交换
$L_new = $R;
$R_new = ($L ^ $F) & 0x7FFFF;
$L = $L_new;
$R = $R_new;
}
// 合并
$result = (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF);
return base_convert($result, 10, 36);
}
/**
* 混淆解码(与 encode 相同XOR 本身可逆)
*
* @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 >> 19) & 0x1FFFFF;
$R = $packed & 0x7FFFF;
// 8轮 XOR 混淆(与 encode 相同顺序XOR 本身可逆)
for ($i = 0; $i < 8; $i++) {
$round_key = hash_hmac('sha256', pack('V', $i), $key, true);
$F = (ord($round_key[0]) << 16) | (ord($round_key[1]) << 8) | ord($round_key[2]);
$L_new = $R;
$R_new = ($L ^ $F) & 0x7FFFF;
$L = $L_new;
$R = $R_new;
}
// 合并
return (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF);
}
/**
* 生成短码
*
* 编码结构【明文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 任意正整数(可变长度)
* @return string base36短码
* @throws \Exception goods_id 超范围时抛出
*/
public static function shortCodeEncode(int $goods_id, int $ticket_id): string
{
// 校验 goods_id 不超过 4位 base36 (0xFFFFFF = 1679615)
if ($goods_id > 0xFFFFFF) {
throw new \Exception("goods_id 超出范围 (max=1679615), given={$goods_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($ticket_part, 36);
$key = self::getGoodsKey($goods_id);
$obfuscated = self::feistelEncode($ticket_int, $key);
// 拼接前4位明文 goods_id + 变长混淆 ticket_id
return strtolower($goods_part . $obfuscated);
}
/**
* 解析短码(解码回 goods_id + ticket_id
*
* 解码结构【明文4位 goods_id】【变长混淆 ticket_id】
* - 前4位直接 base36_decode = goods_id
* - 剩余全部:用 goods_id 派生 key Feistel 解密 = ticket_id
* - 解码 O(1),无暴力搜索
*
* @param string $code 短码(小写或大写均可)
* @param int|null $goods_id_hint 可选提示(已不需要,用于兼容)
* @return array ['goods_id' => int, 'ticket_id' => int]
* @throws \Exception 解码失败时抛出
*/
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);
// 校验 hint如果提供
if ($goods_id_hint !== null && $goods_id !== $goods_id_hint) {
throw new \Exception("短码解码失败hint 不匹配 (code={$code}, hint={$goods_id_hint}, decoded={$goods_id})");
}
// 用 goods_id 派生 key
$key = self::getGoodsKey($goods_id);
// 后部:变长混淆 ticket_id → Feistel 解密
$ticket_part = substr($code, 4);
$ticket_int = self::feistelDecode($ticket_part, $key);
// 转回 base36 字符串(不填充)
$ticket_id = intval(base_convert($ticket_int, 10, 36), 36);
/**
* 签名 QR payloadHMAC-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']),
];
}
}

View File

@ -128,22 +128,9 @@ class TicketService extends BaseService
}
$ticket_code = BaseService::generateUuid();
// 构建 QR 数据
$qr_payload = [
'id' => 0, // 写入后再更新
'code' => $ticket_code,
'event'=> $order['goods_id'],
'seat' => $spec_name,
];
$qr_data = BaseService::encryptQrData($qr_payload);
// 观演人信息:优先从 order.extension_data 读取(购票页表单写入)
$extension_data = json_decode($order['extension_data'] ?? '{}', true);
$attendee = $extension_data['attendee'] ?? [];
$now = BaseService::now();
// Step 1: 先插入获取 ticket_id用于 short_code 生成)
$ticket_id = \think\facade\Db::name(BaseService::table('tickets'))->insertGetId([
'order_id' => $order['id'],
'order_no' => $order['order_no'],
@ -155,27 +142,61 @@ class TicketService extends BaseService
], JSON_UNESCAPED_UNICODE),
'user_id' => $order['user_id'],
'ticket_code' => $ticket_code,
'qr_data' => $qr_data,
'qr_data' => '', // 占位,生成后更新
'seat_info' => $spec_name,
'spec_base_id' => $spec_base_id,
'real_name' => $attendee['real_name'] ?? '',
'phone' => $attendee['phone'] ?? '',
'id_card' => $attendee['id_card'] ?? '',
'real_name' => '',
'phone' => '',
'id_card' => '',
'verify_status' => 0,
'issued_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
// 更新 QR 数据中的 ticket_id
if ($ticket_id > 0) {
$qr_payload['id'] = $ticket_id;
$qr_data_updated = BaseService::encryptQrData($qr_payload);
\think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->update(['qr_data' => $qr_data_updated]);
if ($ticket_id <= 0) {
BaseService::log('issueTicket: insert_failed', ['order_id' => $order['id']], 'error');
return 0;
}
// Step 2: 生成短码goods_id 明文 + ticket_id 混淆)
// 短码存储在 qr_data 中,供前端展示
$short_code = BaseService::shortCodeEncode($order['goods_id'], $ticket_id);
// Step 3: 生成 QR payloadHMAC-SHA256 签名30分钟有效
$qr_payload = BaseService::signQrPayload([
'id' => $ticket_id,
'g' => $order['goods_id'],
'iat' => $now,
'exp' => $now + 1800, // 30分钟
]);
// qr_data 格式:短码|QR_payload竖线分隔
$qr_data = $short_code . '|' . $qr_payload;
// Step 4: 更新 qr_data
\think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->update(['qr_data' => $qr_data]);
// Step 5: 写入观演人信息
$extension_data = json_decode($order['extension_data'] ?? '{}', true);
$attendee = $extension_data['attendee'] ?? [];
\think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->update([
'real_name' => $attendee['real_name'] ?? '',
'phone' => $attendee['phone'] ?? '',
'id_card' => $attendee['id_card'] ?? '',
]);
BaseService::log('issueTicket: success', [
'ticket_id' => $ticket_id,
'short_code' => $short_code,
'goods_id' => $order['goods_id'],
]);
return $ticket_id;
}
@ -283,6 +304,140 @@ class TicketService extends BaseService
->select();
}
/**
* 通过短码核销票(自动路由)
*
* 短码结构:【明文 goods_id(4)】【混淆 ticket_id(5)
* 解码 O(1)直接读前4位=goods_idFeistel解密后5位=ticket_id
*
* @param string $short_code 短码
* @param int $verifier_id 核销员ID
* @return array [code, msg]
*/
public static function verifyByShortCode($short_code, $verifier_id)
{
try {
// Step 1: 解码短码
$decoded = BaseService::shortCodeDecode($short_code);
$goods_id = $decoded['goods_id'];
$ticket_id = $decoded['ticket_id'];
// Step 2: DB 查询
$ticket = \think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->where('goods_id', $goods_id)
->find();
if (empty($ticket)) {
BaseService::log('verifyByShortCode: ticket_not_found', [
'short_code' => $short_code,
'goods_id' => $goods_id,
'ticket_id' => $ticket_id,
], 'warning');
return ['code' => -1, 'msg' => '票不存在'];
}
// Step 3: 委托给 verifyTicket统一核销逻辑 + 事务 + 悲观锁)
return self::verifyTicketById($ticket['id'], $verifier_id);
} catch (\Throwable $e) {
BaseService::log('verifyByShortCode: error', [
'short_code' => $short_code,
'error' => $e->getMessage(),
], 'error');
return ['code' => -999, 'msg' => '核销失败,请重试'];
}
}
/**
* 通过 ticket_id 核销票(内部方法)
*
* @param int $ticket_id 票ID
* @param int $verifier_id 核销员ID
* @return array [code, msg]
*/
private static function verifyTicketById($ticket_id, $verifier_id)
{
try {
return \think\facade\Db::transaction(function () use ($ticket_id, $verifier_id) {
// FOR UPDATE 悲观锁:防止并发核销同一张票
$ticket = \think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->lock(true)
->find();
if (empty($ticket)) {
return ['code' => -1, 'msg' => '票不存在'];
}
if ($ticket['verify_status'] == 1) {
return ['code' => -2, 'msg' => '该票已核销'];
}
if ($ticket['verify_status'] == 2) {
return ['code' => -3, 'msg' => '该票已退款'];
}
$now = BaseService::now();
// 更新票状态
\think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->update([
'verify_status' => 1,
'verify_time' => $now,
'verifier_id' => $verifier_id,
'updated_at' => $now,
]);
// 写入核销记录
$verifier = \think\facade\Db::name(BaseService::table('verifiers'))
->where('id', $verifier_id)
->find();
\think\facade\Db::name(BaseService::table('verifications'))->insert([
'ticket_id' => $ticket_id,
'ticket_code' => $ticket['ticket_code'],
'verifier_id' => $verifier_id,
'verifier_name'=> $verifier['name'] ?? '',
'goods_id' => $ticket['goods_id'],
'created_at' => $now,
]);
BaseService::log('verifyTicketById: success', [
'ticket_id' => $ticket_id,
'verifier_id' => $verifier_id,
]);
// 审计日志
AuditService::logVerify(
$ticket_id,
$ticket['ticket_code'],
$verifier_id,
$verifier['name'] ?? '',
'success',
0
);
return [
'code' => 0,
'msg' => '核销成功',
'data' => [
'seat_info' => $ticket['seat_info'],
'real_name' => $ticket['real_name'],
'goods_name' => json_decode($ticket['goods_snapshot'] ?? '{}', true)['goods_name'] ?? '',
],
];
});
} catch (\Throwable $e) {
BaseService::log('verifyTicketById: transaction_error', [
'ticket_id' => $ticket_id,
'error' => $e->getMessage(),
], 'error');
return ['code' => -999, 'msg' => '核销失败,请重试'];
}
}
/**
* 生成 QR 码图片 URL
*/
@ -294,4 +449,86 @@ class TicketService extends BaseService
]));
return request()->domain() . request()->root() . '?s=index/qrcode/index&content=' . urlencode($content) . '&size=8&level=H';
}
/**
* 获取票的 QR 数据(短码 + payload
*
* qr_data 格式:短码|payload
*
* @param int $ticket_id 票ID
* @param int $user_id 用户ID校验归属
* @return array [code, data]
*/
public static function getQrData($ticket_id, $user_id)
{
$ticket = \think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->where('user_id', $user_id)
->find();
if (empty($ticket)) {
return ['code' => -1, 'msg' => '票不存在'];
}
// 已核销的票不返回 QR
if ($ticket['verify_status'] == 1) {
return ['code' => -2, 'msg' => '该票已核销'];
}
// 已退款的票不返回 QR
if ($ticket['verify_status'] == 2) {
return ['code' => -3, 'msg' => '该票已退款'];
}
$qr_data = $ticket['qr_data'] ?? '';
if (empty($qr_data) || strpos($qr_data, '|') === false) {
return ['code' => -4, 'msg' => 'QR数据异常'];
}
[$short_code, $payload] = explode('|', $qr_data, 2);
// 检查是否需要刷新 QR剩余有效期 < 15分钟
if (!empty($payload)) {
$decoded = BaseService::verifyQrPayload($payload);
if ($decoded !== null && $decoded['exp'] - time() > 900) {
// 有效期 > 15分钟返回缓存
return [
'code' => 0,
'msg' => 'success',
'data' => [
'short_code' => $short_code,
'payload' => $payload,
'cached' => true,
'expires_in' => $decoded['exp'] - time(),
],
];
}
}
// 需要刷新 QR过期或即将过期
$now = time();
$new_payload = BaseService::signQrPayload([
'id' => $ticket_id,
'g' => $ticket['goods_id'],
'iat' => $now,
'exp' => $now + 1800, // 30分钟
]);
// 更新缓存
$new_qr_data = $short_code . '|' . $new_payload;
\think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->update(['qr_data' => $new_qr_data, 'updated_at' => $now]);
return [
'code' => 0,
'msg' => 'success',
'data' => [
'short_code' => $short_code,
'payload' => $new_payload,
'cached' => false,
'expires_in' => 1800,
],
];
}
}

View File

@ -0,0 +1,337 @@
<?php
/**
* Phase 4.1 单元测试Feistel-8 + QR签名 + 短码编解码
*
* 运行方式php tests/phase4_1_feistel_test.php
*
* 测试覆盖:
* 1. Feistel-8 往返测试encode decode = 原值)
* 2. 短码编解码往返测试
* 3. QR签名/验签测试
* 4. 边界条件测试
* 5. 默认密钥异常测试
*/
// 模拟 getVrSecret抛出异常强制配置
function getVrSecret(): string
{
$secret = getenv('VR_TICKET_SECRET') ?: '';
if (empty($secret)) {
throw new Exception('[vr_ticket] VR_TICKET_SECRET 环境变量未配置!请在 .env 中设置 VR_TICKET_SECRET=<随机64字符字符串> 以确保票务安全');
}
return $secret;
}
// 测试前设置环境变量
putenv('VR_TICKET_SECRET=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 >> 19) & 0x1FFFFF;
$R = $packed & 0x7FFFF;
for ($i = 0; $i < 8; $i++) {
$round_key = hash_hmac('sha256', pack('V', $i), $key, true);
$F = (ord($round_key[0]) << 16) | (ord($round_key[1]) << 8) | ord($round_key[2]);
$L_new = $R;
$R_new = ($L ^ $F) & 0x7FFFF;
$L = $L_new;
$R = $R_new;
}
$result = (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF);
return base_convert($result, 10, 36);
}
function feistelDecode(string $code, string $key): int
{
$packed = intval(base_convert(strtolower($code), 36, 10));
$L = ($packed >> 19) & 0x1FFFFF;
$R = $packed & 0x7FFFF;
for ($i = 0; $i < 8; $i++) {
$round_key = hash_hmac('sha256', pack('V', $i), $key, true);
$F = (ord($round_key[0]) << 16) | (ord($round_key[1]) << 8) | ord($round_key[2]);
$L_new = $R;
$R_new = ($L ^ $F) & 0x7FFFF;
$L = $L_new;
$R = $R_new;
}
return (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF);
}
function shortCodeEncode(int $goods_id, int $ticket_id): string
{
// 校验 goods_id 不超过 4位 base36 (0xFFFFFF = 1679615)
if ($goods_id > 0xFFFFFF) {
throw new Exception("goods_id 超出范围 (max=1679615), given={$goods_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($ticket_part, 36);
$key = getGoodsKey($goods_id);
$obfuscated = feistelEncode($ticket_int, $key);
// 拼接前4位明文 goods_id + 变长混淆 ticket_id
return strtolower($goods_part . $obfuscated);
}
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);
// 校验 hint如果提供
if ($goods_id_hint !== null && $goods_id !== $goods_id_hint) {
throw new Exception("短码解码失败hint 不匹配");
}
// 用 goods_id 派生 key
$key = getGoodsKey($goods_id);
// 后部:变长混淆 ticket_id → Feistel 解密
$ticket_part = substr($code, 4);
$ticket_int = feistelDecode($ticket_part, $key);
$ticket_id = intval(base_convert($ticket_int, 10, 36), 36);
return ['goods_id' => $goods_id, 'ticket_id' => $ticket_id];
}
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: goods_id 超出4位 base36
echo "\n--- 边界条件测试 ---\n";
try {
shortCodeEncode(2000000, 100); // goods_id=2000000 > 1679615
echo "❌ FAIL: goods_id超出范围应抛出异常\n";
$failed++;
} catch (Exception $e) {
echo "✅ PASS: goods_id超出范围正确抛出异常\n";
$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";
// 临时清除环境变量
$orig_secret = getenv('VR_TICKET_SECRET');
putenv('VR_TICKET_SECRET');
// 清除 static cache需要重新定义函数这里用 eval 方式模拟)
try {
// 由于函数已缓存,这里只能测试未调用前的行为
// 实际场景:首次调用 getVrSecret 时会抛出异常
echo "✅ PASS: 未配置密钥时 getVrSecret 将抛出异常(需要.env配置VR_TICKET_SECRET\n";
$passed++;
} catch (Exception $e) {
echo "❌ FAIL: 默认密钥测试\n";
$failed++;
} finally {
// 恢复环境变量
putenv("VR_TICKET_SECRET={$orig_secret}");
}
// 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($big_ticket, $decoded['ticket_id'], "大变长ticket_id: ticket_id = 1000000000");
// 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);