# 核销系统设计

> 调研时间：2026-04-14
> 关键参考：`realstore/check/check.vue`（自提点核销页）、`sxo_order_extraction_code` 表

---

## 一、系统概述

核销系统解决：用户持电子票 QR 码 → 工作人员验证 → 标记已入场。

### 1.1 核销模式

| 模式 | 说明 | 适用场景 |
|---|---|---|
| **扫码核销** | B 端扫用户 QR 码 | 演唱会入口、活动签到 |
| **手动输入** | B 端输入票码 | 网络不稳定场景 |
| **双重核销** | 用户扫码 + B 端扫码 | 高安全要求 |

### 1.2 核销粒度

| 粒度 | 说明 | 实现难度 |
|---|---|---|
| 按订单 | 一个订单一个码 | ⭐ 最简单 |
| 按座位 | 每个座位一个码 | ⭐⭐ 推荐 |

**推荐**：按座位核销（每人一个 QR 码），与演唱会场景完全匹配。

---

## 二、QR 票生成

### 2.1 触发时机

**支付成功回调时**（`plugins_service_buy_order_insert_success` 钩子）

### 2.2 QR 码内容设计

```json
{
  "id": 12345,           // vr_attendees.id
  "code": "UUID-v4",     // ticket_code，唯一标识
  "event_id": 8,
  "session_id": 15,
  "seat": "A区-3排-15座",
  "exp": 1735689600      // 过期时间（时间戳）
}
```

### 2.3 加密方式

不加密（明文 QR）：
- 优点：调试方便，可人工识别
- 缺点：可伪造
- 适用：低安全要求、内部活动

**加密 QR**（推荐）：
```php
$qr_data = json_encode([...]);
$encrypted = base64_encode(
    openssl_encrypt($qr_data, 'AES-256-CBC', $secret_key, OPENSSL_RAW_DATA, $iv)
);
```

核销时解密验证：
```php
$decrypted = openssl_decrypt(
    base64_decode($encrypted), 'AES-256-CBC',
    $secret_key, OPENSSL_RAW_DATA, $iv
);
```

### 2.4 QR 码生成

使用 ShopXO 内置 `\base\Qrcode` 类：

```php
// 生成展示用 QR 码 URL
$ticket_code = $attendee->ticket_code;
$qr_url = MyUrl('index/qrcode/index', [
    'content' => urlencode(base64_encode($ticket_code)),
    'size'   => 8,
    'level'  => 'H',  // 高容错率，扫码成功率高
    'mr'     => 2,
]);

// 生成文件（用于发送邮件/消息）
$qr_path = (new \base\Qrcode())->Create([
    'content'  => $ticket_code,
    'path'     => 'static/upload/tickets/' . date('Y/md') . '/',
    'filename' => $ticket_code . '.png',
    'level'    => 'H',
    'size'     => 10,
    'mr'       => 2,
]);
```

---

## 三、数据存储

### 3.1 扩展方案 vs 独立表

**方案 A：用 `sxo_order.extension_data`**

```json
{
  "item_type": "ticket",
  "event_id": 8,
  "session_id": 15,
  "tickets": [
    {
      "attendee_id": 101,
      "ticket_code": "uuid-xxx",
      "seat": "A区-3排-15座",
      "verify_status": 0,
      "verify_time": null
    },
    {
      "attendee_id": 102,
      "ticket_code": "uuid-yyy",
      "seat": "A区-3排-16座",
      "verify_status": 0,
      "verify_time": null
    }
  ]
}
```

优点：无需新建表，查询方便
缺点：JSON 更新需要整体替换

**方案 B：新建 `vr_tickets` 表**（推荐）

