17 KiB
17 KiB
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 结构:
{
"id": 12345,
"code": "uuid-xxx",
"event": 8,
"seat": "A区-3排-15座",
"iat": 1745286000,
"exp": 1745287800
}
iat:签发时间(Unix时间戳)exp:过期时间 = iat + 1800秒(30分钟)
加密:
$qr_data = BaseService::encryptQrData($payload);
// 现有 AES-256-CBC 加密逻辑不变,只需把 iat/exp 加入 payload
解密验证(核销API):
$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%),但猜错会被核销员要求出示证件,人工对抗成本存在
生成(写入票时预计算):
// 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];
}
解码(核销时):
$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 缓存前端逻辑
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()
页面交互:
- 扫码枪捕获输入 → 自动提交
- 手动输入短码 → 回车提交
- 调用
TicketService::verifyTicket() - 展示结果 + 声音提示(成功"滴"一声,失败"滴滴"两声)
- 清空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内部):
// 优先查 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维(场次/场馆/演播室/分区/座位号):
// 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:
// order.extension_data
{
"attendee": {
"real_name": "张三",
"phone": "13800138000",
"id_card": "110101199001011234"
}
}
onOrderPaid 解析该字段写入 vr_tickets。
5.4 issueTicket 写入内容(最终版)
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
-- =====================================================
-- 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 细节。