vr-shopxo-plugin/docs/PHAN_4_PLAN.md

534 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码动态加密** | 自助机/无人值守 | 微信/支付宝扫一扫 | 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/362.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 ← 扫码枪 / 手动输入备用 │
└──────────────────────────────────────────────────┘
```
---
## 三、功能 AC端票夹
### 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 | 必须 |
---
## 四、功能 BB端核销
### 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_ticketsreal_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端核销APIverifySubmit + stats
├── api/controller/
│ └── Ticket.php # [新建] C端票APItickets + 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 细节。