vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/Hook.php

766 lines
34 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace app\plugins\vr_ticket;
require_once __DIR__ . '/service/BaseService.php';
use app\plugins\vr_ticket\service\BaseService;
use app\plugins\vr_ticket\service\TicketService;
use think\facade\Db;
class Hook
{
public function handle($params = [])
{
if(!empty($params['hook_name']))
{
$ret = '';
switch($params['hook_name'])
{
// 后台左侧菜单钩子
case 'plugins_service_admin_menu_data':
$this->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 .= '<div class="vr-ticket-card" data-ticket-id="' . $ticket['id'] . '">' .
'<div class="vr-ticket-card-header">' .
'<div class="vr-ticket-goods-title">电子票</div>' .
'<div class="vr-ticket-status ' . $status['class'] . '">' . $status['text'] . '</div>' .
'</div>' .
'<div class="vr-ticket-info">' .
'<div class="vr-ticket-info-row"><span class="vr-ticket-info-icon">💺</span><span>' . htmlspecialchars($ticket['seat_info'] ?? '') . '</span></div>' .
'<div class="vr-ticket-info-row"><span class="vr-ticket-info-icon">👤</span><span>' . htmlspecialchars($ticket['real_name'] ?? '') . '</span></div>' .
'</div>' .
'<div class="vr-ticket-footer">' .
'<div class="vr-ticket-short-code">短码: ' . htmlspecialchars($shortCode) . '</div>' .
'<a href="javascript:;" class="vr-ticket-view-btn" onclick="VrTicketWallet.viewTicket(' . $ticket['id'] . ')">查看票码 →</a>' .
'</div>' .
'</div>';
}
$style = '<style>
.vr-ticket-card { background: #fff; border-radius: 12px; padding: 16px; margin: 12px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.06); cursor: pointer; }
.vr-ticket-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
.vr-ticket-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.vr-ticket-goods-title { font-size: 16px; font-weight: 600; color: #333; }
.vr-ticket-status { font-size: 12px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
.vr-ticket-status.unverified { background: #e6f7ff; color: #1890ff; }
.vr-ticket-status.verified { background: #f6ffed; color: #52c41a; }
.vr-ticket-status.refunded { background: #fff1f0; color: #ff4d4f; }
.vr-ticket-info { font-size: 13px; color: #666; line-height: 1.6; }
.vr-ticket-info-row { display: flex; align-items: center; margin-bottom: 4px; }
.vr-ticket-info-icon { width: 16px; color: #999; margin-right: 6px; }
.vr-ticket-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; padding-top: 12px; border-top: 1px solid #f0f0f0; }
.vr-ticket-short-code { font-size: 14px; font-family: "Courier New", monospace; color: #333; font-weight: 600; letter-spacing: 1px; }
.vr-ticket-view-btn { font-size: 13px; color: #1890ff; text-decoration: none; }
.vr-ticket-view-btn:hover { text-decoration: underline; }
</style>';
$ticketHtml = '<div class="vr-order-ticket-section">' .
'<div style="font-size:16px;font-weight:600;margin-bottom:12px;">📋 我的电子票</div>' .
$ticketCardsHtml .
'</div>';
$params['page_data']['ticket_section'] = $ticketHtml;
$params['page_data']['ticket_css'] = $style;
// JS
$js = '<script>
(function() {
var apiBase = "' . $hostUrl . '/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=";
var token = "' . htmlspecialchars($token) . '";
window.VrTicketWallet = {
viewTicket: function(ticketId) {
var modal = document.getElementById("vrTicketModal") || createModal();
modal.classList.add("active");
var body = document.getElementById("vrTicketModalBody");
body.innerHTML = \'<div style="text-align:center;padding:40px;">加载中...</div>\';
$.ajax({
url: apiBase + "detail&id=" + ticketId,
headers: token ? {"X-Token": token} : {},
success: function(res) {
if (res.code === 0 && res.data.ticket) {
var t = res.data.ticket;
var statusMap = {0:{text:"未核销",class:"unverified"},1:{text:"已核销",class:"verified"},2:{text:"已退款",class:"refunded"}};
var status = statusMap[t.verify_status] || statusMap[0];
body.innerHTML = \'<div style="text-align:center;padding:20px;background:#fafafa;border-radius:12px;"><div id="vrQrcodeBox"></div></div>\' +
\'<div style="text-align:center;margin:16px 0;padding:12px;background:#f5f5f5;border-radius:8px;">\' +
\'<div style="font-size:12px;color:#999;margin-bottom:4px;">短码(人工核销)</div>\' +
\'<div style="font-size:20px;font-family:monospace;font-weight:700;letter-spacing:2px;">\' + t.short_code + \'</div></div>\' +
\'<div style="text-align:center;"><span class="vr-ticket-status \' + status.class + \'">\' + status.text + \'</span></div>\';
if (t.qr_payload) {
$("#vrQrcodeBox").qrcode({text: atob(t.qr_payload), width: 180, height: 180});
}
}
}
});
},
closeModal: function() {
var modal = document.getElementById("vrTicketModal");
if (modal) modal.classList.remove("active");
}
};
function createModal() {
var html = \'<div id="vrTicketModal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:none;align-items:center;justify-content:center;">\' +
\'<div style="background:#fff;border-radius:16px;width:90%;max-width:400px;padding:24px;"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">\' +
\'<div style="font-size:18px;font-weight:600;">电子票</div><button onclick="VrTicketWallet.closeModal()" style="width:28px;height:28px;border-radius:50%;background:#f0f0f0;border:none;cursor:pointer;">×</button></div>\' +
\'<div id="vrTicketModalBody"></div></div></div>\';
document.body.insertAdjacentHTML("beforeend", html);
var modal = document.getElementById("vrTicketModal");
modal.addEventListener("click", function(e) { if (e.target === modal) VrTicketWallet.closeModal(); });
return modal;
}
})();
</script>';
$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 = '<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;
}
/**
* 商品列表查询钩子 — 注入坐标城市筛选仅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);
}
}
?>