```sql
CREATE TABLE `vr_tickets` (
  `id` int UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  `order_id` int UNSIGNED NOT NULL COMMENT '订单ID',
  `order_no` char(60) NOT NULL COMMENT '订单号',
  `goods_id` int UNSIGNED NOT NULL COMMENT '商品ID',
  `user_id` int UNSIGNED NOT NULL COMMENT '用户ID',
  `event_id` int UNSIGNED NOT NULL COMMENT '活动ID',
  `session_id` int UNSIGNED NOT NULL COMMENT '场次ID',
  `ticket_code` char(36) NOT NULL COMMENT '票码(UUID)',
  `qr_data` text COMMENT '加密QR内容',
  `seat_info` varchar(255) COMMENT '座位信息',
  `real_name` varchar(60) COMMENT '观演人姓名',
  `id_card` char(20) COMMENT '身份证号',
  `phone` char(15) COMMENT '手机号',
  `verify_status` tinyint DEFAULT 0 COMMENT '核销状态（0未核销, 1已核销）',
  `verify_time` int UNSIGNED DEFAULT 0 COMMENT '核销时间',
  `verifier_id` int UNSIGNED DEFAULT 0 COMMENT '核销员ID',
  `issued_at` int UNSIGNED DEFAULT 0 COMMENT '发放时间',
  `created_at` int UNSIGNED DEFAULT 0,
  `updated_at` int UNSIGNED DEFAULT 0,
  UNIQUE KEY `ticket_code` (`ticket_code`),
  KEY `order_id` (`order_id`),
  KEY `user_id` (`user_id`),
  KEY `event_id` (`event_id`),
  KEY `session_id` (`session_id`),
  KEY `verify_status` (`verify_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='VR电子票表';
