22 KiB
核销系统设计
调研时间: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 码内容设计
{
"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(推荐):
$qr_data = json_encode([...]);
$encrypted = base64_encode(
openssl_encrypt($qr_data, 'AES-256-CBC', $secret_key, OPENSSL_RAW_DATA, $iv)
);
核销时解密验证:
$decrypted = openssl_decrypt(
base64_decode($encrypted), 'AES-256-CBC',
$secret_key, OPENSSL_RAW_DATA, $iv
);
2.4 QR 码生成
使用 ShopXO 内置 \base\Qrcode 类:
// 生成展示用 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
{
"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 表(推荐)
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 核销记录表
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 核销员表
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 设计
<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 调用
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 返回格式
成功:
{
"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
}
}
失败:
{
"code": -1,
"msg": "该票已核销"
}
5.3 核销逻辑实现
// 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 小程序,访问核销页面。
部署:
- 把
vr-ticket-verify作为 ShopXO uni-app 的插件发布 - 工作人员在小程序中访问该页面
- 手机对着票 QR 码扫描
八、核销统计
8.1 实时统计 API
GET /?s=admin/vrticket/stats&event_id=8
返回:
{
"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 — 后端悲观锁(关键): 用户点击"提交订单"时,后端在事务内完成:
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 座位锁表设计
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 = 永久锁定(已支付),有值 = 临时锁(未支付)- 定时任务每分钟清理过期锁并回补库存:
-- 清理过期锁并回补库存 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:数据库乐观锁(推荐用于低并发场景)
// 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 分布式锁(推荐用于高并发演唱会抢票)
$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 核销时的超卖防御
核销阶段不存在超卖(每张票只能核销一次),但需防御:
- 幂等核销:
vr_tickets.verify_status = 1是唯一性约束,重复核销返回"已核销"而非报错 - 事务隔离:
VerifyTicket()在事务内完成,status 更新和记录写入原子执行 - QR 时效:
exp字段确保过期票不会被核销(即使数据库状态异常) - 核销员权限:核销前验证
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 无需修改即可兼容。