304 lines
9.9 KiB
PHP
304 lines
9.9 KiB
PHP
<?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";
|