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