17 KiB
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分钟
本地缓存(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万
编码实现:
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()
页面交互:
- 扫码枪捕获输入 → 自动提交
- 手动输入短码 → 回车提交
- 调用
TicketService::verifyTicket() - 展示结果 + 声音提示
- 清空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 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:
{
"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();
// 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
-- =====================================================
-- 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。
$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签名,计算不可逆 |