# 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/36（2.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   ← 扫码枪 / 手动输入备用      │
└──────────────────────────────────────────────────┘
```

---

## 三、功能 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 缓存前端逻辑

```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 | 必须 |

---

## 四、功能 B：B端核销

### 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_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 细节。
