From f422ffcebb33f5ad611bebf2fbfb43efaf0419dc Mon Sep 17 00:00:00 2001 From: Council Date: Fri, 24 Apr 2026 12:16:58 +0800 Subject: [PATCH] fix: order paid hook goods_id from order_detail, backfill tickets --- fix_backlog_tickets.php | 303 ++++++++++++++++++ .../vr_ticket/service/TicketService.php | 22 +- 2 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 fix_backlog_tickets.php diff --git a/fix_backlog_tickets.php b/fix_backlog_tickets.php new file mode 100644 index 0000000..05cdb16 --- /dev/null +++ b/fix_backlog_tickets.php @@ -0,0 +1,303 @@ + PDO::ERRMODE_EXCEPTION] + ); +} catch (PDOException $e) { + die("数据库连接失败: " . $e->getMessage() . "\n"); +} + +echo "[配置] 数据库: {$MYSQL_DB}\n\n"; + +// ============================================================ +// 工具函数 +// ============================================================ + +function vr_now(): int { + return time(); +} + +function vr_generate_uuid(): string { + $data = random_bytes(16); + $data[6] = chr(ord($data[6]) & 0x0f | 0x40); + $data[8] = chr(ord($data[8]) & 0x3f | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); +} + +function vr_get_goods_key(int $goods_id, string $secret): string { + return substr(hash_hmac('sha256', (string)$goods_id, $secret), 0, 16); +} + +function vr_feistel_round(int $R, int $round, string $key): int { + $hmac = hash_hmac('sha256', $R . '.' . $round, $key, true); + $val = (ord($hmac[0]) << 16) | (ord($hmac[1]) << 8) | ord($hmac[2]); + return $val & 0x7FFFF; +} + +function vr_feistel_encode(int $packed, string $key): string { + $L = ($packed >> 19) & 0x1FFFFF; + $R = $packed & 0x7FFFF; + for ($i = 0; $i < 8; $i++) { + $round_key = hash_hmac('sha256', pack('V', $i), $key, true); + $F = (ord($round_key[0]) << 16) | (ord($round_key[1]) << 8) | ord($round_key[2]); + $L_new = $R; + $R_new = ($L ^ $F) & 0x7FFFF; + $L = $L_new; + $R = $R_new; + } + $result = (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF); + return base_convert($result, 10, 36); +} + +function vr_short_code_encode(int $goods_id, int $ticket_id, string $secret): string { + if ($goods_id > 0xFFFFFF) { + throw new Exception("goods_id 超出范围: {$goods_id}"); + } + if ($ticket_id <= 0) { + throw new Exception("ticket_id 必须为正整数: {$ticket_id}"); + } + $goods_part = str_pad(base_convert($goods_id, 10, 36), 4, '0', STR_PAD_LEFT); + $ticket_int = intval(base_convert($ticket_id, 10, 36)); + $key = vr_get_goods_key($goods_id, $secret); + $obfuscated = vr_feistel_encode($ticket_int, $key); + return strtolower($goods_part . $obfuscated); +} + +function vr_sign_qr_payload(int $id, int $goods_id, int $now, string $secret): string { + $sign_str = "{$id}.{$goods_id}.{$now}." . ($now + 1800); + $sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8); + $payload = json_encode([ + 'id' => $id, + 'g' => $goods_id, + 'iat' => $now, + 'exp' => $now + 1800, + 'sig' => $sig, + ], JSON_UNESCAPED_UNICODE); + return base64_encode($payload); +} + +// 解析 spec JSON,提取 $vr-座位号 类型的值 +function extract_seat_from_spec(string $spec): string { + $list = json_decode($spec, true); + if (!is_array($list)) { + return ''; + } + foreach ($list as $item) { + $type = $item['type'] ?? ''; + $value = $item['value'] ?? ''; + if ($type === '$vr-座位号') { + return $value; + } + } + // fallback: 取 $vr-分区 + foreach ($list as $item) { + $type = $item['type'] ?? ''; + $value = $item['value'] ?? ''; + if ($type === '$vr-分区') { + return $value; + } + } + return ''; +} + +// 判断是否为票务商品 +function is_ticket_goods(PDO $pdo, int $goods_id): bool { + $stmt = $pdo->prepare("SELECT item_type FROM vrt_goods WHERE id = ?"); + $stmt->execute([$goods_id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return false; + } + return ($row['item_type'] ?? '') === 'ticket'; +} + +// ============================================================ +// 主流程 +// ============================================================ + +// Step 1: 查找已支付订单中,vrt_vr_tickets 里还没有票的订单 +$sql = " +SELECT + o.id AS order_id, + o.order_no, + o.user_id, + o.extension_data, + od.id AS detail_id, + od.goods_id, + od.title, + od.price, + od.spec +FROM vrt_order o +JOIN vrt_order_detail od ON o.id = od.order_id +WHERE o.pay_status = 1 + AND NOT EXISTS ( + SELECT 1 FROM vrt_vr_tickets t + WHERE t.order_id = o.id + AND t.seat_info = :seat_placeholder + ) +ORDER BY o.id, od.id +"; + +$stmt = $pdo->prepare($sql); +$stmt->execute(['seat_placeholder' => '']); +$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + +echo "[Step 1] 找到 " . count($rows) . " 个待处理的订单明细行\n\n"; + +// 先按 order_id 分组,看看哪些订单需要处理 +$ordersById = []; +foreach ($rows as $row) { + $ordersById[$row['order_id']] = true; +} +echo "涉及订单: " . implode(', ', array_keys($ordersById)) . "\n\n"; + +// Step 2: 逐行处理,发放票 +$totalNewTickets = 0; +$skipped = 0; +$pdo->beginTransaction(); + +foreach ($rows as $row) { + $order_id = (int)$row['order_id']; + $detail_id = (int)$row['detail_id']; + $goods_id = (int)$row['goods_id']; + $order_no = $row['order_no']; + $user_id = (int)$row['user_id']; + $title = $row['title']; + $price = (float)$row['price']; + $spec = $row['spec']; + $extension_data = $row['extension_data'] ?? '{}'; + + // 判断是否为票务商品 + if (!is_ticket_goods($pdo, $goods_id)) { + echo " [SKIP] order_id={$order_id} goods_id={$goods_id} 不是票务商品\n"; + $skipped++; + continue; + } + + // 解析座位信息 + $seat_info = extract_seat_from_spec($spec); + if (empty($seat_info)) { + echo " [WARN] order_id={$order_id} goods_id={$goods_id} 无法从 spec 提取座位信息\n"; + $skipped++; + continue; + } + + // 幂等检查:同一 order_id + seat_info 是否已有票 + $checkStmt = $pdo->prepare("SELECT id FROM vrt_vr_tickets WHERE order_id = ? AND seat_info = ?"); + $checkStmt->execute([$order_id, $seat_info]); + if ($checkStmt->fetch()) { + echo " [SKIP] order_id={$order_id} seat_info={$seat_info} 已存在票,幂等跳过\n"; + $skipped++; + continue; + } + + // 生成票数据 + $ticket_code = vr_generate_uuid(); + $now = vr_now(); + $goods_snapshot = json_encode([ + 'goods_name' => $title, + 'spec_name' => $seat_info, + 'price' => $price, + ], JSON_UNESCAPED_UNICODE); + + // 插入票 + $insertSql = "INSERT INTO vrt_vr_tickets + (order_id, order_no, goods_id, goods_snapshot, user_id, ticket_code, qr_data, + seat_info, spec_base_id, real_name, phone, id_card, verify_status, issued_at, created_at, updated_at) + VALUES + (:order_id, :order_no, :goods_id, :goods_snapshot, :user_id, :ticket_code, :qr_data, + :seat_info, :spec_base_id, :real_name, :phone, :id_card, 0, :issued_at, :created_at, :updated_at)"; + + $insertStmt = $pdo->prepare($insertSql); + + // 生成短码和 QR payload(先插入获取自增 ID) + // 短码需要 ticket_id,所以分两步:先插入占位,再更新 + + $insertStmt->execute([ + ':order_id' => $order_id, + ':order_no' => $order_no, + ':goods_id' => $goods_id, + ':goods_snapshot' => $goods_snapshot, + ':user_id' => $user_id, + ':ticket_code' => $ticket_code, + ':qr_data' => '', // 占位 + ':seat_info' => $seat_info, + ':spec_base_id' => 0, + ':real_name' => '', + ':phone' => '', + ':id_card' => '', + ':issued_at' => $now, + ':created_at' => $now, + ':updated_at' => $now, + ]); + + $ticket_id = (int)$pdo->lastInsertId(); + + // 生成短码和 QR payload + $short_code = vr_short_code_encode($goods_id, $ticket_id, $VR_SECRET); + $qr_payload = vr_sign_qr_payload($ticket_id, $goods_id, $now, $VR_SECRET); + $qr_data = $short_code . '|' . $qr_payload; + + // 更新 qr_data + $updateStmt = $pdo->prepare("UPDATE vrt_vr_tickets SET qr_data = :qr_data WHERE id = :id"); + $updateStmt->execute([':qr_data' => $qr_data, ':id' => $ticket_id]); + + // 写入观演人信息 + $attendee = ['real_name' => '', 'phone' => '', 'id_card' => '']; + $extData = json_decode($extension_data, true); + if (isset($extData['attendee'])) { + $attendee = array_merge($attendee, $extData['attendee']); + } + + $updateAttendee = $pdo->prepare("UPDATE vrt_vr_tickets SET real_name = :rn, phone = :ph, id_card = :ic WHERE id = :id"); + $updateAttendee->execute([ + ':rn' => $attendee['real_name'] ?? '', + ':ph' => $attendee['phone'] ?? '', + ':ic' => $attendee['id_card'] ?? '', + ':id' => $ticket_id, + ]); + + $totalNewTickets++; + echo " [OK] order_id={$order_id}, ticket_id={$ticket_id}, goods_id={$goods_id}, seat={$seat_info}\n"; +} + +$pdo->commit(); + +echo "\n========================================\n"; +echo "完成!\n"; +echo "补发票数: {$totalNewTickets}\n"; +echo "跳过数: {$skipped}\n"; +echo "========================================\n"; diff --git a/shopxo/app/plugins/vr_ticket/service/TicketService.php b/shopxo/app/plugins/vr_ticket/service/TicketService.php index 63d923c..07ea6d8 100644 --- a/shopxo/app/plugins/vr_ticket/service/TicketService.php +++ b/shopxo/app/plugins/vr_ticket/service/TicketService.php @@ -35,12 +35,6 @@ class TicketService extends BaseService 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) @@ -53,6 +47,12 @@ class TicketService extends BaseService // 逐行解析 spec JSON,提取座位信息 foreach ($order_goods as &$og) { + // 判断是否为票务商品(goods_id 在 order_detail 而非 order 主表) + if (!BaseService::isTicketGoods($og['goods_id'])) { + BaseService::log('onOrderPaid: not a ticket goods', ['order_id' => $order_id, 'goods_id' => $og['goods_id']], 'info'); + continue; + } + $spec_list = json_decode($og['spec'] ?? '[]', true); $spec_name = ''; $spec_base_id = 0; @@ -74,7 +74,7 @@ class TicketService extends BaseService // 尝试通过座位名反向查找 spec_base_id if ($spec_name) { $spec_base = \think\facade\Db::name('goods_spec_value') - ->where('goods_id', $order['goods_id']) + ->where('goods_id', $og['goods_id']) ->where('value', $spec_name) ->find(); $spec_base_id = $spec_base['goods_spec_base_id'] ?? 0; @@ -134,7 +134,7 @@ class TicketService extends BaseService $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_id' => $og['goods_id'], 'goods_snapshot' => json_encode([ 'goods_name' => $og['title'] ?? '', 'spec_name' => $spec_name, @@ -161,12 +161,12 @@ class TicketService extends BaseService // Step 2: 生成短码(goods_id 明文 + ticket_id 混淆) // 短码存储在 qr_data 中,供前端展示 - $short_code = BaseService::shortCodeEncode($order['goods_id'], $ticket_id); + $short_code = BaseService::shortCodeEncode($og['goods_id'], $ticket_id); // Step 3: 生成 QR payload(HMAC-SHA256 签名,30分钟有效) $qr_payload = BaseService::signQrPayload([ 'id' => $ticket_id, - 'g' => $order['goods_id'], + 'g' => $og['goods_id'], 'iat' => $now, 'exp' => $now + 1800, // 30分钟 ]); @@ -194,7 +194,7 @@ class TicketService extends BaseService BaseService::log('issueTicket: success', [ 'ticket_id' => $ticket_id, 'short_code' => $short_code, - 'goods_id' => $order['goods_id'], + 'goods_id' => $og['goods_id'], ]); return $ticket_id;