2026-04-22 09:58:39 +00:00
|
|
|
|
# Phase 4 规划:发票 · 核销 · 票夹
|
|
|
|
|
|
|
|
|
|
|
|
> 规划日期:2026-04-22
|
2026-04-25 01:03:52 +00:00
|
|
|
|
> 最后更新:2026-04-25(JsBarcode本地化修复 + 状态追踪重置)
|
2026-04-25 01:19:03 +00:00
|
|
|
|
> 状态:**Phase 4.1/4.2/4.3 完成,B端核销待开发,M-06权限为B端前置**
|
2026-04-25 01:03:52 +00:00
|
|
|
|
> 进度追踪:[Issue #22](http://xmhome.gitop.top:3000/sileya-ai/vr-shopxo-plugin/issues/22) | 安全问题:[Issue #7](http://xmhome.gitop.top:3000/sileya-ai/vr-shopxo-plugin/issues/7)(全未修复)
|
2026-04-22 09:58:39 +00:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 一、目标功能
|
|
|
|
|
|
|
|
|
|
|
|
| 功能 | 描述 | 优先级 |
|
|
|
|
|
|
|------|------|--------|
|
|
|
|
|
|
| **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分钟
|
2026-04-23 04:35:10 +00:00
|
|
|
|
阈值:剩余有效期 > 15分钟(900s)→ 返回缓存;≤ 15分钟 → 刷新
|
2026-04-22 09:58:39 +00:00
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
前端 localStorage 缓存格式:
|
2026-04-22 09:58:39 +00:00
|
|
|
|
{
|
2026-04-23 04:35:10 +00:00
|
|
|
|
"payload": "BASE64_QR内容",
|
2026-04-22 09:58:39 +00:00
|
|
|
|
"generated_at": 1745286000,
|
|
|
|
|
|
"expires_at": 1745287800
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
后端 getQrData() 缓存判断逻辑(PHP):
|
|
|
|
|
|
if (verifyQrPayload(payload) !== null && exp - now > 900) {
|
|
|
|
|
|
return ['cached' => true, ...]; // 有效期 > 15min,返回缓存
|
|
|
|
|
|
}
|
|
|
|
|
|
// 否则生成新 QR
|
2026-04-22 09:58:39 +00:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
**阈值 15 分钟的设计理由**:给用户留出足够的提前量,在 QR 即将过期前静默刷新,避免展示过期 QR 导致核销失败。
|
|
|
|
|
|
|
|
|
|
|
|
### 2.3 短码(HMAC-XOR混淆,可变长度)
|
|
|
|
|
|
|
|
|
|
|
|
> **⚠️ 算法变更记录(2026-04-23)**:原 Feistel-8 方案因往返失败废弃,改为 HMAC-XOR 方案。详见「十三、重大变更记录」。
|
2026-04-22 09:58:39 +00:00
|
|
|
|
|
|
|
|
|
|
**编码结构**:
|
|
|
|
|
|
```
|
2026-04-23 04:35:10 +00:00
|
|
|
|
[goods_id: base36, 固定4位明文] + [ticket_id: base36, 可变长度] → HMAC-XOR混淆 → base36 → 短码
|
2026-04-22 09:58:39 +00:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
| 字段 | 编码 | 位数 | 范围 |
|
|
|
|
|
|
|------|------|------|------|
|
|
|
|
|
|
| goods_id | base36, **固定4位** | ~20bit | ~167万(ShopXO商品总量13万,足足有余) |
|
2026-04-23 04:35:10 +00:00
|
|
|
|
| ticket_id | base36, **可变长度** | 随 ID 增长 | 全局 BIGINT 自增,上限无限制 |
|
2026-04-22 09:58:39 +00:00
|
|
|
|
|
|
|
|
|
|
**码长范围**:
|
2026-04-23 04:35:10 +00:00
|
|
|
|
| ticket_id | base36编码 | 短码总字符数 |
|
|
|
|
|
|
|-----------|-----------|------------|
|
|
|
|
|
|
| 1 | 1 | 12(4+8,HMAC-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);
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**短码编解码**:
|
2026-04-22 09:58:39 +00:00
|
|
|
|
```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);
|
2026-04-23 04:35:10 +00:00
|
|
|
|
$ticket_part = base_convert($ticket_id, 10, 36); // 可变长度,不补位
|
|
|
|
|
|
$ticket_int = intval($ticket_part, 36);
|
2026-04-22 09:58:39 +00:00
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
$key = self::getGoodsKey($goods_id);
|
|
|
|
|
|
$obfuscated = self::feistelEncode($ticket_int, $key);
|
|
|
|
|
|
|
|
|
|
|
|
return strtolower($goods_part . $obfuscated);
|
2026-04-22 09:58:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
function shortCodeDecode(string $code, ?int $goods_id_hint = null): array
|
2026-04-22 09:58:39 +00:00
|
|
|
|
{
|
2026-04-23 04:35:10 +00:00
|
|
|
|
$code = strtolower($code);
|
|
|
|
|
|
|
|
|
|
|
|
// 前4位:明文 goods_id
|
|
|
|
|
|
$goods_id = intval(substr($code, 0, 4), 36);
|
2026-04-22 09:58:39 +00:00
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
// 剩余全部:混淆 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);
|
2026-04-22 09:58:39 +00:00
|
|
|
|
|
|
|
|
|
|
return ['goods_id' => $goods_id, 'ticket_id' => $ticket_id];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
function getGoodsKey(int $goods_id): string
|
2026-04-22 09:58:39 +00:00
|
|
|
|
{
|
|
|
|
|
|
static $cache = [];
|
|
|
|
|
|
if (!isset($cache[$goods_id])) {
|
2026-04-23 04:35:10 +00:00
|
|
|
|
$secret = self::getVrSecret();
|
|
|
|
|
|
$cache[$goods_id] = substr(hash_hmac('sha256', (string)$goods_id, $secret), 0, 16);
|
2026-04-22 09:58:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
return $cache[$goods_id];
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**验证流程(完全自动路由)**:
|
|
|
|
|
|
```
|
2026-04-23 04:35:10 +00:00
|
|
|
|
核销员扫 short_code: "003a2hgmgety"
|
2026-04-22 09:58:39 +00:00
|
|
|
|
↓
|
2026-04-23 04:35:10 +00:00
|
|
|
|
decode("003a2hgmgety") → 前4位=003a→goods_id=118,剩余→HMAC-XOR解密→ticket_id=1
|
2026-04-22 09:58:39 +00:00
|
|
|
|
↓
|
2026-04-23 04:35:10 +00:00
|
|
|
|
DB: WHERE id=1 AND goods_id=118 → 命中 ✅ → 核销
|
2026-04-22 09:58:39 +00:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**无需知道 goods_id 在哪,也无需选场次,自动路由。**
|
|
|
|
|
|
|
|
|
|
|
|
### 2.4 票面三码并行展示
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
┌──────────────────────────────────────────────────────────┐
|
|
|
|
|
|
│ 🎵 周杰伦2026巡回演唱会 │
|
|
|
|
|
|
│ 📅 2026-06-01 20:00 📍 国家体育馆 │
|
|
|
|
|
|
│ 💺 A区-3排-15座 👤 张三 138****1234 │
|
|
|
|
|
|
│ │
|
|
|
|
|
|
│ │
|
|
|
|
|
|
│ [============== QR CODE ==============] │
|
|
|
|
|
|
│ (JWT签名, 30min有效, 本地缓存) │
|
|
|
|
|
|
│ │
|
|
|
|
|
|
│ [============== BAR CODE ==============] │
|
|
|
|
|
|
│ (短码条形码) │
|
|
|
|
|
|
│ │
|
|
|
|
|
|
│ 短码: AX7FK9P3 ← 显示明文,扫码枪无效时候/ 手动输入备用 │
|
|
|
|
|
|
└──────────────────────────────────────────────────────────┘
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 三、功能 A:C端票夹
|
|
|
|
|
|
|
|
|
|
|
|
### 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 | 必须 |
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 四、功能 B:B端核销
|
|
|
|
|
|
|
|
|
|
|
|
### 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 Auth),API 入口加核销员权限验证。
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 五、功能 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`。
|
|
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
### 5.4 issueTicket 写入内容(实际实现)
|
|
|
|
|
|
|
|
|
|
|
|
> **⚠️ 与原 Plan 差异**:原 Plan 设计了「先占位 ticket_id=0,再回填」的两步流程。实际实现中,`insertGetId` 在 INSERT 后立即返回真实 ticket_id,因此可以直接签名,无需占位回填。
|
2026-04-22 09:58:39 +00:00
|
|
|
|
|
|
|
|
|
|
```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'];
|
|
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
// 2. 生成 ticket_code(UUID)
|
2026-04-22 09:58:39 +00:00
|
|
|
|
$ticket_code = BaseService::generateUuid();
|
2026-04-23 04:35:10 +00:00
|
|
|
|
$now = BaseService::now();
|
2026-04-22 09:58:39 +00:00
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
// 3. 先 INSERT 获取 ticket_id(用于短码生成)
|
2026-04-22 09:58:39 +00:00
|
|
|
|
$ticket_id = Db::name('tickets')->insertGetId([
|
|
|
|
|
|
'order_id' => $order['id'],
|
|
|
|
|
|
'order_no' => $order['order_no'],
|
|
|
|
|
|
'goods_id' => $order['goods_id'],
|
2026-04-23 04:35:10 +00:00
|
|
|
|
'goods_snapshot' => json_encode([...], JSON_UNESCAPED_UNICODE),
|
2026-04-22 09:58:39 +00:00
|
|
|
|
'user_id' => $order['user_id'],
|
|
|
|
|
|
'ticket_code' => $ticket_code,
|
2026-04-23 04:35:10 +00:00
|
|
|
|
'qr_data' => '', // 占位,稍后更新
|
2026-04-22 09:58:39 +00:00
|
|
|
|
'seat_info' => $spec_name,
|
|
|
|
|
|
'spec_base_id' => $spec_base_id,
|
2026-04-23 04:35:10 +00:00
|
|
|
|
'real_name' => '',
|
|
|
|
|
|
'phone' => '',
|
|
|
|
|
|
'id_card' => '',
|
2026-04-22 09:58:39 +00:00
|
|
|
|
'verify_status' => 0,
|
2026-04-23 04:35:10 +00:00
|
|
|
|
'issued_at' => $now,
|
|
|
|
|
|
'created_at' => $now,
|
|
|
|
|
|
'updated_at' => $now,
|
2026-04-22 09:58:39 +00:00
|
|
|
|
]);
|
|
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
// 4. 生成短码(goods_id 明文 + ticket_id 混淆)
|
|
|
|
|
|
$short_code = BaseService::shortCodeEncode($order['goods_id'], $ticket_id);
|
|
|
|
|
|
|
|
|
|
|
|
// 5. 生成 QR payload(HMAC-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'] ?? [];
|
2026-04-22 09:58:39 +00:00
|
|
|
|
Db::name('tickets')->where('id', $ticket_id)->update([
|
2026-04-23 04:35:10 +00:00
|
|
|
|
'real_name' => $attendee['real_name'] ?? '',
|
|
|
|
|
|
'phone' => $attendee['phone'] ?? '',
|
|
|
|
|
|
'id_card' => $attendee['id_card'] ?? '',
|
2026-04-22 09:58:39 +00:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
return $ticket_id;
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
**QR payload 最终格式**:
|
|
|
|
|
|
```json
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": 482815,
|
|
|
|
|
|
"g": 118,
|
|
|
|
|
|
"iat": 1745286000,
|
|
|
|
|
|
"exp": 1745287800,
|
|
|
|
|
|
"sig": "A3F9B2C1"
|
|
|
|
|
|
}
|
|
|
|
|
|
→ base64_encode(json_encode(...))
|
|
|
|
|
|
→ 存储于 vr_tickets.qr_data(短码|payload)
|
|
|
|
|
|
|
2026-04-22 09:58:39 +00:00
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 六、数据库变更
|
|
|
|
|
|
|
|
|
|
|
|
### 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 — 基础设施
|
2026-04-25 01:03:52 +00:00
|
|
|
|
├─ 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端票夹 ✅(2026-04-24)
|
|
|
|
|
|
├─ api/controller/Ticket.php ✅
|
|
|
|
|
|
├─ WalletService.php ✅
|
|
|
|
|
|
├─ ticket_wallet.html ✅
|
|
|
|
|
|
├─ ticket_card.html ✅
|
|
|
|
|
|
├─ QR本地缓存逻辑 ✅
|
|
|
|
|
|
└─ JsBarcode本地化 ✅(2026-04-25,cdn.jsdelivr → ShopXO自带)
|
|
|
|
|
|
|
2026-04-25 01:19:03 +00:00
|
|
|
|
**⚠️ 安全问题(Issue #7,已重新评估 2026-04-25)**:
|
|
|
|
|
|
├─ M-01 ✅ 已修复(FOR UPDATE + 事务)
|
|
|
|
|
|
├─ M-04 ✅ 已实现(seatSpecMap.inventory 反推已售座位,ShopXO原生防超卖)
|
|
|
|
|
|
├─ M-08 ✅ 已修复(无占位符写入)
|
|
|
|
|
|
├─ M-03 🟢 快速修复(ALTER TABLE条件bug,2行)
|
|
|
|
|
|
├─ M-07 🟢 低风险(QR payload无害)
|
|
|
|
|
|
└─ M-02/M-05/M-06 ⚠️ B端开发时处理(M-06为B端安全基线)
|
2026-04-25 01:03:52 +00:00
|
|
|
|
|
|
|
|
|
|
Phase 4.4 — B端核销 ❌ 未开始
|
2026-04-22 09:58:39 +00:00
|
|
|
|
├─ admin/controller/Ticket.php: verifySubmit
|
|
|
|
|
|
├─ admin/view/ticket/verify.html
|
2026-04-25 01:03:52 +00:00
|
|
|
|
├─ M-01 TOCTOU原子更新
|
|
|
|
|
|
├─ M-02/M-05 verifier_id鉴权
|
2026-04-22 09:58:39 +00:00
|
|
|
|
└─ 联调:扫码 → 核销成功 → vr_verifications有记录
|
|
|
|
|
|
|
2026-04-25 01:03:52 +00:00
|
|
|
|
Phase 4.5 — 全链路验证 ❌ 未开始
|
2026-04-22 09:58:39 +00:00
|
|
|
|
└─ 完整流程: 选座→下单→支付→出票→票夹展示→核销
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 十、调研结果(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'])` 可正常工作。
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### Q3:API 路由注册 ✅
|
|
|
|
|
|
|
|
|
|
|
|
**无需手动声明路由**。全靠 `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+Q5:localStorage + 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 04:35:10 +00:00
|
|
|
|
## 十一、当前实现状态(截至 2026-04-23)
|
2026-04-22 09:58:39 +00:00
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
### ✅ 已完成
|
|
|
|
|
|
- ✅ `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 派生(不同商品短码互相隔离)
|
2026-04-22 09:58:39 +00:00
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
### 🔧 进行中
|
|
|
|
|
|
- ⏳ `admin/controller/Ticket.php` — B端核销API
|
|
|
|
|
|
- ⏳ `admin/view/ticket/verify.html` — B端扫码核销页
|
2026-04-22 09:58:39 +00:00
|
|
|
|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
### ❌ 待新建
|
|
|
|
|
|
- ❌ `api/controller/Ticket.php` — C端票API
|
|
|
|
|
|
- ❌ `index/Ticket.php` — C端票夹页面
|
|
|
|
|
|
- ❌ `view/goods/ticket_wallet.html` — C端票夹页
|
|
|
|
|
|
- ❌ `view/goods/ticket_card.html` — 共享票卡片段
|
|
|
|
|
|
- ❌ `service/WalletService.php` — 票夹聚合查询
|
2026-04-22 09:58:39 +00:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 十二、安全模型总结
|
|
|
|
|
|
|
|
|
|
|
|
| 场景 | 攻击难度 | 防御机制 |
|
|
|
|
|
|
|------|---------|---------|
|
2026-04-23 04:35:10 +00:00
|
|
|
|
| 自助机 QR 暴力破解 | 无人值守,可无限重试 | HMAC-SHA256签名 + 30min时间窗口,伪造不可行 |
|
|
|
|
|
|
| 核销员短码猜测 | 人工核验,无法无限重试 | HMAC-XOR混淆(per-goods key 隔离),猜错直接拒 |
|
2026-04-22 09:58:39 +00:00
|
|
|
|
| QR 重放(截图复用) | 同一QR反复扫 | DB verify_status 检查 |
|
2026-04-23 04:35:10 +00:00
|
|
|
|
| 伪造 QR | 不知道 VR_TICKET_SECRET | HMAC签名,计算不可逆 |
|
|
|
|
|
|
| 短码解码 | 不知道 per-goods key | HMAC-XOR 对合密码,encode=decode |
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 十三、重大变更记录
|
|
|
|
|
|
|
|
|
|
|
|
### 变更 #1:Feistel-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-XOR(XOR 本身对合,无需逆向轮)
|
|
|
|
|
|
|
|
|
|
|
|
**新旧方案对比**:
|
|
|
|
|
|
|
|
|
|
|
|
| 属性 | 旧 Feistel-8(废弃) | 新 HMAC-XOR(当前) |
|
|
|
|
|
|
|------|---------------------|-------------------|
|
|
|
|
|
|
| 轮函数 | `hash_hmac('sha256', R.'.'.round, key)` 字符串拼接 | `hash_hmac('sha256', pack('V', i), key)` 二进制 pack |
|
|
|
|
|
|
| 位分配 | L=19bit, R=17bit(36bit) | L=21bit, R=19bit(40bit,实际用36bit) |
|
|
|
|
|
|
| 逆向方式 | 8轮逆向(7→0),F 输入反复出错 | 相同顺序(0→7),XOR 对合无需逆 |
|
|
|
|
|
|
| 往返测试 | ❌ 全部失败(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_id(10亿,1/1)
|
|
|
|
|
|
5. Per-goods key 隔离(1/1)
|
|
|
|
|
|
6. 边界条件(goods_id 超出范围 / ticket_id=0,2/3)
|
|
|
|
|
|
|
|
|
|
|
|
**⚠️ 注意**:测试使用 `test-key-12345678` 作为 feistel crypt key(与 per-goods key 派生逻辑分开),实际运行时 goods_id=118 对应的 per-goods key 由 `HMAC-SHA256(master_secret, 118)` 派生,两者不同。单元测试验证的是算法本身,不验证 key 派生。
|