find($order_id); if (empty($order) || $order['pay_status'] != 1) { BaseService::log('onOrderPaid: order not paid or not found', ['order_id' => $order_id], 'warning'); return false; } // 判断是否为票务商品 if (!BaseService::isTicketGoods($order['goods_id'])) { BaseService::log('onOrderPaid: not a ticket goods', ['order_id' => $order_id], 'info'); return true; // 不是票务商品,不报错 } // 查询订单明细(规格信息存储在 spec JSON 字段) $order_goods = \think\facade\Db::name('order_detail') ->where('order_id', $order_id) ->select(); if (empty($order_goods)) { BaseService::log('onOrderPaid: no order detail', ['order_id' => $order_id], 'error'); return false; } // 逐行解析 spec JSON,提取座位信息 foreach ($order_goods as &$og) { $spec_list = json_decode($og['spec'] ?? '[]', true); $spec_name = ''; $spec_base_id = 0; if (is_array($spec_list)) { // 优先取座位号,其次分区名 foreach ($spec_list as $spec_item) { $type = $spec_item['type'] ?? ''; $value = $spec_item['value'] ?? ''; if ($type === '$vr-座位号') { $spec_name = $value; break; } elseif ($type === '$vr-分区' && !$spec_name) { $spec_name = $value; } } } // 尝试通过座位名反向查找 spec_base_id if ($spec_name) { $spec_base = \think\facade\Db::name('goods_spec_value') ->where('goods_id', $order['goods_id']) ->where('value', $spec_name) ->find(); $spec_base_id = $spec_base['goods_spec_base_id'] ?? 0; } $og['_parsed_spec_name'] = $spec_name; $og['_parsed_spec_base_id'] = $spec_base_id; } unset($og); // 逐个生成票(每个订单明细行 = 一张票) $count = 0; foreach ($order_goods as $og) { $ticket_id = self::issueTicket($order, $og); if ($ticket_id > 0) { $count++; } } BaseService::log('onOrderPaid: success', [ 'order_id' => $order_id, 'tickets_issued' => $count, ]); return $count > 0; } /** * 发放单张票 * * @param array $order 订单数据 * @param array $og 订单商品数据(来自 vrt_order_detail,已解析 _parsed_spec_name 等字段) * @return int 票ID */ public static function issueTicket($order, $og) { $spec_name = $og['_parsed_spec_name'] ?? ''; $spec_base_id = $og['_parsed_spec_base_id'] ?? 0; // P0-1 幂等保护:同一订单+同一座位名只发一张票 $existing = \think\facade\Db::name(BaseService::table('tickets')) ->where('order_id', $order['id']) ->where('seat_info', $spec_name) ->find(); if (!empty($existing)) { BaseService::log('issueTicket: idempotent_skip', [ 'order_id' => $order['id'], 'seat_info' => $spec_name, ], 'info'); return $existing['id']; } $ticket_code = BaseService::generateUuid(); $now = BaseService::now(); // Step 1: 先插入获取 ticket_id(用于 short_code 生成) $ticket_id = \think\facade\Db::name(BaseService::table('tickets'))->insertGetId([ 'order_id' => $order['id'], 'order_no' => $order['order_no'], 'goods_id' => $order['goods_id'], 'goods_snapshot' => json_encode([ 'goods_name' => $og['title'] ?? '', 'spec_name' => $spec_name, 'price' => $og['price'] ?? 0, ], JSON_UNESCAPED_UNICODE), 'user_id' => $order['user_id'], 'ticket_code' => $ticket_code, 'qr_data' => '', // 占位,生成后更新 'seat_info' => $spec_name, 'spec_base_id' => $spec_base_id, 'real_name' => '', 'phone' => '', 'id_card' => '', 'verify_status' => 0, 'issued_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); if ($ticket_id <= 0) { BaseService::log('issueTicket: insert_failed', ['order_id' => $order['id']], 'error'); return 0; } // Step 2: 生成短码(goods_id 明文 + ticket_id 混淆) // 短码存储在 qr_data 中,供前端展示 $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分钟 ]); // qr_data 格式:短码|QR_payload(竖线分隔) $qr_data = $short_code . '|' . $qr_payload; // Step 4: 更新 qr_data \think\facade\Db::name(BaseService::table('tickets')) ->where('id', $ticket_id) ->update(['qr_data' => $qr_data]); // 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; } /** * 核销票(事务保护 + 悲观锁防并发) * * @param string $ticket_code 票码 * @param int $verifier_id 核销员ID * @return array [code, msg] */ public static function verifyTicket($ticket_code, $verifier_id) { try { return \think\facade\Db::transaction(function () use ($ticket_code, $verifier_id) { // FOR UPDATE 悲观锁:防止并发核销同一张票 $ticket = \think\facade\Db::name(BaseService::table('tickets')) ->where('ticket_code', $ticket_code) ->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_code, 'verifier_id' => $verifier_id, 'verifier_name'=> $verifier['name'] ?? '', 'goods_id' => $ticket['goods_id'], 'created_at' => $now, ]); BaseService::log('verifyTicket: success', [ 'ticket_id' => $ticket['id'], 'verifier_id' => $verifier_id, ]); // 审计日志 AuditService::logVerify( $ticket['id'], $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('verifyTicket: transaction_error', [ 'ticket_code' => $ticket_code, 'error' => $e->getMessage(), ], 'error'); return ['code' => -999, 'msg' => '核销失败,请重试']; } } /** * 获取用户所有票 */ public static function getUserTickets($user_id, $status = null) { $where = ['user_id' => $user_id]; if ($status !== null) { $where['verify_status'] = $status; } return \think\facade\Db::name(BaseService::table('tickets')) ->where($where) ->order('created_at', 'desc') ->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 */ public static function getQrCodeUrl($ticket_code) { $content = base64_encode(json_encode([ 'type' => 'vr_ticket', 'code' => $ticket_code, ])); return request()->domain() . request()->root() . '?s=index/qrcode/index&content=' . urlencode($content) . '&size=8&level=H'; } /** * 获取票的 QR 数据(短码 + payload) * * qr_data 格式:短码|payload * * @param int $ticket_id 票ID * @param int $user_id 用户ID(校验归属) * @return array [code, data] */ public static function getQrData($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_data = $ticket['qr_data'] ?? ''; if (empty($qr_data) || strpos($qr_data, '|') === false) { return ['code' => -4, 'msg' => 'QR数据异常']; } [$short_code, $payload] = explode('|', $qr_data, 2); // 检查是否需要刷新 QR(剩余有效期 < 15分钟) if (!empty($payload)) { $decoded = BaseService::verifyQrPayload($payload); if ($decoded !== null && $decoded['exp'] - time() > 900) { // 有效期 > 15分钟,返回缓存 return [ 'code' => 0, 'msg' => 'success', 'data' => [ 'short_code' => $short_code, 'payload' => $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分钟 ]); // 更新缓存 $new_qr_data = $short_code . '|' . $new_payload; \think\facade\Db::name(BaseService::table('tickets')) ->where('id', $ticket_id) ->update(['qr_data' => $new_qr_data, 'updated_at' => $now]); return [ 'code' => 0, 'msg' => 'success', 'data' => [ 'short_code' => $short_code, 'payload' => $new_payload, 'cached' => false, 'expires_in' => 1800, ], ]; } }