vr-shopxo-plugin/docs/03_VERIFICATION_SYSTEM.md

716 lines
22 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.

# 核销系统设计
> 调研时间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 '关联用户IDShopXO 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推荐用户进入票夹时刷新状态
- 方案 BWebSocket 推送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();
```
- 优点:无需额外锁资源
- 缺点:并发高时大量重试,响应延迟
**方案 BRedis 分布式锁(推荐用于高并发演唱会抢票)**
```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 无需修改即可兼容。