fix: order paid hook goods_id from order_detail, backfill tickets

feat/phase-b-verification
Council 2026-04-24 12:16:58 +08:00
parent 14e277a20d
commit f422ffcebb
2 changed files with 314 additions and 11 deletions

303
fix_backlog_tickets.php Normal file
View File

@ -0,0 +1,303 @@
<?php
/**
* 补发历史已支付订单的票(修复 goods_id order_detail 而非 order 主表取值)
*
* 问题根因onOrderPaid vrt_order 表取 goods_id字段不存在实际在 vrt_order_detail
* 修复后onOrderPaid 已改为从 order_detail goods_id
* 此脚本用于补发已支付但未生票的历史订单
*
* 使用方式(直接在宿主机执行):
* php fix_backlog_tickets.php
*
* 或在 Docker PHP 容器内执行:
* docker exec shopxo-php php /var/www/html/fix_backlog_tickets.php
*/
echo "========================================\n";
echo "VR票务 - 历史订单补票脚本\n";
echo "========================================\n\n";
// ============================================================
// 连接数据库
// ============================================================
$MYSQL_HOST = '172.23.0.2';
$MYSQL_PORT = 3306;
$MYSQL_USER = 'root';
$MYSQL_PASS = 'shopxo_root_2024';
$MYSQL_DB = 'vrticket';
// VR_TICKET_SECRET与 BaseService 保持一致,使用默认值)
$VR_SECRET = '8935b3a3-a7b4-4e3d-8c1f-9b7e2a6f5d4c';
try {
$pdo = new PDO(
"mysql:host={$MYSQL_HOST};port={$MYSQL_PORT};dbname={$MYSQL_DB};charset=utf8mb4",
$MYSQL_USER,
$MYSQL_PASS,
[PDO::ATTR_ERRMODE => 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";

View File

@ -35,12 +35,6 @@ class TicketService extends BaseService
return false; return false;
} }
// 判断是否为票务商品
if (!BaseService::isTicketGoods($order['goods_id'])) {
BaseService::log('onOrderPaid: not a ticket goods', ['order_id' => $order_id], 'info');
return true; // 不是票务商品,不报错
}
// 查询订单明细(规格信息存储在 spec JSON 字段) // 查询订单明细(规格信息存储在 spec JSON 字段)
$order_goods = \think\facade\Db::name('order_detail') $order_goods = \think\facade\Db::name('order_detail')
->where('order_id', $order_id) ->where('order_id', $order_id)
@ -53,6 +47,12 @@ class TicketService extends BaseService
// 逐行解析 spec JSON提取座位信息 // 逐行解析 spec JSON提取座位信息
foreach ($order_goods as &$og) { 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_list = json_decode($og['spec'] ?? '[]', true);
$spec_name = ''; $spec_name = '';
$spec_base_id = 0; $spec_base_id = 0;
@ -74,7 +74,7 @@ class TicketService extends BaseService
// 尝试通过座位名反向查找 spec_base_id // 尝试通过座位名反向查找 spec_base_id
if ($spec_name) { if ($spec_name) {
$spec_base = \think\facade\Db::name('goods_spec_value') $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) ->where('value', $spec_name)
->find(); ->find();
$spec_base_id = $spec_base['goods_spec_base_id'] ?? 0; $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([ $ticket_id = \think\facade\Db::name(BaseService::table('tickets'))->insertGetId([
'order_id' => $order['id'], 'order_id' => $order['id'],
'order_no' => $order['order_no'], 'order_no' => $order['order_no'],
'goods_id' => $order['goods_id'], 'goods_id' => $og['goods_id'],
'goods_snapshot' => json_encode([ 'goods_snapshot' => json_encode([
'goods_name' => $og['title'] ?? '', 'goods_name' => $og['title'] ?? '',
'spec_name' => $spec_name, 'spec_name' => $spec_name,
@ -161,12 +161,12 @@ class TicketService extends BaseService
// Step 2: 生成短码goods_id 明文 + ticket_id 混淆) // Step 2: 生成短码goods_id 明文 + ticket_id 混淆)
// 短码存储在 qr_data 中,供前端展示 // 短码存储在 qr_data 中,供前端展示
$short_code = BaseService::shortCodeEncode($order['goods_id'], $ticket_id); $short_code = BaseService::shortCodeEncode($og['goods_id'], $ticket_id);
// Step 3: 生成 QR payloadHMAC-SHA256 签名30分钟有效 // Step 3: 生成 QR payloadHMAC-SHA256 签名30分钟有效
$qr_payload = BaseService::signQrPayload([ $qr_payload = BaseService::signQrPayload([
'id' => $ticket_id, 'id' => $ticket_id,
'g' => $order['goods_id'], 'g' => $og['goods_id'],
'iat' => $now, 'iat' => $now,
'exp' => $now + 1800, // 30分钟 'exp' => $now + 1800, // 30分钟
]); ]);
@ -194,7 +194,7 @@ class TicketService extends BaseService
BaseService::log('issueTicket: success', [ BaseService::log('issueTicket: success', [
'ticket_id' => $ticket_id, 'ticket_id' => $ticket_id,
'short_code' => $short_code, 'short_code' => $short_code,
'goods_id' => $order['goods_id'], 'goods_id' => $og['goods_id'],
]); ]);
return $ticket_id; return $ticket_id;