2026-04-14 04:01:37 +00:00
|
|
|
|
# 核销系统设计
|
|
|
|
|
|
|
|
|
|
|
|
> 调研时间: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)
|
2026-04-14 06:10:59 +00:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 九、防超卖机制
|
|
|
|
|
|
|
|
|
|
|
|
> 本章节为架构性补充,是编码前的 **阻断性要求**。
|
|
|
|
|
|
> 核销的完整性依赖于:只有合法持票人能核销,且每张票只能核销一次。
|
|
|
|
|
|
|
|
|
|
|
|
### 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 无需修改即可兼容。
|