# 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  ← 显示明文，扫码枪无效时候/ 手动输入备用     │
└──────────────────────────────────────────────────────────┘
```

---

## 三、功能 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()
```

页面交互：
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 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`：
```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 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`

```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'])` 可正常工作。

---

### 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签名，计算不可逆 |
