vr-shopxo-plugin/docs/PHASE_4_PLAN.md

23 KiB
Raw Blame History

Phase 4 规划:发票 · 核销 · 票夹

规划日期2026-04-22 状态:规划完成,待启动调研


一、目标功能

功能 描述 优先级
A. C端票夹 用户查看已购票 + 展示QR + 短码 + 核销状态 P0
B. B端核销页 工作人员扫码/输入短码 → 票验证 → 核销 P0
C. 出票链路闭环 支付成功 → 生成 vr_tickets → 用户可见票 P0

二、码体系设计(最终版)

2.1 设计原则

码类型 用途 场景 安全模型
QR码 自助机/无人值守 微信/支付宝扫一扫 JWT签名 + 时间戳30min窗口防暴力破解
短码 人工核销/扫码枪/手动输入 核销员在场 Feistel混淆 + DB查询核销员人工对抗

核销员是真人 → 无限重试攻击不可行 → 密码学防伪造降到次要地位

2.2 QR码JWT签名

Payload 结构

{
  "id": 482815,
  "g": 118,
  "iat": 1745286000,
  "exp": 1745287800,
  "sig": "A3F9B2C1"
}

签名算法HMAC-SHA256防篡改

$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 算法

// 轮函数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);
}

短码编解码

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内部

// 优先 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

{
  "attendee": {
    "real_name": "张三",
    "phone": "13800138000",
    "id_card": "110101199001011234"
  }
}

onOrderPaid 解析该字段写入 vr_tickets

5.4 issueTicket 写入内容(实际实现)

⚠️ 与原 Plan 差异:原 Plan 设计了「先占位 ticket_id=0再回填」的两步流程。实际实现中insertGetId 在 INSERT 后立即返回真实 ticket_id因此可以直接签名无需占位回填。

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 最终格式

{
  "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_endpay_status=1, status=2 已写入DB后触发。

$params 结构:使用 $params['order_id'] 而非 business_id

$params = [
    'order'   => [/* 单个订单全字段,含 extension_data */],
    'order_id'=> 123,
    'params'  => [/* 支付参数,含 extension_data */],
];

extension_data 可读性 完全可读。$order 是DB查询内存副本update 操作不影响内存变量。onOrderPaidjson_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 派生。