vr-shopxo-plugin/docs/PHASE_4_PLAN.md

532 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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 ← 显示明文,扫码枪无效时候/ 手动输入备用 │
└──────────────────────────────────────────────────────────┘
```
---
## 三、功能 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内部**
```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 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`
```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 payloadJWT签名格式
$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'])` 可正常工作。
---
### 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无需引入额外库。
---
## 十一、当前实现状态
### 已就绪(可直接用)
- ✅ 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签名计算不可逆 |