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

545 lines
15 KiB
Markdown
Raw Permalink 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