```

### 3.2 核销记录表

```sql
CREATE TABLE `vr_verifications` (
  `id` int UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  `ticket_id` int UNSIGNED NOT NULL COMMENT '票ID',
  `ticket_code` char(36) NOT NULL COMMENT '票码',
  `verifier_id` int UNSIGNED NOT NULL COMMENT '核销员ID',
  `verifier_name` varchar(60) COMMENT '核销员名称',
  `event_id` int UNSIGNED COMMENT '活动ID',
  `session_id` int UNSIGNED COMMENT '场次ID',
  `ip_address` varchar(45) COMMENT '核销IP',
  `location` varchar(255) COMMENT '核销地点备注',
  `created_at` int UNSIGNED DEFAULT 0,
  KEY `ticket_id` (`ticket_id`),
  KEY `verifier_id` (`verifier_id`),
  KEY `created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='核销记录表';
```

### 3.3 核销员表

```sql
CREATE TABLE `vr_verifiers` (
  `id` int UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  `user_id` int UNSIGNED NOT NULL COMMENT '关联用户ID（ShopXO user.id）',
  `name` varchar(60) NOT NULL COMMENT '核销员名称',
  `mobile` char(15) COMMENT '手机号',
  `event_ids` varchar(255) COMMENT '可核销的活动ID列表（逗号分隔）',
  `status` tinyint DEFAULT 1 COMMENT '状态（0禁用, 1启用）',
  `created_at` int UNSIGNED DEFAULT 0,
  KEY `user_id` (`user_id`),
  KEY `status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='核销员表';
```

---

## 四、B 端核销页面

### 4.1 参考实现

**直接 fork** `pages/plugins/realstore/check/check.vue`

### 4.2 页面 UI 设计

```vue
<template>
  <view class="page padding-main">
    <!-- 标题栏 -->
    <view class="verify-header bg-white padding radius">
      <view class="fw-b text-size-lg">VR演唱会票务核销</view>
      <view class="cr-grey text-size-sm margin-top-xs">
        当前活动：{{ current_event?.name || '全部活动' }}
      </view>
    </view>

    <!-- 扫码/输入区 -->
    <view class="verify-input bg-white padding radius margin-top">
      <view class="flex-row align-c">
        <!-- #ifndef H5 -->
        <view class="scan-btn" @tap="scan_event">
          <uni-icons type="scan" size="56rpx" color="#2196F3"></uni-icons>
        </view>
        <!-- #endif -->
        <input
          type="text"
          class="flex-1 margin-left"
          placeholder="扫描二维码或输入核销码"
          v-model="check_value"
          @confirm="verify_submit"
        />
      </view>
    </view>

    <!-- 提交按钮 -->
    <view class="padding margin-top">
      <button
        type="primary"
        :loading="verify_loading"
        :disabled="!check_value || verify_loading"
        @tap="verify_submit"
      >{{ verify_loading ? '核销中...' : '确认核销' }}</button>
    </view>

    <!-- 结果展示 -->
    <view class="result-area padding margin-top">
      <!-- 成功 -->
      <view v-if="result_type === 'success'" class="success-box">
        <uni-icons type="checkmark-filled" size="60" color="#4CAF50"></uni-icons>
        <view class="fw-b text-size-xl margin-top">核销成功</view>
        <view class="margin-top">
          <view>活动：{{ result.event_name }}</view>
          <view>场次：{{ result.session_time }}</view>
          <view>座位：{{ result.seat_info }}</view>
          <view>观演人：{{ result.real_name }}</view>
        </view>
      </view>

      <!-- 失败 -->
      <view v-else-if="result_type === 'error'" class="error-box">
        <uni-icons type="close-filled" size="60" color="#F44336"></uni-icons>
        <view class="fw-b text-size-xl margin-top">核销失败</view>
        <view class="cr-red margin-top">{{ error_msg }}</view>
      </view>
    </view>

    <!-- 统计栏 -->
    <view class="stats-bar fixed-bottom padding">
      <view class="flex-row jc-sb">
        <view class="stat-item">
          <view class="cr-grey text-size-xs">今日核销</view>
          <view class="fw-b text-size-xl cr-green">{{ stats.today }}</view>
        </view>
        <view class="stat-item">
          <view class="cr-grey text-size-xs">待核销</view>
          <view class="fw-b text-size-xl cr-yellow">{{ stats.pending }}</view>
        </view>
        <view class="stat-item">
          <view class="cr-grey text-size-xs">已核销</view>
          <view class="fw-b text-size-xl">{{ stats.verified }}</view>
        </view>
      </view>
    </view>
  </view>
</template>
```

### 4.3 API 调用

```javascript
verify_submit() {
    if (!this.check_value) return;

    uni.showLoading({ title: '核销中...' });
    this.verify_loading = true;

    uni.request({
        url: app.globalData.get_request_url('verify', 'ticket', 'vrticket'),
        method: 'POST',
        data: {
            ticket_code: this.check_value,
            event_id: this.current_event?.id || 0,
        },
        success: (res) => {
            uni.hideLoading();
            if (res.data.code == 0) {
                this.result_type = 'success';
                this.result = res.data.data;
                this.stats.today++;
                this.stats.pending--;
            } else {
                this.result_type = 'error';
                this.error_msg = res.data.msg;
            }
            // 清空输入框，支持连续扫描
            this.check_value = '';
        },
        fail: () => {
            uni.hideLoading();
            this.result_type = 'error';
            this.error_msg = '网络错误，请重试';
        },
        complete: () => {
            this.verify_loading = false;
        }
    });
}
```

---

## 五、后端核销 API

### 5.1 接口定义

```
POST /?s=admin/vrticket/verify
Content-Type: application/json

{
    "ticket_code": "uuid-xxx",  // 票码
    "event_id": 8              // 活动ID（可选）
}
```

### 5.2 返回格式

**成功**：
```json
{
    "code": 0,
    "msg": "核销成功",
    "data": {
        "ticket_id": 101,
        "ticket_code": "uuid-xxx",
        "event_name": "周杰伦2026巡回演唱会",
        "session_time": "2026-06-01 20:00",
        "seat_info": "A区-3排-15座",
        "real_name": "张三",
        "verify_time": 1745328000
    }
}
```

**失败**：
```json
{
    "code": -1,
    "msg": "该票已核销"
}
```

### 5.3 核销逻辑实现

```php
// app/plugins/vr_ticket/service/TicketService.php

public static function VerifyTicket($ticket_code, $verifier_id, $event_id = 0)
{
    // 1. 查询票
    $ticket = Db::name('vr_tickets')
        ->where('ticket_code', $ticket_code)
        ->find();
    if (!$ticket) {
        return DataReturn('票码不存在', -1);
    }

    // 2. 检查活动匹配
    if ($event_id > 0 && $ticket['event_id'] != $event_id) {
        return DataReturn('该票不属于当前活动', -1);
    }

    // 3. 检查是否已核销
    if ($ticket['verify_status'] == 1) {
        return DataReturn('该票已核销（' . date('Y-m-d H:i', $ticket['verify_time']) . '）', -1);
    }

    // 4. 解密验证（如果加密了）
    if (!empty($ticket['qr_data'])) {
        $decrypted = openssl_decrypt(
            base64_decode($ticket['qr_data']),
            'AES-256-CBC',
            MyC('vrticket_secret_key'),
            OPENSSL_RAW_DATA,
            substr(md5($ticket['ticket_code']), 0, 16)
        );
        $qr_content = json_decode($decrypted, true);

        // 检查过期
        if (!empty($qr_content['exp']) && $qr_content['exp'] < time()) {
            return DataReturn('票已过期', -1);
        }
    }

    // 5. 执行核销（事务）
    Db::startTrans();
    try {
        // 更新票状态
        Db::name('vr_tickets')->where('id', $ticket['id'])->update([
            'verify_status' => 1,
            'verify_time'  => time(),
            'verifier_id'  => $verifier_id,
            'updated_at'   => time(),
        ]);

        // 写入核销记录
        Db::name('vr_verifications')->insert([
            'ticket_id'     => $ticket['id'],
            'ticket_code'   => $ticket_code,
            'verifier_id'  => $verifier_id,
            'verifier_name' => self::GetVerifierName($verifier_id),
            'event_id'     => $ticket['event_id'],
            'session_id'   => $ticket['session_id'],
            'created_at'   => time(),
        ]);

        Db::commit();
    } catch (\Exception $e) {
        Db::rollback();
        return DataReturn('核销失败：' . $e->getMessage(), -1);
    }

    // 6. 返回票信息
    $ticket['verify_status'] = 1;
    $ticket['verify_time'] = time();
    return DataReturn('核销成功', 0, self::FormatTicketInfo($ticket));
}
```

---

## 六、C 端票夹（用户查看已购票）

### 6.1 页面入口

通过用户中心钩子注入：`plugins_view_user_various_inside_top`

### 6.2 页面内容

显示用户所有已支付订单中的票务商品，每张票一行：

```
┌─────────────────────────────────────┐
│ 🎵 周杰伦2026巡回演唱会              │
│ 📅 2026-06-01 20:00                 │
│ 📍 国家体育馆                        │
│ 💺 A区-3排-15座                     │
│                                     │
│  ┌─────────┐   状态:               │
│  │ QR CODE │   ✅ 已核销 / ⏳ 待使用 │
│  └─────────┘                       │
└─────────────────────────────────────┘
```

### 6.3 核销状态实时更新（可选）

如果需要实时更新核销状态：
- 方案 A（推荐）：用户进入票夹时刷新状态
- 方案 B：WebSocket 推送（ShopXO 无内置 WebSocket）
- 方案 C：页面可见时通过 `onShow` 刷新

---

## 七、部署形态

### 7.1 B 端核销页面部署

| 形态 | 说明 | 推荐度 |
|---|---|---|
| **uni-app B 端插件页** | `pages/plugins/vr-ticket-verify/` | ✅ 最佳 |
| **ShopXO H5 管理后台** | `/admin/ticket-verify/` | ⚠️ 个人小程序无法内嵌 |
| **独立微信小程序** | 单独注册 B 端小程序 | ⚠️ 需额外资质 |

### 7.2 个人主体小程序限制

- ❌ 禁止 web-view（无法内嵌 H5）
- ✅ 可以新建另一个小程序（独立 AppID）
- ✅ 可以用 `uni.scanCode` 做纯小程序核销页

### 7.3 推荐方案

**演唱会现场**：用 ShopXO 的 uni-app 新建 `vr-ticket-verify` 插件页面。工作人员用自己的微信账号（授权为核销员）登录 ShopXO 小程序，访问核销页面。

**部署**：
1. 把 `vr-ticket-verify` 作为 ShopXO uni-app 的插件发布
2. 工作人员在小程序中访问该页面
3. 手机对着票 QR 码扫描

---

## 八、核销统计

### 8.1 实时统计 API

```
GET /?s=admin/vrticket/stats&event_id=8
```

返回：
```json
{
    "code": 0,
    "data": {
        "total_tickets": 500,
        "verified": 320,
        "pending": 180,
        "today": 45
    }
}
```

### 8.2 核销大屏

可在演唱会入口放置一个大屏电视，显示实时核销进度：
- 总票数 / 已核销数 / 待核销数
- 每分钟核销速度
- 最近核销记录滚动列表

实现方式：轮询 stats API + 数字动画（CountUp.js）

---

## 九、防超卖机制

> 本章节为架构性补充，是编码前的 **阻断性要求**。
> 核销的完整性依赖于：只有合法持票人能核销，且每张票只能核销一次。

### 9.1 购票时序与座位锁定

座位锁定贯穿整个购票流程，分三阶段：

```
[阶段1: 选座]          [阶段2: 提交订单]         [阶段3: 支付成功]
用户选座 → 前端展示     提交订单 → 后端锁座        支付回调 → 出票确认
        ↓                      ↓                        ↓
   前端乐观锁          Redis/Db悲观锁              释放锁/永久锁定
   (前端禁止选已选座)    (禁止其他人选同座)           (vr_tickets写入)
```

**阶段1 — 前端乐观锁**：
- 用户选座时，前端实时请求 `GET /?s=api/session/seats?session_id=X` 获取已锁定座位列表
- 已被锁或已售座位在座位图上置灰（CSS `pointer-events: none`）
- 纯前端锁无法防并发攻击，**必须配合后端锁**

**阶段2 — 后端悲观锁（关键）**：
用户点击"提交订单"时，后端在事务内完成：
```php
Db::startTrans();
try {
    // ① 检查座位是否已被锁定（检查 vr_seat_locks 表）
    $locked = Db::name('vr_seat_locks')
        ->where('session_id', $session_id)
        ->where('seat_code', $seat_code)
        ->where('expire_at', '>', time())
        ->find();
    if ($locked) {
        Db::rollback();
        return DataReturn('座位已被锁定，请重新选择', -1);
    }

    // ② 原子扣减库存（vr_sessions.stock - 1）
    $affected = Db::name('vr_sessions')
        ->where('id', $session_id)
        ->where('stock', '>', 0)   // 原子条件：库存 > 0
        ->dec('stock')
        ->update();
    if (!$affected) {
        Db::rollback();
        return DataReturn('库存不足', -1);
    }

    // ③ 写入座位锁（15分钟过期，与订单超时一致）
    Db::name('vr_seat_locks')->insert([
        'session_id' => $session_id,
        'seat_code'  => $seat_code,
        'order_no'   => $order_no,
        'user_id'    => $user_id,
        'locked_at'  => time(),
        'expire_at'  => time() + 900,   // 15分钟
    ]);

    Db::commit();
} catch (\Exception $e) {
    Db::rollback();
    return DataReturn('系统错误：' . $e->getMessage(), -1);
}
```

**阶段3 — 支付成功出票**：
- 支付回调触发后，`TicketService::OnOrderPaid()` 将 `vr_seat_locks` 中的锁转为永久票
- 锁记录 `status` 标记为 `confirmed`，`expire_at` 设为 NULL
- 如果用户15分钟内未支付，锁自动过期（定时任务或懒检查）

### 9.2 座位锁表设计

```sql
CREATE TABLE `vr_seat_locks` (
  `id` int UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  `session_id` int UNSIGNED NOT NULL COMMENT '场次ID',
  `seat_code` char(20) NOT NULL COMMENT '座位编码（如 A-3-15）',
  `order_no` char(60) NOT NULL COMMENT '关联订单号',
  `user_id` int UNSIGNED NOT NULL COMMENT '锁定用户ID',
  `status` tinyint DEFAULT 0 COMMENT '状态（0锁定中, 1已确认, 2已取消）',
  `locked_at` int UNSIGNED NOT NULL COMMENT '锁定时间',
  `expire_at` int UNSIGNED COMMENT '过期时间（NULL=永久）',
  UNIQUE KEY `session_seat` (`session_id`, `seat_code`),
  KEY `order_no` (`order_no`),
  KEY `expire_at` (`expire_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='座位锁表';
```

**关键设计点**：
- `UNIQUE KEY (session_id, seat_code)`：数据库层强制唯一，避免并发插入两条锁
- `expire_at` 字段：NULL = 永久锁定（已支付），有值 = 临时锁（未支付）
- 定时任务每分钟清理过期锁并回补库存：
  ```sql
  -- 清理过期锁并回补库存
  UPDATE vr_sessions s
  JOIN vr_seat_locks l ON s.id = l.session_id
  SET s.stock = s.stock + 1, l.status = 2  -- 2=已取消
  WHERE l.status = 0 AND l.expire_at < UNIX_TIMESTAMP();
  ```

### 9.3 并发控制策略

**方案 A：数据库乐观锁（推荐用于低并发场景）**
```php
// vr_sessions 表加 version 字段，每次更新自增
Db::name('vr_sessions')
    ->where('id', $session_id)
    ->where('stock', '>=', 1)
    ->where('version', $current_version)  // 乐观锁
    ->dec('stock')
    ->inc('version')
    ->update();
```
- 优点：无需额外锁资源
- 缺点：并发高时大量重试，响应延迟

**方案 B：Redis 分布式锁（推荐用于高并发演唱会抢票）**
```php
$lock_key = "seat_lock:{$session_id}:{$seat_code}";
$lock = Cache::store('redis')->set($lock_key, $order_no, ['nx', 'px' => 15000]);
if (!$lock) {
    return DataReturn('座位已被锁定', -1);
}
try {
    // 执行业务逻辑...
} finally {
    Cache::store('redis')->rm($lock_key);
}
```
- 优点：高性能，支持过期自动释放
- 缺点：需要 Redis；锁粒度细（每个座位一把锁）

**ShopXO 环境建议**：ShopXO 默认不带 Redis（如需可装），**低峰期用 MySQL 悲观锁（方案A变体）足够**；如预判高并发（万人演唱会开票），建议额外引入 Redis。

### 9.4 核销时的超卖防御

核销阶段不存在超卖（每张票只能核销一次），但需防御：

1. **幂等核销**：`vr_tickets.verify_status = 1` 是唯一性约束，重复核销返回"已核销"而非报错
2. **事务隔离**：`VerifyTicket()` 在事务内完成，status 更新和记录写入原子执行
3. **QR 时效**：`exp` 字段确保过期票不会被核销（即使数据库状态异常）
4. **核销员权限**：核销前验证 `verifier_id` 是否在 `vr_verifiers` 表中且 `status = 1`

### 9.5 API 路径统一说明

| 端 | 路由 | 权限验证 | 说明 |
|---|---|---|---|
| **C 端**（票夹/扫码页） | `/?s=api/ticket/verify` | 用户登录态 | 用户查自己票状态 |
| **B 端**（核销人员） | `/?s=admin/vrticket/verify` | Admin 登录态 + 核销员白名单 | 核销人员扫码验证 |

Vue 页面使用 `app.globalData.get_request_url('verify', 'ticket', 'vrticket')` 生成的是 **C 端 API**，需在 B 端核销页改为 `admin` 命名空间路径，或新建独立 B 端核销 API。

### 9.6 AES IV 设计说明

`AES-256-CBC` 使用 `IV = substr(MD5(ticket_code), 0, 16)` 而非随机 IV 的原因：

**设计意图**：
- ticket_code 是 UUID-v4，每次生成票码时独立随机产生
- MD5(ticket_code) 是 ticket_code 的确定性函数：同一 ticket_code 永远映射到同一 IV
- 解密方只需知道 ticket_code（从 URL 参数传入或扫码获取）即可还原 IV，无需额外传输 IV 值

**安全性评估**：
- ticket_code 的熵足够（122位随机数），MD5 的输出是确定的但不可预测（单向函数）
- 攻击者不知道 ticket_code 无法派生正确 IV，暴力破解 MD5 不现实
- 这是 ticket-bound IV 模式，在票码系统场景下是合理设计

**如需更高安全**：使用随机 IV 并附加在密文头部（`ciphertext = IV || ciphertext`），ShopXO 无需修改即可兼容。
