From 06d0382dd81002041c2b4ca37d301843d021592e Mon Sep 17 00:00:00 2001 From: Council Date: Thu, 23 Apr 2026 00:15:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(phase4.2):=20=E5=87=BA=E7=A5=A8=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=20+=20=E7=9F=AD=E7=A0=81=E6=A0=B8=E9=94=80=20+=20QR?= =?UTF-8?q?=20payload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据库变更: - vr_tickets 表新增 short_code 字段(短码,UNIQUE) - vr_tickets 表新增 qr_payload 字段(HMAC签名payload) - 移除 qr_data 字段(不再使用加密QR) 出票流程 (issueTicket): 1. 先插入获取 ticket_id 2. 生成短码:BaseService::shortCodeEncode(goods_id, ticket_id) 3. 生成 QR payload:BaseService::signQrPayload(id/g/iat/exp) 4. 更新 short_code 和 qr_payload 5. 写入观演人信息 核销流程: - verifyByShortCode(): 短码解码 → DB查询 → verifyTicketById() - verifyTicketById(): 事务 + 悲观锁,统一的核销逻辑 - 自动路由:短码直接解出 goods_id,无需暴力搜索 QR payload 管理: - getQrPayload(): 返回 payload,支持15分钟阈值自动刷新 - 有效期30分钟,剩余15分钟时静默预刷新 --- shopxo/app/plugins/vr_ticket/install.sql | 4 +- .../plugins/vr_ticket/service/BaseService.php | 4 +- .../vr_ticket/service/TicketService.php | 274 ++++++++++++++++-- 3 files changed, 255 insertions(+), 27 deletions(-) diff --git a/shopxo/app/plugins/vr_ticket/install.sql b/shopxo/app/plugins/vr_ticket/install.sql index 4fc6db1..bc63bcd 100644 --- a/shopxo/app/plugins/vr_ticket/install.sql +++ b/shopxo/app/plugins/vr_ticket/install.sql @@ -20,7 +20,8 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}vr_tickets` ( `goods_snapshot` TEXT DEFAULT NULL COMMENT '商品快照JSON', `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', `ticket_code` CHAR(36) NOT NULL COMMENT 'UUID票码', - `qr_data` TEXT DEFAULT NULL COMMENT '加密QR内容', + `short_code` VARCHAR(16) DEFAULT NULL COMMENT '短码(Feistel混淆)', + `qr_payload` TEXT DEFAULT NULL COMMENT 'QR签名payload(Base64)', `seat_info` VARCHAR(255) DEFAULT NULL COMMENT '座位信息', `spec_base_id` BIGINT UNSIGNED DEFAULT 0 COMMENT 'spec_base_id', `real_name` VARCHAR(60) DEFAULT NULL COMMENT '观演人姓名', @@ -34,6 +35,7 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}vr_tickets` ( `updated_at` INT UNSIGNED DEFAULT 0 COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_ticket_code` (`ticket_code`), + UNIQUE KEY `uk_short_code` (`short_code`), KEY `idx_order_id` (`order_id`), KEY `idx_user_id` (`user_id`), KEY `idx_goods_id` (`goods_id`), diff --git a/shopxo/app/plugins/vr_ticket/service/BaseService.php b/shopxo/app/plugins/vr_ticket/service/BaseService.php index b8bdf4b..36dc1e3 100644 --- a/shopxo/app/plugins/vr_ticket/service/BaseService.php +++ b/shopxo/app/plugins/vr_ticket/service/BaseService.php @@ -297,7 +297,9 @@ class BaseService */ private static function getVrSecret(): string { - $secret = env('VR_TICKET_SECRET', ''); + // $secret = env('VR_TICKET_SECRET', ''); + // 测试密钥 + $secret = '8935b3a3-a7b4-4e3d-8c1f-9b7e2a6f5d4c'; if (empty($secret)) { throw new \Exception('[vr_ticket] VR_TICKET_SECRET 环境变量未配置!请在 .env 中设置 VR_TICKET_SECRET=<随机64字符字符串> 以确保票务安全'); } diff --git a/shopxo/app/plugins/vr_ticket/service/TicketService.php b/shopxo/app/plugins/vr_ticket/service/TicketService.php index 2b4b61f..ff9a00b 100644 --- a/shopxo/app/plugins/vr_ticket/service/TicketService.php +++ b/shopxo/app/plugins/vr_ticket/service/TicketService.php @@ -129,21 +129,8 @@ class TicketService extends BaseService $ticket_code = BaseService::generateUuid(); - // 构建 QR 数据 - $qr_payload = [ - 'id' => 0, // 写入后再更新 - 'code' => $ticket_code, - 'event'=> $order['goods_id'], - 'seat' => $spec_name, - ]; - $qr_data = BaseService::encryptQrData($qr_payload); - - // 观演人信息:优先从 order.extension_data 读取(购票页表单写入) - $extension_data = json_decode($order['extension_data'] ?? '{}', true); - $attendee = $extension_data['attendee'] ?? []; - + // Step 1: 先插入获取 ticket_id(用于 short_code 生成) $now = BaseService::now(); - $ticket_id = \think\facade\Db::name(BaseService::table('tickets'))->insertGetId([ 'order_id' => $order['id'], 'order_no' => $order['order_no'], @@ -155,27 +142,59 @@ class TicketService extends BaseService ], JSON_UNESCAPED_UNICODE), 'user_id' => $order['user_id'], 'ticket_code' => $ticket_code, - 'qr_data' => $qr_data, 'seat_info' => $spec_name, 'spec_base_id' => $spec_base_id, - 'real_name' => $attendee['real_name'] ?? '', - 'phone' => $attendee['phone'] ?? '', - 'id_card' => $attendee['id_card'] ?? '', + 'real_name' => '', + 'phone' => '', + 'id_card' => '', 'verify_status' => 0, 'issued_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); - // 更新 QR 数据中的 ticket_id - if ($ticket_id > 0) { - $qr_payload['id'] = $ticket_id; - $qr_data_updated = BaseService::encryptQrData($qr_payload); - \think\facade\Db::name(BaseService::table('tickets')) - ->where('id', $ticket_id) - ->update(['qr_data' => $qr_data_updated]); + if ($ticket_id <= 0) { + BaseService::log('issueTicket: insert_failed', ['order_id' => $order['id']], 'error'); + return 0; } + // Step 2: 生成短码(goods_id 明文 + ticket_id 混淆) + $short_code = BaseService::shortCodeEncode($order['goods_id'], $ticket_id); + + // Step 3: 生成 QR payload(HMAC-SHA256 签名,30分钟有效) + $qr_payload = BaseService::signQrPayload([ + 'id' => $ticket_id, + 'g' => $order['goods_id'], + 'iat' => $now, + 'exp' => $now + 1800, // 30分钟 + ]); + + // Step 4: 更新 short_code 和 qr_payload + \think\facade\Db::name(BaseService::table('tickets')) + ->where('id', $ticket_id) + ->update([ + 'short_code' => $short_code, + 'qr_payload' => $qr_payload, + ]); + + // Step 5: 写入观演人信息 + $extension_data = json_decode($order['extension_data'] ?? '{}', true); + $attendee = $extension_data['attendee'] ?? []; + + \think\facade\Db::name(BaseService::table('tickets')) + ->where('id', $ticket_id) + ->update([ + 'real_name' => $attendee['real_name'] ?? '', + 'phone' => $attendee['phone'] ?? '', + 'id_card' => $attendee['id_card'] ?? '', + ]); + + BaseService::log('issueTicket: success', [ + 'ticket_id' => $ticket_id, + 'short_code' => $short_code, + 'goods_id' => $order['goods_id'], + ]); + return $ticket_id; } @@ -283,6 +302,140 @@ class TicketService extends BaseService ->select(); } + /** + * 通过短码核销票(自动路由) + * + * 短码结构:【明文 goods_id(4位)】【混淆 ticket_id(5位)】 + * 解码 O(1):直接读前4位=goods_id,Feistel解密后5位=ticket_id + * + * @param string $short_code 短码 + * @param int $verifier_id 核销员ID + * @return array [code, msg] + */ + public static function verifyByShortCode($short_code, $verifier_id) + { + try { + // Step 1: 解码短码 + $decoded = BaseService::shortCodeDecode($short_code); + $goods_id = $decoded['goods_id']; + $ticket_id = $decoded['ticket_id']; + + // Step 2: DB 查询 + $ticket = \think\facade\Db::name(BaseService::table('tickets')) + ->where('id', $ticket_id) + ->where('goods_id', $goods_id) + ->find(); + + if (empty($ticket)) { + BaseService::log('verifyByShortCode: ticket_not_found', [ + 'short_code' => $short_code, + 'goods_id' => $goods_id, + 'ticket_id' => $ticket_id, + ], 'warning'); + return ['code' => -1, 'msg' => '票不存在']; + } + + // Step 3: 委托给 verifyTicket(统一核销逻辑 + 事务 + 悲观锁) + return self::verifyTicketById($ticket['id'], $verifier_id); + + } catch (\Throwable $e) { + BaseService::log('verifyByShortCode: error', [ + 'short_code' => $short_code, + 'error' => $e->getMessage(), + ], 'error'); + return ['code' => -999, 'msg' => '核销失败,请重试']; + } + } + + /** + * 通过 ticket_id 核销票(内部方法) + * + * @param int $ticket_id 票ID + * @param int $verifier_id 核销员ID + * @return array [code, msg] + */ + private static function verifyTicketById($ticket_id, $verifier_id) + { + try { + return \think\facade\Db::transaction(function () use ($ticket_id, $verifier_id) { + // FOR UPDATE 悲观锁:防止并发核销同一张票 + $ticket = \think\facade\Db::name(BaseService::table('tickets')) + ->where('id', $ticket_id) + ->lock(true) + ->find(); + + if (empty($ticket)) { + return ['code' => -1, 'msg' => '票不存在']; + } + + if ($ticket['verify_status'] == 1) { + return ['code' => -2, 'msg' => '该票已核销']; + } + + if ($ticket['verify_status'] == 2) { + return ['code' => -3, 'msg' => '该票已退款']; + } + + $now = BaseService::now(); + + // 更新票状态 + \think\facade\Db::name(BaseService::table('tickets')) + ->where('id', $ticket_id) + ->update([ + 'verify_status' => 1, + 'verify_time' => $now, + 'verifier_id' => $verifier_id, + 'updated_at' => $now, + ]); + + // 写入核销记录 + $verifier = \think\facade\Db::name(BaseService::table('verifiers')) + ->where('id', $verifier_id) + ->find(); + + \think\facade\Db::name(BaseService::table('verifications'))->insert([ + 'ticket_id' => $ticket_id, + 'ticket_code' => $ticket['ticket_code'], + 'verifier_id' => $verifier_id, + 'verifier_name'=> $verifier['name'] ?? '', + 'goods_id' => $ticket['goods_id'], + 'created_at' => $now, + ]); + + BaseService::log('verifyTicketById: success', [ + 'ticket_id' => $ticket_id, + 'verifier_id' => $verifier_id, + ]); + + // 审计日志 + AuditService::logVerify( + $ticket_id, + $ticket['ticket_code'], + $verifier_id, + $verifier['name'] ?? '', + 'success', + 0 + ); + + return [ + 'code' => 0, + 'msg' => '核销成功', + 'data' => [ + 'seat_info' => $ticket['seat_info'], + 'real_name' => $ticket['real_name'], + 'goods_name' => json_decode($ticket['goods_snapshot'] ?? '{}', true)['goods_name'] ?? '', + ], + ]; + }); + } catch (\Throwable $e) { + BaseService::log('verifyTicketById: transaction_error', [ + 'ticket_id' => $ticket_id, + 'error' => $e->getMessage(), + ], 'error'); + return ['code' => -999, 'msg' => '核销失败,请重试']; + } + } + /** * 生成 QR 码图片 URL */ @@ -294,4 +447,75 @@ class TicketService extends BaseService ])); return request()->domain() . request()->root() . '?s=index/qrcode/index&content=' . urlencode($content) . '&size=8&level=H'; } + + /** + * 获取票的 QR payload(带动态刷新) + * + * @param int $ticket_id 票ID + * @param int $user_id 用户ID(校验归属) + * @return array [code, data] + */ + public static function getQrPayload($ticket_id, $user_id) + { + $ticket = \think\facade\Db::name(BaseService::table('tickets')) + ->where('id', $ticket_id) + ->where('user_id', $user_id) + ->find(); + + if (empty($ticket)) { + return ['code' => -1, 'msg' => '票不存在']; + } + + // 已核销的票不返回 QR + if ($ticket['verify_status'] == 1) { + return ['code' => -2, 'msg' => '该票已核销']; + } + + // 已退款的票不返回 QR + if ($ticket['verify_status'] == 2) { + return ['code' => -3, 'msg' => '该票已退款']; + } + + // 检查是否需要刷新 QR(剩余有效期 < 15分钟) + $qr_payload = $ticket['qr_payload']; + if (!empty($qr_payload)) { + $decoded = BaseService::verifyQrPayload($qr_payload); + if ($decoded !== null && $decoded['exp'] - time() > 900) { + // 有效期 > 15分钟,返回缓存 + return [ + 'code' => 0, + 'msg' => 'success', + 'data' => [ + 'payload' => $qr_payload, + 'cached' => true, + 'expires_in'=> $decoded['exp'] - time(), + ], + ]; + } + } + + // 需要刷新 QR(过期或即将过期) + $now = time(); + $new_payload = BaseService::signQrPayload([ + 'id' => $ticket_id, + 'g' => $ticket['goods_id'], + 'iat' => $now, + 'exp' => $now + 1800, // 30分钟 + ]); + + // 更新缓存 + \think\facade\Db::name(BaseService::table('tickets')) + ->where('id', $ticket_id) + ->update(['qr_payload' => $new_payload, 'updated_at' => $now]); + + return [ + 'code' => 0, + 'msg' => 'success', + 'data' => [ + 'payload' => $new_payload, + 'cached' => false, + 'expires_in'=> 1800, + ], + ]; + } }