# Phase 4 规划:发票 · 核销 · 票夹 > 规划日期:2026-04-22 > 状态:规划中,待确认后启动调研 --- ## 一、目标功能 | 功能 | 描述 | 优先级 | |------|------|--------| | **A. C端票夹** | 用户查看已购票 + 展示QR + 短码 + 核销状态 | P0 | | **B. B端核销页** | 工作人员扫码/输入短码 → 票验证 → 核销 | P0 | | **C. 出票链路闭环** | 支付成功 → 生成 vr_tickets → 用户可见票 | P0 | --- ## 二、码体系设计 ### 2.1 码的分类与安全模型 | 码类型 | 用途 | 场景 | 安全机制 | |--------|------|------|---------| | **QR码(动态加密)** | 自助机/无人值守 | 微信/支付宝扫一扫 | AES-256-CBC + 时间戳,30min窗口防暴力破解 | | **短码(静态混淆)** | 人工核销/扫码枪/手动输入 | 核销员在场 | 混淆防规律猜测,人工对抗成本天然存在 | ### 2.2 QR码(动态加密) **Payload 结构**: ```json { "id": 12345, "code": "uuid-xxx", "event": 8, "seat": "A区-3排-15座", "iat": 1745286000, "exp": 1745287800 } ``` - `iat`:签发时间(Unix时间戳) - `exp`:过期时间 = iat + 1800秒(30分钟) **加密**: ```php $qr_data = BaseService::encryptQrData($payload); // 现有 AES-256-CBC 加密逻辑不变,只需把 iat/exp 加入 payload ``` **解密验证(核销API)**: ```php $data = BaseService::decryptQrData($qr_data); if ($data['exp'] < time()) { return ['code' => -10, 'msg' => 'QR码已过期,请刷新重试']; } ``` **客户端缓存策略(节省服务端调用)**: ``` QR有效期:30分钟 本地缓存内容: { encrypted_payload: "BASE64_AES密文", generated_at: 1745286000, expires_at: 1745287800 } 展示决策: 无缓存 → 立即请求服务器 expires_at > now → 展示缓存 ✅ expires_at <= now → 请求服务器(刷新) 剩余时间 < 15min → 静默预刷新,交换新码 ``` **自助机场景**: 用户进入无网络区域,缓存QR仍有效 → 正常展示 → 自助机扫码 → 解密验时间戳 → 通行。服务端零调用。 ### 2.3 短码(静态混淆,10字符) **编码公式**: ``` short_code = Base58Encode(user_id × 1_000_000 + ticket_id) + XOR校验字符 ``` **参数**: | 字段 | 位数 | 范围 | Base58表示 | |------|------|------|-----------| | user_id | 24bit | 1 ~ 16,777,216(千万级) | — | | ticket_id | 20bit | 1 ~ 999,999 | — | | combined | 44bit | ~10^13 | 9 chars | | XOR校验 | 6bit | 0~35 | 1 char | | **总计** | | | **10字符** | **示例**: ``` user_id = 118, ticket_id = 12345 combined = 118 × 1_000_000 + 12345 = 118,012,345 Base58Encode = "axxf7mbg" XOR校验 = (ASCII叠加) % 36 → Base36 → "K" 最终码: "axxf7mbgK" ``` **防单字符输入错误**: - 任何1位翻 → XOR校验必然变化 → 拦截 - 碰撞概率 ≈ 1/36(2.8%),但猜错会被核销员要求出示证件,人工对抗成本存在 **生成(写入票时预计算)**: ```php // BaseService.php public static function generateShortCode(int $user_id, int $ticket_id): string { $combined = $user_id * 1_000_000 + $ticket_id; $code58 = Base58::encode($combined); // 9 chars $xor_val = 0; for ($i = 0; $i < 9; $i++) { $xor_val ^= ord($code58[$i]); } $check = Base36::encode($xor_val % 36); return $code58 . $check; // "axxf7mbgK" } public static function verifyShortCode(string $short_code): ?array { if (strlen($short_code) !== 10) return null; $code58 = substr($short_code, 0, 9); $check = substr($short_code, 9, 1); $xor_val = 0; for ($i = 0; $i < 9; $i++) { $xor_val ^= ord($code58[$i]); } if (Base36::encode($xor_val % 36) !== $check) { return null; // 格式错误 } $combined = Base58::decode($code58); $user_id = intdiv($combined, 1_000_000); $ticket_id = $combined % 1_000_000; return ['user_id' => $user_id, 'ticket_id' => $ticket_id]; } ``` **解码(核销时)**: ```php $decoded = BaseService::verifyShortCode($input_code); if ($decoded === null) { return ['code' => -4, 'msg' => '短码格式错误']; } // 查询: WHERE user_id=? AND ticket_id=? ``` ### 2.4 票面三码并行展示 ``` ┌──────────────────────────────────────────────────┐ │ 🎵 周杰伦2026巡回演唱会 │ │ 📅 2026-06-01 20:00 📍 国家体育馆 │ │ 💺 A区-3排-15座 👤 张三 138****1234 │ │ │ │ [============== QR CODE ==============] │ │ (动态加密, 30min有效) │ │ │ │ 短码: axxf7mbgK ← 扫码枪 / 手动输入备用 │ └──────────────────────────────────────────────────┘ ``` --- ## 三、功能 A:C端票夹 ### 3.1 挂载点选择 ShopXO 用户中心扩展使用 Hook,不改核心文件: ``` 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 + 短码 + 状态) ``` 票夹页内容(一次展示一张,可滑动切换。未使用的默认放前面,具体方案需讨论): ``` ┌─ 订单 202604220001 (1/2)─────────────────────────────┐ │ 🎵 周杰伦2026巡回演唱会 │ │ 💺 A区-3排-15座 ⏳ 待使用 │ │ [====== QR CODE ======] 短码: axxf7mbgK │ └─────────────────────────────────────────────────┘ ┌─ 订单 202604220001 (2/2)─────────────────────────────┐ │ 🎵 周杰伦2026巡回演唱会 │ │ 💺 A区-3排-16座 ✅ 已核销 │ │ [====== QR CODE ======] 短码: bxkp8n2mL │ └─────────────────────────────────────────────────┘ ``` ### 3.3 QR 缓存前端逻辑 ```javascript const QR_CACHE_KEY = 'vrt_qr_cache_'; // + ticket_id const QR_WINDOW_MS = 30 * 60 * 1000; // 30分钟 const PRE_REFRESH_MS = 15 * 60 * 1000; // 剩余15分钟预刷新 function getQrData(ticketId, encryptedPayload) { const key = QR_CACHE_KEY + ticketId; const cached = localStorage.getItem(key); const now = Date.now(); if (cached) { const { encrypted_payload, generated_at, expires_at } = JSON.parse(cached); if (now < expires_at) { if (expires_at - now < PRE_REFRESH_MS) { // 即将过期,静默预刷新 fetchNewQr(ticketId); } return encrypted_payload; // 展示缓存 } } // 缓存不存在或已过期,请求新码 return fetchNewQr(ticketId); } function fetchNewQr(ticketId) { // 调用 GET /?s=api/vr_ticket/qr_data&id=X // 响应: { encrypted_payload, generated_at, expires_at } // 存储到 localStorage 后返回 } ``` ### 3.4 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 ← 扫码核销页(参考 docs/03_VERIFICATION_SYSTEM.md) 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": "axxf7mbgK", // 或短码(查询ticket_code) "verifier_id": 1 } ``` **双轨查询逻辑(verifyTicket内部)**: ```php // 优先查 ticket_code if (!empty($ticket_code)) { $ticket = Db::name('tickets')->where('ticket_code', $ticket_code)->find(); } // 回退查 short_code if (empty($ticket) && !empty($short_code)) { $decoded = BaseService::verifyShortCode($short_code); if ($decoded) { $ticket = Db::name('tickets') ->where('user_id', $decoded['user_id']) ->where('id', $decoded['ticket_id']) ->find(); } } if (empty($ticket)) { return ['code' => -1, 'msg' => '票码不存在']; } ``` ### 4.3 权限 后台管理员登录态(ShopXO Admin Auth),无需额外核销员表关联(Phase 2 的 vr_verifiers 表为B端管理后台的核销员管理,与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维结构 现有 `onOrderPaid` 解析4维spec(场次/场馆/分区/座位号),**需适配5维**(场次/场馆/演播室/分区/座位号): ```php // 5维 spec_value 格式: // "08:00|测试场馆|主要展厅|A区|A1" // parts[0]=场次, [1]=场馆, [2]=演播室, [3]=分区, [4]=座位号 $spec_str = implode('|', array_column($spec_list, 'value')); $parts = explode('|', $spec_str); // $parts[0] = 场次时间 // $parts[1] = 场馆名 // $parts[2] = 演播室 // $parts[3] = 分区 // $parts[4] = 座位号(最终票面信息) ``` ### 5.3 观演人信息传递 购票页 `ticket_detail.html` 提交表单时,attendee数据写入 `order.extension_data`: ```json // order.extension_data { "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(); $short_code = BaseService::generateShortCode($order['user_id'], 0); // ticket_id 写0,发完再更新 $qr_payload = [ 'id' => 0, // 写入后再更新 'code' => $ticket_code, 'event'=> $order['goods_id'], 'seat' => $spec_name, 'iat' => time(), 'exp' => time() + 1800, ]; $qr_data = BaseService::encryptQrData($qr_payload); // 3. 观演人 $extension_data = json_decode($order['extension_data'] ?? '{}', true); $attendee = $extension_data['attendee'] ?? []; // 4. 写入 $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, 'short_code' => $short_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(), ]); // 5. 回填 ticket_id 到 short_code(用真实ticket_id重新生成) $short_code_final = BaseService::generateShortCode($order['user_id'], $ticket_id); Db::name('tickets')->where('id', $ticket_id)->update(['short_code' => $short_code_final]); // 6. 更新 QR payload 中的 id $qr_payload['id'] = $ticket_id; $qr_data_updated = BaseService::encryptQrData($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: 票夹 + 核销 + 短码 -- ===================================================== -- 1. vr_tickets 表新增字段 ALTER TABLE vrt_tickets ADD COLUMN short_code CHAR(10) NOT NULL COMMENT '静态短码(9位base58+1位xor校验)' AFTER ticket_code, ADD COLUMN ticket_secret CHAR(64) NOT NULL DEFAULT '' COMMENT 'HMAC种子(预留)' AFTER qr_data, ADD COLUMN qr_expires_at INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'QR有效期截止时间戳' AFTER ticket_secret, ADD INDEX (short_code), ADD INDEX (user_id, verify_status); -- 2. 观演人信息字段已在 vr_tickets(real_name/phone/id_card) -- 3. vr_verifications / vr_verifiers / vr_seat_templates 已存在(Phase 0) ``` --- ## 七、目录结构(Phase 4 新增/修改) ``` shopxo/app/plugins/vr_ticket/ ├── admin/controller/ │ └── Ticket.php # [新建] B端核销API(verifySubmit + stats) ├── api/controller/ │ └── Ticket.php # [新建] C端票API(tickets + qrData) ├── service/ │ ├── TicketService.php # [修改] 适配5维spec + 写入short_code │ ├── WalletService.php # [新建] 票夹聚合查询(用户票列表) │ └── BaseService.php # [修改] 添加generateShortCode/verifyShortCode ├── 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: generateShortCode / verifyShortCode ├─ Base58 / Base36 工具类 └─ DB Migration: 002_ticket_wallet.sql Phase 4.2 — 出票链路(最关键) ├─ TicketService::onOrderPaid: 适配5维spec解析 ├─ TicketService::issueTicket: 写入 short_code + qr_payload含时间戳 └─ 联调:支付成功 → 查 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 — 全链路验证 └─ 完整流程: 选座→下单→支付→出票→票夹展示→核销 ``` --- ## 十、调研清单(确认后启动) | # | 问题 | 目的 | |---|------|------| | Q1 | ShopXO `plugins_service_order_pay_success_handle_end` 触发时机和参数格式? | 确认 onOrderPaid 能收到正确数据 | | Q2 | `order.extension_data` 在订单完成后是否仍可读? | 确认观演人信息传递链路 | | Q3 | ShopXO `PluginsService` 如何注册 API 路由(api/ 命名空间)? | 确认 C端/B端 API 挂载方式 | | Q4 | ShopXO 内置 Base58/Base36 实现是否有现成的? | 避免重复造轮子 | | Q5 | ShopXO 前端 JS 是否有 localStorage 或统一存储方案? | 确认 QR 缓存策略在前端的兼容性 | --- > 规划完毕。待确认后启动调研,补充 Q1-Q5 细节。