# 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 结构**: ```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分钟 本地缓存(localStorage): { "encrypted_payload": "BASE64_QR内容", "generated_at": 1745286000, "expires_at": 1745287800 } 展示决策: 无缓存 → 立即请求服务器 expires_at > now → 展示缓存 ✅ expires_at <= now → 请求服务器(刷新) 剩余时间 < 15min → 静默预刷新,交换新码 ``` ### 2.3 短码(Feistel混淆,6~8字符) **编码结构**: ``` [goods_id: base36, 固定4位] + [ticket_id: base36, 可变长度] → Feistel8混淆 → base36 → 短码 ``` | 字段 | 编码 | 位数 | 范围 | |------|------|------|------| | goods_id | base36, **固定4位** | ~20bit | ~167万(ShopXO商品总量13万,足足有余) | | ticket_id | base36, **可变长度** | ticket增长而上 | 全局BIGINT自增,上限无限制 | **码长范围**: | ticket_id | base36编码 | 总字符数 | |-----------|-----------|---------| | 100 | 2s | 6 | | 1,000万 | 5r1FC | 6 | | 10亿 | 1egtd2 | 7 | | 28亿 | 5lja3k | 7 | | **实际业务上限** | **~8位** | **撑满8位** | **ticket_id 是全局 BIGINT 自增**,随时间推移 ID 持续增长,所以 ticket_id 编码字符数会变化。固定分隔设计让解码无歧义:后4位 = goods_id,前面的 = ticket_id。 **为什么 Feistel 混淆**: - 完全可逆,不需要存储解码表 - per-goods key 由 `HMAC-SHA256(master_secret, goods_id)` 派生 - 不知道 master_secret → 无法反推 ticket_id - 8轮足够快:单次解码 ~0.025ms,单进程 QPS ~4万 **编码实现**: ```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; $key = getPerGoodsKey($goods_id); return feistel8($packed, $key); } function shortCodeDecode(string $code, int $goods_id): array { $key = getPerGoodsKey($goods_id); $packed = feistel8_decode($code, $key); $goods_id = intval(base_convert(substr($packed, 0, 4), 36, 10)); $ticket_id = intval(base_convert(substr($packed, 4), 36, 10)); return ['goods_id' => $goods_id, 'ticket_id' => $ticket_id]; } function getPerGoodsKey(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); } return $cache[$goods_id]; } ``` **验证流程(完全自动路由)**: ``` 核销员扫 short_code: "Ax7fK9p3" ↓ decode("Ax7fK9p3", goods_id已知) → {goods_id: 118, ticket_id: 482815} ↓ DB: WHERE goods_id=118 AND id=482815 → 命中 ✅ → 核销 ``` **无需知道 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`。 ### 5.4 issueTicket 写入内容 ```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_code = BaseService::generateUuid(); // 3. 生成 QR payload(JWT签名格式) $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 发完回填) $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), 'user_id' => $order['user_id'], 'ticket_code' => $ticket_code, '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'] ?? '', 'verify_status' => 0, 'issued_at' => self::now(), 'created_at' => self::now(), 'updated_at' => self::now(), ]); // 6. 回填 ticket_id 到 QR payload $qr_payload['id'] = $ticket_id; $qr_data_updated = BaseService::signQrPayload($qr_payload); Db::name('tickets')->where('id', $ticket_id)->update([ 'qr_data' => $qr_data_updated ]); return $ticket_id; } ``` --- ## 六、数据库变更 ### 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'])` 可正常工作。 --- ### 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,无需引入额外库。 --- ## 十一、当前实现状态 ### 已就绪(可直接用) - ✅ 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时触发) ### 待新建 - ❌ `api/Ticket.php`(C端票API) - ❌ `index/Ticket.php`(C端票夹页面) - ❌ `admin/Ticket.php`(B端核销API) - ❌ `service/WalletService.php` - ❌ `service/BaseService.php` 新增 Feistel8 + QR签名 ### 待修改 - ⚠️ `service/TicketService.php` — 适配5维spec解析 + QR签名格式 --- ## 十二、安全模型总结 | 场景 | 攻击难度 | 防御机制 | |------|---------|---------| | 自助机 QR 暴力破解 | 无人值守,可无限重试 | JWT签名 + 30min时间窗口,伪造不可行 | | 核销员短码猜测 | 人工核验,无法无限重试 | Feistel混淆,猜错直接拒 | | QR 重放(截图复用) | 同一QR反复扫 | DB verify_status 检查 | | 伪造 QR | 不知道 secret | HMAC签名,计算不可逆 |