15 KiB
15 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)