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 .= '
' .
'' .
'
' .
'
💺' . 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);
}
}
?>