vr-shopxo-plugin/docs/PHASE_4_PLAN.md

686 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# Phase 4 规划:发票 · 核销 · 票夹
> 规划日期2026-04-22
> 最后更新2026-04-23算法变更Feistel-8 → HMAC-XORBUG修复
> 状态:**Phase 4.1/4.2 已完成B端核销进行中**
---
## 一、目标功能
| 功能 | 描述 | 优先级 |
|------|------|--------|
| **A. C端票夹** | 用户查看已购票 + 展示QR + 短码 + 核销状态 | P0 |
| **B. B端核销页** | 工作人员扫码/输入短码 → 票验证 → 核销 | P0 |
| **C. 出票链路闭环** | 支付成功 → 生成 vr_tickets → 用户可见票 | P0 |
---
## 二、码体系设计(最终版)
### 2.1 设计原则
| 码类型 | 用途 | 场景 | 安全模型 |
|--------|------|------|---------|
| **QR码** | 自助机/无人值守 | 微信/支付宝扫一扫 | JWT签名 + 时间戳30min窗口防暴力破解 |
| **短码** | 人工核销/扫码枪/手动输入 | 核销员在场 | Feistel混淆 + DB查询核销员人工对抗 |
**核销员是真人 → 无限重试攻击不可行 → 密码学防伪造降到次要地位**
### 2.2 QR码JWT签名
**Payload 结构**
```json
{
"id": 482815,
"g": 118,
"iat": 1745286000,
"exp": 1745287800,
"sig": "A3F9B2C1"
}
```
**签名算法HMAC-SHA256防篡改**
```php
$sign_str = "{$id}.{$g}.{$iat}.{$exp}";
$sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8);
```
**核销机本地验证流程(离线可行)**
```
1. 解析 payload
2. 检查 exp < now → 已过期 → 拒绝
3. 验签: HMAC-SHA256("{$id}.{$g}.{$iat}.{$exp}", secret)[0:8] == sig
→ 不等 → 伪造 → 拒绝
→ 相等 → 票凭证合法
4. 联网查 DB: verify_status == 0? → 未核销 → 放行
```
步骤1-3 完全本地执行不联网也可判断凭证是否被篡改或过期。步骤4联网是执行 DB 状态更新(防重放)。
**加密吗?** 不加密。payload 内容id/goods_id/时间戳本身无害泄露无害只需防篡改。HMAC-SHA256 的计算不可逆性保证了安全性。
**客户端缓存策略(节省服务端调用)**
```
QR有效期30分钟
阈值:剩余有效期 > 15分钟900s→ 返回缓存;≤ 15分钟 → 刷新
前端 localStorage 缓存格式:
{
"payload": "BASE64_QR内容",
"generated_at": 1745286000,
"expires_at": 1745287800
}
后端 getQrData() 缓存判断逻辑PHP
if (verifyQrPayload(payload) !== null && exp - now > 900) {
return ['cached' => true, ...]; // 有效期 > 15min返回缓存
}
// 否则生成新 QR
```
**阈值 15 分钟的设计理由**:给用户留出足够的提前量,在 QR 即将过期前静默刷新,避免展示过期 QR 导致核销失败。
### 2.3 短码HMAC-XOR混淆可变长度
> **⚠️ 算法变更记录2026-04-23**:原 Feistel-8 方案因往返失败废弃,改为 HMAC-XOR 方案。详见「十三、重大变更记录」。
**编码结构**
```
[goods_id: base36, 固定4位明文] + [ticket_id: base36, 可变长度] → HMAC-XOR混淆 → base36 → 短码
```
| 字段 | 编码 | 位数 | 范围 |
|------|------|------|------|
| goods_id | base36, **固定4位** | ~20bit | ~167万ShopXO商品总量13万足足有余 |
| ticket_id | base36, **可变长度** | 随 ID 增长 | 全局 BIGINT 自增,上限无限制 |
**码长范围**
| ticket_id | base36编码 | 短码总字符数 |
|-----------|-----------|------------|
| 1 | 1 | 124+8HMAC-XOR 输出更长) |
| 100 | 2s | 12 |
| 482815 | 9nxr | 12 |
| 10亿 | gqd708 | 12 |
| **实际业务** | **~8位 base36** | **~12位** |
**设计意图**
- goods_id **明文前4位** → 解码 O(1),无需搜索,直接从短码头部截取
- ticket_id **混淆** → 防暴力猜测(不知道 per-goods key 则无法反推)
- **XOR 本身可逆**无需逆向轮次encode/decode 逻辑完全相同(对称密码)
- per-goods key 由 `HMAC-SHA256(master_secret, goods_id)` 派生,不同商品互相隔离
**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); // 可变长度,不补位
$ticket_int = intval($ticket_part, 36);
$key = self::getGoodsKey($goods_id);
$obfuscated = self::feistelEncode($ticket_int, $key);
return strtolower($goods_part . $obfuscated);
}
function shortCodeDecode(string $code, ?int $goods_id_hint = null): array
{
$code = strtolower($code);
// 前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 getGoodsKey(int $goods_id): string
{
static $cache = [];
if (!isset($cache[$goods_id])) {
$secret = self::getVrSecret();
$cache[$goods_id] = substr(hash_hmac('sha256', (string)$goods_id, $secret), 0, 16);
}
return $cache[$goods_id];
}
```
**验证流程(完全自动路由)**
```
核销员扫 short_code: "003a2hgmgety"
decode("003a2hgmgety") → 前4位=003a→goods_id=118剩余→HMAC-XOR解密→ticket_id=1
DB: WHERE id=1 AND goods_id=118 → 命中 ✅ → 核销
```
**无需知道 goods_id 在哪,也无需选场次,自动路由。**
### 2.4 票面三码并行展示
```
┌──────────────────────────────────────────────────────────┐
│ 🎵 周杰伦2026巡回演唱会 │
│ 📅 2026-06-01 20:00 📍 国家体育馆 │
│ 💺 A区-3排-15座 👤 张三 138****1234 │
│ │
│ │
│ [============== QR CODE ==============] │
│ (JWT签名, 30min有效, 本地缓存) │
│ │
│ [============== BAR CODE ==============] │
│ (短码条形码) │
│ │
│ 短码: AX7FK9P3 ← 显示明文,扫码枪无效时候/ 手动输入备用 │
└──────────────────────────────────────────────────────────┘
```
---
## 三、功能 AC端票夹
### 3.1 挂载点选择
```
plugins_service_order_detail_page_info → 订单详情页注入票卡推荐P0
plugins_view_user_various_inside_top → 用户中心顶部入口(次选)
```
推荐方案:在订单详情页注入票卡(用户已有购票行为,路径最短)。
### 3.2 页面结构
```
view/goods/ticket_wallet.html ← 独立票夹页(完整列表)
view/goods/ticket_card.html ← 共享票卡片片段QR + 短码 + 状态)
```
### 3.3 API 设计C端
| 方法 | 路由 | 说明 | 登录态 |
|------|------|------|--------|
| GET | `/?s=api/vr_ticket/tickets` | 用户所有票列表 | 必须 |
| GET | `/?s=api/vr_ticket/qr_data&id=X` | 获取QR签名payload | 必须 |
---
## 四、功能 BB端核销
### 4.1 页面设计
```
admin/view/ticket/verify.html ← 扫码核销页
admin/controller/Ticket.php ← verifyPage() + verifySubmit()
```
页面交互:
1. 扫码枪捕获输入 → 自动提交
2. 手动输入短码 → 回车提交
3. 调用 `TicketService::verifyTicket()`
4. 展示结果 + 声音提示
5. 清空input支持连续扫描
### 4.2 核销API
```
POST /?s=admin/vr_ticket/verify
Body: {
"ticket_code": "uuid-xxx", // QR解密后传ticket_code
"short_code": "Ax7fK9p3", // 或短码(自动解码路由)
"verifier_id": 1
}
```
**自动路由逻辑verifyTicket内部**
```php
// 优先 QR ticket_code
if (!empty($ticket_code)) {
$ticket = Db::name('tickets')->where('ticket_code', $ticket_code)->find();
}
// 回退短码decode → goods_id + ticket_id → DB查询
if (empty($ticket) && !empty($short_code)) {
// goods_id从短码中解出直接命中
$ticket = Db::name('tickets')
->where('goods_id', $decoded['goods_id'])
->where('id', $decoded['ticket_id'])
->find();
}
```
### 4.3 权限
后台管理员登录态ShopXO Admin AuthAPI 入口加核销员权限验证。
---
## 五、功能 C出票链路闭环
### 5.1 支付回调链路
ShopXO 微信支付流程:
```
微信支付成功
→ 回调 ShopXO 支付回调URL
→ 更新 order.pay_status = 1
→ 触发 hook: plugins_service_order_pay_success_handle_end
→ Hook.php → TicketService::onOrderPaid()
```
`Hook.php` 已注册该hook无需修改。
### 5.2 spec 解析适配5维结构
5维 spec_value 格式:
```
"08:00|测试场馆|主要展厅|A区|A1"
parts[0]=场次, [1]=场馆, [2]=演播室, [3]=分区, [4]=座位号
```
### 5.3 观演人信息传递
购票页提交时attendee 数据写入 `order.extension_data`
```json
{
"attendee": {
"real_name": "张三",
"phone": "13800138000",
"id_card": "110101199001011234"
}
}
```
`onOrderPaid` 解析该字段写入 `vr_tickets`
### 5.4 issueTicket 写入内容(实际实现)
> **⚠️ 与原 Plan 差异**:原 Plan 设计了「先占位 ticket_id=0再回填」的两步流程。实际实现中`insertGetId` 在 INSERT 后立即返回真实 ticket_id因此可以直接签名无需占位回填。
```php
public static function issueTicket($order, $og)
{
// 1. 幂等保护
$existing = Db::name('tickets')
->where('order_id', $order['id'])
->where('seat_info', $spec_name)
->find();
if (!empty($existing)) return $existing['id'];
// 2. 生成 ticket_codeUUID
$ticket_code = BaseService::generateUuid();
$now = BaseService::now();
// 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([...], JSON_UNESCAPED_UNICODE),
'user_id' => $order['user_id'],
'ticket_code' => $ticket_code,
'qr_data' => '', // 占位,稍后更新
'seat_info' => $spec_name,
'spec_base_id' => $spec_base_id,
'real_name' => '',
'phone' => '',
'id_card' => '',
'verify_status' => 0,
'issued_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
// 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([
'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
---
## 六、数据库变更
### Migration: `002_ticket_wallet.sql`
```sql
-- =====================================================
-- Phase 4: 票夹 + 核销 + 短码 + QR签名
-- =====================================================
-- goods_snapshot 扩大(存更多场次信息)
ALTER TABLE vrt_tickets
MODIFY COLUMN goods_snapshot LONGTEXT;
-- qr_data 字段保留原qr加密内容 → 替换为JWT签名内容无需重建
```
**无需新增 short_code 存储字段**。短码由 ticket_id + goods_id 实时编码,不存 DB。核销时解码直接反推 ID。
---
## 七、目录结构Phase 4 新增/修改)
```
shopxo/app/plugins/vr_ticket/
├── admin/controller/
│ └── Ticket.php # [新建] B端核销API
├── api/controller/
│ └── Ticket.php # [新建] C端票API
├── service/
│ ├── TicketService.php # [修改] 适配5维spec + QR签名
│ ├── WalletService.php # [新建] 票夹聚合查询
│ └── BaseService.php # [修改] Feistel8 + QR签名 + ShortCode编解码
├── view/
│ ├── admin/ticket/
│ │ └── verify.html # [新建] B端扫码核销页
│ └── goods/
│ ├── ticket_wallet.html # [新建] C端票夹页
│ └── ticket_card.html # [新建] 共享票卡片段
├── Hook.php # [修改] 注册API路由
└── database/migrations/
└── 002_ticket_wallet.sql # [新建] 票夹migration
```
---
## 八、API 总览
### C端用户
| 方法 | 路由 | 说明 | 登录 |
|------|------|------|------|
| GET | `/?s=api/vr_ticket/tickets` | 票列表 | 必须 |
| GET | `/?s=api/vr_ticket/qr_data&id=X` | 获取QR签名payload | 必须 |
### B端管理员
| 方法 | 路由 | 说明 | 登录 |
|------|------|------|------|
| GET | `/?s=admin/vr_ticket/verify_page` | 核销页面 | Admin |
| POST | `/?s=admin/vr_ticket/verify` | 核销提交 | Admin |
| GET | `/?s=admin/vr_ticket/stats` | 核销统计 | Admin |
---
## 九、实现顺序
```
Phase 4.1 — 基础设施
├─ BaseService: Feistel8 encode/decode
├─ BaseService: signQrPayload / verifyQrPayload
├─ BaseService: shortCodeEncode / shortCodeDecode
└─ DB Migration: 002_ticket_wallet.sql
Phase 4.2 — 出票链路(最关键)
├─ TicketService::onOrderPaid: 适配5维spec解析
├─ TicketService::issueTicket: JWT签名QR + 写入
└─ 联调:支付成功 → 查 vr_tickets 有记录
Phase 4.3 — C端票夹
├─ api/controller/Ticket.php
├─ WalletService.php
├─ ticket_wallet.html
├─ ticket_card.html
└─ QR本地缓存逻辑
Phase 4.4 — B端核销
├─ admin/controller/Ticket.php: verifySubmit
├─ admin/view/ticket/verify.html
└─ 联调:扫码 → 核销成功 → vr_verifications有记录
Phase 4.5 — 全链路验证
└─ 完整流程: 选座→下单→支付→出票→票夹展示→核销
```
---
## 十、调研结果2026-04-22
### Q1+Q2支付回调时机 + extension_data ✅
**触发时机**`plugins_service_order_pay_success_handle_end` 在 `pay_status=1, status=2` 已写入DB后触发。
**$params 结构**:使用 `$params['order_id']` 而非 `business_id`
```php
$params = [
'order' => [/* 单个订单全字段,含 extension_data */],
'order_id'=> 123,
'params' => [/* 支付参数,含 extension_data */],
];
```
**extension_data 可读性**:✅ 完全可读。`$order` 是DB查询内存副本`update` 操作不影响内存变量。`onOrderPaid` 中 `json_decode($order['extension_data'])` 可正常工作。
---
### Q3API 路由注册 ✅
**无需手动声明路由**。全靠 `PluginsService::PluginsControlCall` 动态映射:
```
/?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=api&pluginsaction=Tickets
→ \app\plugins\vr_ticket\api\Api::Tickets()
```
**三种入口**
| group | 入口 | 目录 |
|-------|------|------|
| `index` | C端页面 | `app/plugins/vr_ticket/index/` |
| `admin` | B端后台 | `app/plugins/vr_ticket/admin/` |
| `api` | C端API | `app/plugins/vr_ticket/api/` |
**⚠️ 当前 vr_ticket 只有 admin/,需新建 api/ 和 index/。**
---
### Q4+Q5localStorage + QR/条码 ✅
**localStorage**:无统一封装,票务页面直接用原生 `localStorage`(够用)。
**QR码**
- 前端jQuery QRcode 插件 `$('.view-qrcode-init').qrcode({text: value})`
- 后端:`/?s=index/qrcode/index` 渲染 PNG
**条码库**:✅ **JsBarcode v3.11.5 已内置**`public/static/common/lib/JsBarcode/JsBarcode.all.min.js`
**前端模板**:原生 PHP + 手写 JS无前端框架。票面 JS 可直接使用 localStorage + JsBarcode + jQuery QRcode无需引入额外库。
---
## 十一、当前实现状态(截至 2026-04-23
### ✅ 已完成
-`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 派生(不同商品短码互相隔离)
### 🔧 进行中
-`admin/controller/Ticket.php` — B端核销API
-`admin/view/ticket/verify.html` — B端扫码核销页
### ❌ 待新建
-`api/controller/Ticket.php` — C端票API
-`index/Ticket.php` — C端票夹页面
-`view/goods/ticket_wallet.html` — C端票夹页
-`view/goods/ticket_card.html` — 共享票卡片段
-`service/WalletService.php` — 票夹聚合查询
---
## 十二、安全模型总结
| 场景 | 攻击难度 | 防御机制 |
|------|---------|---------|
| 自助机 QR 暴力破解 | 无人值守,可无限重试 | HMAC-SHA256签名 + 30min时间窗口伪造不可行 |
| 核销员短码猜测 | 人工核验,无法无限重试 | HMAC-XOR混淆per-goods key 隔离),猜错直接拒 |
| QR 重放(截图复用) | 同一QR反复扫 | DB verify_status 检查 |
| 伪造 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 派生。