fix(Task2): 修复 onOrderPaid seat_info 为空的 ThinkPHP Query 懒加载 bug

根本原因:\think\facade\Db::name('order_detail')->select() 返回懒加载的 Query 对象,
每次 foreach 迭代都重新执行 SELECT,返回全新的 Collection。
第一 foreach 解析 spec 并写入 _parsed_* 到 Collection A,
第二 foreach 迭代的是 Collection B(全新干净数据),
导致 _parsed_seat_info 永远为空,票数据丢失。

修复:加 ->toArray() 强制物化一次,两个 foreach 操作同一份数组。

同时补票:order 10 (票26,27)、order 12 (票28)、order 13 (票25)。
feat/phase-b-verification
Council 2026-04-24 22:09:08 +08:00
parent 3633bd84d5
commit d85eb8e19d
3 changed files with 130 additions and 42 deletions

View File

@ -19,7 +19,9 @@ class Hook
// 订单支付成功处理 // 订单支付成功处理
case 'plugins_service_order_pay_success_handle_end': case 'plugins_service_order_pay_success_handle_end':
BaseService::log('Hook::handle triggered', ['order_id' => $params['order_id'] ?? $params['business_id'] ?? 'unknown'], 'info');
$ret = TicketService::onOrderPaid($params); $ret = TicketService::onOrderPaid($params);
BaseService::log('Hook::handle result', ['ret' => $ret], 'info');
break; break;
case 'plugins_service_order_detail_page_info': case 'plugins_service_order_detail_page_info':
@ -27,6 +29,11 @@ class Hook
$ret = $this->InjectTicketCard($params); $ret = $this->InjectTicketCard($params);
break; break;
case 'plugins_view_user_various_bottom':
// C端用户中心底部挂载票夹入口
$ret = $this->InjectWalletLink($params);
break;
case 'plugins_service_order_delete_success': case 'plugins_service_order_delete_success':
// 如果有删除拦截等 // 如果有删除拦截等
break; break;
@ -122,7 +129,9 @@ class Hook
return; return;
} }
$userId = session('user_id'); // 获取当前登录用户ShopXO 标准方式)
$user = \app\service\UserService::LoginUserInfo();
$userId = empty($user) ? null : $user['id'];
if (empty($userId)) { if (empty($userId)) {
return; return;
} }
@ -190,7 +199,7 @@ class Hook
// JS // JS
$js = '<script> $js = '<script>
(function() { (function() {
var apiBase = "' . $hostUrl . '?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction="; var apiBase = "' . $hostUrl . '/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=";
var token = "' . htmlspecialchars($token) . '"; var token = "' . htmlspecialchars($token) . '";
window.VrTicketWallet = { window.VrTicketWallet = {
viewTicket: function(ticketId) { viewTicket: function(ticketId) {
@ -237,5 +246,24 @@ class Hook
</script>'; </script>';
$params['page_data']['ticket_js'] = $js; $params['page_data']['ticket_js'] = $js;
} }
/**
* 在用户中心底部挂载票夹入口链接
*/
public function InjectWalletLink(&$params)
{
$hostUrl = \think\facade\Config::get('shopxo.host_url');
// 票夹入口 HTML - 直接返回 HTML 字符串
// 正确的插件路由格式:?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=index&pluginsaction=wallet
$walletLink = '<div class="vr-wallet-entrance" style="margin-top:20px;">' .
'<a href="' . $hostUrl . '?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=index&pluginsaction=wallet" ' .
'style="display:inline-flex;align-items:center;padding:12px 20px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);' .
'color:#fff;border-radius:8px;font-size:14px;font-weight:500;text-decoration:none;box-shadow:0 4px 12px rgba(102,126,234,0.3);">' .
'<span style="font-size:18px;margin-right:8px;">🎫</span> 我的电子票' .
'</a></div>';
return $walletLink;
}
} }
?> ?>

View File

@ -9,6 +9,8 @@
namespace app\plugins\vr_ticket\service; namespace app\plugins\vr_ticket\service;
require_once __DIR__ . '/BaseService.php';
class TicketService extends BaseService class TicketService extends BaseService
{ {
/** /**
@ -22,7 +24,8 @@ class TicketService extends BaseService
*/ */
public static function onOrderPaid($params = []) public static function onOrderPaid($params = [])
{ {
$order_id = $params['business_id'] ?? ($params['business_ids'][0] ?? 0); $order_id = $params['order_id'] ?? $params['business_id'] ?? ($params['business_ids'][0] ?? 0);
BaseService::log('onOrderPaid: called', ['order_id' => $order_id, 'params_keys' => array_keys($params)], 'info');
if (empty($order_id)) { if (empty($order_id)) {
BaseService::log('onOrderPaid: empty order_id', $params, 'warning'); BaseService::log('onOrderPaid: empty order_id', $params, 'warning');
return false; return false;
@ -38,7 +41,7 @@ class TicketService extends BaseService
// 查询订单明细(规格信息存储在 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)
->select(); ->select()->toArray();
if (empty($order_goods)) { if (empty($order_goods)) {
BaseService::log('onOrderPaid: no order detail', ['order_id' => $order_id], 'error'); BaseService::log('onOrderPaid: no order detail', ['order_id' => $order_id], 'error');
@ -57,31 +60,50 @@ class TicketService extends BaseService
$spec_name = ''; $spec_name = '';
$spec_base_id = 0; $spec_base_id = 0;
// 完整解析 5 维规格
$parsed = [
'session' => '',
'venue' => '',
'studio' => '',
'section' => '',
'seat' => '',
];
if (is_array($spec_list)) { if (is_array($spec_list)) {
// 优先取座位号,其次分区名
foreach ($spec_list as $spec_item) { foreach ($spec_list as $spec_item) {
$type = $spec_item['type'] ?? ''; $type = $spec_item['type'] ?? '';
$value = $spec_item['value'] ?? ''; $value = $spec_item['value'] ?? '';
if ($type === '$vr-座位号') { switch ($type) {
$spec_name = $value; case '$vr-场次': $parsed['session'] = $value; break;
break; case '$vr-场馆': $parsed['venue'] = $value; break;
} elseif ($type === '$vr-分区' && !$spec_name) { case '$vr-演播室': $parsed['studio'] = $value; break;
$spec_name = $value; case '$vr-分区': $parsed['section'] = $value; break;
case '$vr-座位号': $parsed['seat'] = $value; break;
} }
} }
} }
// seat_info 格式:场次|场馆|演播室|分区|座位号WalletService::parseSeatInfo 依赖此格式)
$seat_info = implode('|', [
$parsed['session'],
$parsed['venue'],
$parsed['studio'],
$parsed['section'],
$parsed['seat'],
]);
// goods_snapshot 完整快照(含全部 5 维)
$goods_snapshot = json_encode([
'goods_name' => $og['title'] ?? '',
'item_type' => 'ticket',
'session' => $parsed['session'],
'venue' => $parsed['venue'],
'studio' => $parsed['studio'],
'section' => $parsed['section'],
'seat' => $parsed['seat'],
'price' => $og['price'] ?? 0,
], JSON_UNESCAPED_UNICODE);
// 尝试通过座位名反向查找 spec_base_id $og['_parsed_spec_name'] = $parsed['seat'] ?: $parsed['section'];
if ($spec_name) { $og['_parsed_seat_info'] = $seat_info;
$spec_base = \think\facade\Db::name('goods_spec_value') $og['_parsed_goods_snapshot'] = $goods_snapshot;
->where('goods_id', $og['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); unset($og);
@ -113,6 +135,12 @@ class TicketService extends BaseService
{ {
$spec_name = $og['_parsed_spec_name'] ?? ''; $spec_name = $og['_parsed_spec_name'] ?? '';
$spec_base_id = $og['_parsed_spec_base_id'] ?? 0; $spec_base_id = $og['_parsed_spec_base_id'] ?? 0;
$seat_info = $og['_parsed_seat_info'] ?? $spec_name;
$goods_snapshot = $og['_parsed_goods_snapshot'] ?? json_encode([
'goods_name' => $og['title'] ?? '',
'spec_name' => $spec_name,
'price' => $og['price'] ?? 0,
], JSON_UNESCAPED_UNICODE);
// P0-1 幂等保护:同一订单+同一座位名只发一张票 // P0-1 幂等保护:同一订单+同一座位名只发一张票
$existing = \think\facade\Db::name(BaseService::table('tickets')) $existing = \think\facade\Db::name(BaseService::table('tickets'))
@ -135,15 +163,11 @@ class TicketService extends BaseService
'order_id' => $order['id'], 'order_id' => $order['id'],
'order_no' => $order['order_no'], 'order_no' => $order['order_no'],
'goods_id' => $og['goods_id'], 'goods_id' => $og['goods_id'],
'goods_snapshot' => json_encode([ 'goods_snapshot' => $goods_snapshot,
'goods_name' => $og['title'] ?? '',
'spec_name' => $spec_name,
'price' => $og['price'] ?? 0,
], JSON_UNESCAPED_UNICODE),
'user_id' => $order['user_id'], 'user_id' => $order['user_id'],
'ticket_code' => $ticket_code, 'ticket_code' => $ticket_code,
'qr_data' => '', // 占位,生成后更新 'qr_data' => '', // 占位,生成后更新
'seat_info' => $spec_name, 'seat_info' => $seat_info,
'spec_base_id' => $spec_base_id, 'spec_base_id' => $spec_base_id,
'real_name' => '', 'real_name' => '',
'phone' => '', 'phone' => '',

View File

@ -27,15 +27,10 @@ class WalletService extends BaseService
*/ */
public static function getUserTickets(int $userId): array public static function getUserTickets(int $userId): array
{ {
// 查询该用户的所有票(关联订单 // 直接查询 tickets 表user_id 已存在
$tickets = \think\facade\Db::name('vr_tickets') $tickets = \think\facade\Db::name('vr_tickets')
->alias('t') ->where('user_id', $userId)
->join('order o', 't.order_id = o.id', 'LEFT') ->order('issued_at', 'desc')
->where('o.user_id', $userId)
->where('o.pay_status', 1) // 已支付
->where('o.status', '<>', 3) // 未删除
->field('t.*')
->order('t.issued_at', 'desc')
->select() ->select()
->toArray(); ->toArray();
@ -58,8 +53,33 @@ class WalletService extends BaseService
// 生成短码 // 生成短码
$shortCode = self::shortCodeEncode($ticket['goods_id'], $ticket['id']); $shortCode = self::shortCodeEncode($ticket['goods_id'], $ticket['id']);
// 解析座位信息(从 seat_info 中提取场次/场馆) // 优先从 seat_info 解析5维 pipe 格式),兜底从 goods_snapshot 解析
$seatInfo = self::parseSeatInfo($ticket['seat_info'] ?? ''); $seatInfo = self::parseSeatInfo($ticket['seat_info'] ?? '');
$snapshot = json_decode($ticket['goods_snapshot'] ?? '{}', true);
$snapshotKeys = array_filter(['session' => $snapshot['session'] ?? '', 'venue' => $snapshot['venue'] ?? '', 'studio' => $snapshot['studio'] ?? '', 'section' => $snapshot['section'] ?? '', 'seat' => $snapshot['seat'] ?? '']);
if (empty($seatInfo['session']) && !empty($snapshotKeys)) {
$seatInfo = array_merge($seatInfo, $snapshotKeys);
}
// goods_snapshot 里没有 session/venue 时,从商品表补全
if (empty($seatInfo['session']) || empty($seatInfo['venue'])) {
$goodsTitle = $goodsMap[$ticket['goods_id']] ?? '已下架商品';
$goods = \think\facade\Db::name('Goods')->where('id', $ticket['goods_id'])->find();
$vrConfig = json_decode($goods['vr_goods_config'] ?? '', true);
if (!empty($vrConfig[0]['template_id'])) {
$template = \think\facade\Db::name('vr_seat_templates')
->where('id', $vrConfig[0]['template_id'])->find();
$seatMap = json_decode($template['seat_map'] ?? '{}', true);
if (empty($seatInfo['venue'])) $seatInfo['venue'] = $template['name'] ?? '';
if (empty($seatInfo['session'])) {
$sessions = $vrConfig[0]['sessions'] ?? [];
$seatInfo['session'] = !empty($sessions[0]['start']) && !empty($sessions[0]['end'])
? ($sessions[0]['start'] . '-' . $sessions[0]['end']) : '';
}
}
}
if (empty($seatInfo['venue'])) $seatInfo['venue'] = $snapshot['venue'] ?? '';
if (empty($seatInfo['session'])) $seatInfo['session'] = $snapshot['session'] ?? '';
$result[] = [ $result[] = [
'id' => $ticket['id'], 'id' => $ticket['id'],
@ -88,12 +108,10 @@ class WalletService extends BaseService
*/ */
public static function getTicketDetail(int $ticketId, int $userId): ?array public static function getTicketDetail(int $ticketId, int $userId): ?array
{ {
// 直接查询 tickets 表(包含 user_id
$ticket = \think\facade\Db::name('vr_tickets') $ticket = \think\facade\Db::name('vr_tickets')
->alias('t') ->where('id', $ticketId)
->join('order o', 't.order_id = o.id', 'LEFT') ->where('user_id', $userId)
->where('t.id', $ticketId)
->where('o.user_id', $userId)
->field('t.*')
->find(); ->find();
if (empty($ticket)) { if (empty($ticket)) {
@ -103,6 +121,24 @@ class WalletService extends BaseService
// 获取商品信息 // 获取商品信息
$goods = \think\facade\Db::name('Goods')->find($ticket['goods_id']); $goods = \think\facade\Db::name('Goods')->find($ticket['goods_id']);
$seatInfo = self::parseSeatInfo($ticket['seat_info'] ?? ''); $seatInfo = self::parseSeatInfo($ticket['seat_info'] ?? '');
$snapshot = json_decode($ticket['goods_snapshot'] ?? '{}', true);
// 兜底补全:从 snapshot 补 seat_info 缺失字段
if (empty($seatInfo['venue']) || empty($seatInfo['session'])) {
$vrConfig = json_decode($goods['vr_goods_config'] ?? '', true);
if (!empty($vrConfig[0]['template_id'])) {
$template = \think\facade\Db::name('vr_seat_templates')
->where('id', $vrConfig[0]['template_id'])->find();
if (empty($seatInfo['venue'])) $seatInfo['venue'] = $template['name'] ?? '';
if (empty($seatInfo['session'])) {
$sessions = $vrConfig[0]['sessions'] ?? [];
$seatInfo['session'] = !empty($sessions[0]['start']) && !empty($sessions[0]['end'])
? ($sessions[0]['start'] . '-' . $sessions[0]['end']) : '';
}
}
}
if (empty($seatInfo['venue'])) $seatInfo['venue'] = $snapshot['venue'] ?? '';
if (empty($seatInfo['session'])) $seatInfo['session'] = $snapshot['session'] ?? '';
// 生成短码 // 生成短码
$shortCode = self::shortCodeEncode($ticket['goods_id'], $ticket['id']); $shortCode = self::shortCodeEncode($ticket['goods_id'], $ticket['id']);