AdminSidebarInit($params['admin_left_menu']); break; // 订单支付成功处理 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); BaseService::log('Hook::handle result', ['ret' => $ret], 'info'); break; case 'plugins_service_order_detail_page_info': // C端订单详情页注入票夹入口 $ret = $this->InjectTicketCard($params); break; case 'plugins_view_user_various_bottom': // C端用户中心底部挂载票夹入口 $ret = $this->InjectWalletLink($params); break; case 'plugins_service_buy_order_insert_begin': // C端下单前校验:距离开场不足5分钟禁止购买 $ret = $this->BuyCheck($params); break; case 'plugins_service_order_delete_success': // 如果有删除拦截等 break; // 商品列表查询钩子 — 注入坐标城市筛选 case 'plugins_service_goods_list_begin': $ret = $this->OnGoodsListBegin($params); break; // 搜索列表查询钩子 — 注入坐标城市筛选 case 'plugins_service_search_goods_list_begin': // [调试代码] 如需调试钩子触发,可取消下方注释: // $debugFile = defined('ROOT') ? ROOT . 'runtime/debug_vr.log' : '/tmp/debug_vr.log'; // @file_put_contents($debugFile, date('Y-m-d H:i:s') . " switch: plugins_service_search_goods_list_begin hit!\n", FILE_APPEND); $ret = $this->OnSearchListBegin($params); break; // 搜索结果后处理钩子 — 票务商品按 coding 合并 case 'plugins_service_search_goods_list_result': $ret = $this->OnSearchListResult($params); break; } return $ret; } } public function AdminSidebarInit(&$params) { $params[] = [ 'id' => 'plugins-vr_ticket', 'name' => 'VR票务', 'title' => 'VR票务', 'icon' => 'am-icon-ticket', 'control' => 'admin', 'action' => 'index', 'is_show' => 1, 'power' => 'vr_ticket-admin', 'url' => PluginsAdminUrl('vr_ticket', 'admin', 'index'), 'items' => [ [ 'id' => 'plugins-vr_ticket-venue', 'name' => '场馆配置', 'title' => '场馆配置', 'is_show' => 1, 'control' => 'admin', 'action' => 'VenueList', 'url' => PluginsAdminUrl('vr_ticket', 'admin', 'VenueList'), 'power' => 'vr_ticket-venueList', ], [ 'id' => 'plugins-vr_ticket-ticket', 'name' => '电子票', 'title' => '电子票', 'is_show' => 1, 'control' => 'admin', 'action' => 'TicketList', 'url' => PluginsAdminUrl('vr_ticket', 'admin', 'TicketList'), 'power' => 'vr_ticket-ticketList', ], [ 'id' => 'plugins-vr_ticket-ticketverify', 'name' => '扫码核销', 'title' => '扫码核销', 'is_show' => 1, 'control' => 'admin', 'action' => 'TicketVerify', 'url' => PluginsAdminUrl('vr_ticket', 'admin', 'TicketVerify'), 'power' => 'vr_ticket-ticketVerify', ], [ 'id' => 'plugins-vr_ticket-verifier', 'name' => '核销员', 'title' => '核销员', 'is_show' => 1, 'control' => 'admin', 'action' => 'VerifierList', 'url' => PluginsAdminUrl('vr_ticket', 'admin', 'VerifierList'), 'power' => 'vr_ticket-verifierList', ], [ 'id' => 'plugins-vr_ticket-varification', 'name' => '核销记录', 'title' => '核销记录', 'is_show' => 1, 'control' => 'admin', 'action' => 'VerificationList', 'url' => PluginsAdminUrl('vr_ticket', 'admin', 'VerificationList'), 'power' => 'vr_ticket-verificationList', ], [ 'id' => 'plugins-vr_ticket-setup', 'name' => '插件设置', 'title' => '插件设置', 'is_show' => 1, 'control' => 'admin', 'action' => 'Setup', 'url' => PluginsAdminUrl('vr_ticket', 'admin', 'Setup'), 'power' => 'vr_ticket-setup', ] ] ]; } /** * C端订单详情页注入票卡片 */ public function InjectTicketCard(&$params) { $order = $params['order'] ?? []; if (empty($order) || ($order['pay_status'] ?? 0) != 1) { return; } // 获取当前登录用户(ShopXO 标准方式) $user = \app\service\UserService::LoginUserInfo(); $userId = empty($user) ? null : $user['id']; if (empty($userId)) { return; } $tickets = \think\facade\Db::name('vr_tickets') ->where('order_id', $order['id']) ->select() ->toArray(); if (empty($tickets)) { return; } $token = session('user_token') ?: ''; $hostUrl = \think\facade\Config::get('shopxo.host_url'); $ticketCardsHtml = ''; foreach ($tickets as $ticket) { $shortCode = \app\plugins\vr_ticket\service\BaseService::shortCodeEncode($ticket['goods_id'], $ticket['id']); $statusMap = [0 => ['text' => '未核销', 'class' => 'unverified'], 1 => ['text' => '已核销', 'class' => 'verified'], 2 => ['text' => '已退款', 'class' => 'refunded']]; $status = $statusMap[$ticket['verify_status']] ?? $statusMap[0]; $ticketCardsHtml .= '
' . '
' . '
电子票
' . '
' . $status['text'] . '
' . '
' . '
' . '
💺' . htmlspecialchars($ticket['seat_info'] ?? '') . '
' . '
👤' . htmlspecialchars($ticket['real_name'] ?? '') . '
' . '
' . '' . '
'; } $style = ''; $ticketHtml = '
' . '
📋 我的电子票
' . $ticketCardsHtml . '
'; $params['page_data']['ticket_section'] = $ticketHtml; $params['page_data']['ticket_css'] = $style; // JS $js = ''; $params['page_data']['ticket_js'] = $js; } /** * 在用户中心底部挂载票夹入口链接 */ /** * C端下单前校验:票务商品数据完整性 + 停售时间校验 * * 两层校验(仅针对票务商品): * 1. 数据完整性:batch_number_expire 必须 > 0(演出日期必填),否则拒绝下单 * 2. 时效性:batch_expire_ts(演出前5分钟截止),已过期则拒绝下单 * * @param array $params 钩子参数 ['hook_name' => ..., 'data' => ..., 'order' => ..., 'goods' => ...] * @return array ['code' => 0|非0, 'msg' => ...] */ public function BuyCheck(&$params) { $goodsItems = $params['goods'] ?? []; if (empty($goodsItems)) { return ['code' => 0, 'msg' => '']; } // ────────────────────────────────────────────────────── // Step 1: 批量查询票务商品标识 // 提取所有 goods_id,一次性查询 vr_goods_config 非空的商品 // 构建 ticketGoodsMap {goods_id => has_config} // ────────────────────────────────────────────────────── $goodsIds = []; foreach ($goodsItems as $item) { $gid = intval($item['goods_id'] ?? 0); if ($gid > 0) { $goodsIds[] = $gid; } } $goodsIds = array_unique($goodsIds); $ticketGoodsMap = []; // goods_id => has_config (bool) if (!empty($goodsIds)) { $ticketGoods = Db::name('Goods') ->where('id', 'in', $goodsIds) ->where('vr_goods_config', '<>', '') ->field('id, batch_number_expire') ->select() ->toArray(); foreach ($ticketGoods as $tg) { $ticketGoodsMap[$tg['id']] = [ 'has_config' => true, 'batch_number_expire' => intval($tg['batch_number_expire'] ?? 0), ]; } } // ────────────────────────────────────────────────────── // Step 2: 逐商品校验 // ────────────────────────────────────────────────────── $now = time(); foreach ($goodsItems as $item) { $gid = intval($item['goods_id'] ?? 0); $isTicketGood = isset($ticketGoodsMap[$gid]) && $ticketGoodsMap[$gid]['has_config']; // 非票务商品:跳过 if (!$isTicketGood) { continue; } // --- 校验 1: batch_number_expire 数据完整性 --- // 优先用 DB 查询结果(最新);其次用订单 item 中的值 $dbBatchExpire = $ticketGoodsMap[$gid]['batch_number_expire'] ?? 0; if ($dbBatchExpire <= 0) { $itemTitle = $item['title'] ?? ('商品#' . $gid); return [ 'code' => -1, 'msg' => '「' . $itemTitle . '」未设置演出日期,暂时无法购买', ]; } // --- 校验 2: batch_expire_ts 停售时间 --- // 从 SKU extends 读取(SeatSkuService::BatchGenerate 已写入) $extends = $item['extends'] ?? []; if (empty($extends) || !is_array($extends)) { if (!empty($item['extends']) && is_string($item['extends'])) { $extends = json_decode($item['extends'], true); } if (empty($extends) || !is_array($extends)) { continue; // 无 extends 数据,跳过停售校验(放过) } } if (!isset($extends['batch_expire_ts'])) { continue; // 未设置停售时间,跳过 } $batchExpireTs = intval($extends['batch_expire_ts']); if ($batchExpireTs <= 0) { continue; } if ($now >= $batchExpireTs) { $sessionInfo = ''; if (!empty($extends['session_start']) && !empty($extends['session_end'])) { $sessionInfo = '(' . $extends['session_start'] . ' - ' . $extends['session_end'] . ')'; } return [ 'code' => -1, 'msg' => '该场次' . $sessionInfo . '距开场已不足5分钟,已停止售票,请选择其他场次', ]; } } // ────────────────────────────────────────────────────── // Step 3: 观影人信息校验(新增) // ────────────────────────────────────────────────────── // 1. 获取前端提交的 viewer_data $viewerData = $params['data']['viewer_data'] ?? []; if (!is_array($viewerData)) { $viewerData = []; } // 2. 获取商品配置中的观影人要求 $viewerConfig = null; foreach ($goodsItems as $item) { $gid = intval($item['goods_id'] ?? 0); if ($gid <= 0) continue; // 只查询票务商品 $isTicketGood = isset($ticketGoodsMap[$gid]) && $ticketGoodsMap[$gid]['has_config']; if (!$isTicketGood) continue; $goods = Db::name('Goods')->where('id', $gid)->field('vr_goods_config')->find(); if (!empty($goods['vr_goods_config'])) { $config = json_decode($goods['vr_goods_config'], true); if (is_array($config) && !empty($config[0]['viewer_config'])) { $viewerConfig = $config[0]['viewer_config']; break; } } } // 3. 如果商品要求观影人信息,进行校验 if (!empty($viewerConfig) && !empty($viewerConfig['require_viewer'])) { // 检查 viewer_data 是否存在且为数组 if (empty($viewerData)) { return [ 'code' => -1, 'msg' => '请填写观影人信息', ]; } // 阶段1:只支持单个观影人(require_viewer_per_seat=0) $perSeat = !empty($viewerConfig['require_viewer_per_seat']) ? $viewerConfig['require_viewer_per_seat'] : 0; if ($perSeat == 0) { if (count($viewerData) != 1) { return [ 'code' => -1, 'msg' => '当前商品只需填写一个观影人信息', ]; } } else { return [ 'code' => -1, 'msg' => '暂不支持每个座位独立填写观影人', ]; } // 4. 遍历校验每个观影人的字段 $viewer = $viewerData[0] ?? []; // 手机号校验 if (!empty($viewerConfig['require_viewer_mobile'])) { if (empty($viewer['mobile'])) { return [ 'code' => -1, 'msg' => '请填写观影人手机号', ]; } if (!preg_match('/^1[3-9]\d{9}$/', $viewer['mobile'])) { return [ 'code' => -1, 'msg' => '手机号格式不正确', ]; } } // 姓名校验 if (!empty($viewerConfig['require_viewer_name'])) { if (empty($viewer['name'])) { return [ 'code' => -1, 'msg' => '请填写观影人姓名', ]; } } // 身份证号校验(阶段1预留) if (!empty($viewerConfig['require_viewer_idcard'])) { if (empty($viewer['idcard'])) { return [ 'code' => -1, 'msg' => '请填写观影人身份证号', ]; } if (!preg_match('/^\d{17}[\dXx]$/', $viewer['idcard'])) { return [ 'code' => -1, 'msg' => '身份证号格式不正确', ]; } } } return ['code' => 0, 'msg' => '']; } 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 = '
' . '' . '🎫 我的电子票' . '
'; return $walletLink; } /** * 商品列表查询钩子 — 注入坐标城市筛选(仅vr_ticket票务商品) * 触发位置:plugins_service_goods_list_begin * * 向下兼容:无 user_lng/user_lat 参数时,不添加任何筛选,返回全部商品 * 修复:只对票务商品(vr_goods_config 非空)应用城市筛选,非票务商品不受影响 * * @param array &$params 钩子参数引用 ['hook_name','is_backend','params','where','field','order_by','m','n'] */ public function OnGoodsListBegin(&$params) { // [调试代码] 如需调试此钩子,可取消下方注释: // $debugFile = defined('ROOT') ? ROOT . 'runtime/debug_vr.log' : '/tmp/debug_vr.log'; // @file_put_contents($debugFile, date('Y-m-d H:i:s') . " OnGoodsListBegin called\n", FILE_APPEND); $paramsArr = $params['params'] ?? []; // 优先级1:显式传入的 city_id / cityid $cityId = isset($paramsArr['city_id']) ? intval($paramsArr['city_id']) : 0; if ($cityId <= 0) { $cityId = isset($paramsArr['cityid']) ? intval($paramsArr['cityid']) : 0; } // 优先级2:从坐标反推城市ID if ($cityId <= 0) { $userLng = isset($paramsArr['user_lng']) ? floatval($paramsArr['user_lng']) : 0; $userLat = isset($paramsArr['user_lat']) ? floatval($paramsArr['user_lat']) : 0; if ($userLng != 0 && $userLat != 0) { $cityId = \app\plugins\vr_ticket\service\GeoCityService::FindNearestCityId($userLng, $userLat); } } // 向下兼容:无坐标无city_id → 不筛选,返回全部 if ($cityId <= 0) { return; } // 修复:只对票务商品应用城市筛选 // 逻辑:(非票务商品 vr_goods_config='') OR (票务商品且城市匹配) $where = &$params['where']; if (!is_array($where)) { $where = []; } // 使用闭包实现 OR 条件:vr_goods_config 为空(非票务) OR produce_region = 城市ID $where[] = function($query) use ($cityId) { $query->where(function($q) { $q->where('g.vr_goods_config', '=', ''); })->whereOr(function($q) use ($cityId) { $q->where('g.vr_goods_config', '<>', '')->where('g.produce_region', '=', $cityId); }); }; // @file_put_contents($debugFile, date('Y-m-d H:i:s') . " OnGoodsListBegin: city filter applied to ticket goods only, cityId=$cityId\n", FILE_APPEND); } /** * 搜索列表查询钩子 — 注入坐标城市筛选(仅vr_ticket票务商品) * 触发位置:plugins_service_search_goods_list_begin * * 向下兼容:无 user_lng/user_lat 参数时,不添加任何筛选,返回全部商品 * 修复:只对票务商品(vr_goods_config 非空)应用城市筛选,非票务商品不受影响 * * @param array &$params 钩子参数引用 ['hook_name','is_backend','params','where_base','where_keywords','where_screening_price','field','order_by','page','page_start','page_size'] */ public function OnSearchListBegin(&$params) { // [调试代码] 如需调试此钩子,可取消下方注释: // $debugFile = defined('ROOT') ? ROOT . 'runtime/debug_vr.log' : '/tmp/debug_vr.log'; // @file_put_contents($debugFile, date('Y-m-d H:i:s') . " OnSearchListBegin called\n", FILE_APPEND); $paramsArr = $params['params'] ?? []; // 优先级1:显式传入的 city_id / cityid $cityId = isset($paramsArr['city_id']) ? intval($paramsArr['city_id']) : 0; if ($cityId <= 0) { $cityId = isset($paramsArr['cityid']) ? intval($paramsArr['cityid']) : 0; } // 优先级2:从坐标反推城市ID if ($cityId <= 0) { $userLng = isset($paramsArr['user_lng']) ? floatval($paramsArr['user_lng']) : 0; $userLat = isset($paramsArr['user_lat']) ? floatval($paramsArr['user_lat']) : 0; if ($userLng != 0 && $userLat != 0) { $cityId = \app\plugins\vr_ticket\service\GeoCityService::FindNearestCityId($userLng, $userLat); } } // 向下兼容:无坐标无city_id → 不筛选,返回全部 if ($cityId <= 0) { // [调试代码] @file_put_contents($debugFile, date('Y-m-d H:i:s') . " OnSearchListBegin: no cityId found, cityId=$cityId\n", FILE_APPEND); return; } // 修复:只对票务商品应用城市筛选 // 逻辑:(非票务商品 vr_goods_config='') OR (票务商品且城市匹配) // SearchService 使用 where_base 而非 where $whereBase = &$params['where_base']; if (!is_array($whereBase)) { $whereBase = []; } // 使用闭包实现 OR 条件:vr_goods_config 为空(非票务) OR produce_region = 城市ID $whereBase[] = function($query) use ($cityId) { $query->where(function($q) { $q->where('g.vr_goods_config', '=', ''); })->whereOr(function($q) use ($cityId) { $q->where('g.vr_goods_config', '<>', '')->where('g.produce_region', '=', $cityId); }); }; // [调试代码] @file_put_contents($debugFile, date('Y-m-d H:i:s') . " OnSearchListBegin: city filter applied to ticket goods only, cityId=$cityId\n", FILE_APPEND); } /** * 搜索结果后处理钩子 — 票务商品按 coding 合并 * 触发位置:plugins_service_search_goods_list_result * * 仅对 UniApp 端(H5 + 小程序)生效: * - H5: h5 * - 小程序: weixin, alipay, baidu, toutiao, qq, kuaishou * - App: ios, android * * 合并规则:以当前日期为准 * 1. 优先选择:最接近今天且 >= 今天 的那个 * 2. 备选:如果都过期了(< 今天),选择最接近今天的那一个 * * @param array &$params 钩子参数引用 ['hook_name','is_backend','params','data','total'] */ public function OnSearchListResult(&$params) { // 仅对 UniApp 端生效(排除 PC端) $uniapp_types = ['h5', 'weixin', 'alipay', 'baidu', 'toutiao', 'qq', 'kuaishou', 'ios', 'android']; if (!in_array(APPLICATION_CLIENT_TYPE, $uniapp_types)) { return; } $data = &$params['data']; if (empty($data) || !is_array($data)) { return; } // 今天日期(用于比较) $today = date('Y-m-d'); $todayTs = strtotime($today); // 用于记录每个 coding 对应的票务商品 // 结构: $ticketCodingMap[coding] = ['item' => array, 'diff' => int] // diff: 距今天的天数差(正值=未来,负值=过去) $ticketCodingMap = []; // 非票务商品直接保留 $nonTicketGoods = []; foreach ($data as $item) { $vrConfig = $item['vr_goods_config'] ?? ''; $coding = $item['coding'] ?? ''; if (!empty($vrConfig) && !empty($coding)) { // 票务商品:按 coding 合并 $batchExpire = $item['batch_number_expire'] ?? ''; // 计算与今天的差距 $diff = self::CalcDateDiffFromToday($batchExpire); if (!isset($ticketCodingMap[$coding])) { // 第一个记录,直接保存 $ticketCodingMap[$coding] = [ 'item' => $item, 'diff' => $diff, ]; } else { // 比较优先级: // 1. 优先选择 diff >= 0 中最小的(今天或未来的最近日期) // 2. 如果没有 diff >= 0,选择 diff < 0 中绝对值最小的(最近的过期日期) $existingDiff = $ticketCodingMap[$coding]['diff']; if ($this->IsBetterDateCandidate($diff, $existingDiff)) { $ticketCodingMap[$coding] = [ 'item' => $item, 'diff' => $diff, ]; } } } else { // 非票务商品直接保留 $nonTicketGoods[] = $item; } } // 合并结果:非票务商品 + 合并后的票务商品 $mergedData = []; foreach ($ticketCodingMap as $entry) { $mergedData[] = $entry['item']; } foreach ($nonTicketGoods as $item) { $mergedData[] = $item; } // 更新引用数据 $params['data'] = $mergedData; $params['total'] = count($mergedData); } /** * 判断新的日期差是否比现有的更好 * * 优先级规则: * 1. 优先选择 diff >= 0 中最小的(今天或未来的最近日期) * 2. 如果没有 diff >= 0,选择 diff < 0 中绝对值最小的(最近的过期日期) * * @param int $newDiff 新日期与今天的差距 * @param int $existingDiff 现有日期与今天的差距 * @return bool true 表示新日期更好,应该替换 */ private function IsBetterDateCandidate($newDiff, $existingDiff) { // 新日期是未来/今天,现有是过去 → 新更好 if ($newDiff >= 0 && $existingDiff < 0) { return true; } // 新日期是过去,现有是未来/今天 → 现有更好 if ($newDiff < 0 && $existingDiff >= 0) { return false; } // 同为未来/今天:选最小的(最近的未来日期) if ($newDiff >= 0 && $existingDiff >= 0) { return $newDiff < $existingDiff; } // 同为过去:选最大的(绝对值最小=最接近今天) if ($newDiff < 0 && $existingDiff < 0) { return $newDiff > $existingDiff; } return false; } /** * 计算日期与今天的差距(天数) * * @param string $dateStr 日期字符串 "Y-m-d" 格式 * @return int 天数差(正值=未来,负值=过去),空值返回 INF(最大正数,视为最远) */ private static function CalcDateDiffFromToday($dateStr) { if (empty($dateStr)) { return PHP_INT_MAX; // 空日期视为最远,不优先选择 } $dateTs = strtotime($dateStr); if ($dateTs === false) { return PHP_INT_MAX; } $todayTs = strtotime(date('Y-m-d')); return round(($dateTs - $todayTs) / 86400); } } ?>