0, 'msg' => $msg, 'data' => $data, ]; } private static function error(string $msg = '请求失败', int $code = -1) { return [ 'code' => $code, 'msg' => $msg, 'data' => [], ]; } /** * 获取热门推荐商品 * * GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=recommend */ public function recommend() { try { // 调用 ShopXO 商品服务获取热门商品 // VR票务插件的 category_id 需要在商品管理中设置 $params = [ 'is_new' => 0, 'is_recommend' => 1, 'is_error' => 0, 'is_delete_time' => 0, 'start' => 0, 'num' => 10, 'order_by' => 'sales', 'sort' => 'desc', ]; $result = GoodsService::GoodsList($params); $list = self::formatGoodsList($result); return self::success([ 'list' => $list, 'count' => count($list), ]); } catch (\Exception $e) { return self::error('获取推荐失败: ' . $e->getMessage()); } } /** * 获取商品列表 * * GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=lists * @param int city_id 城市ID筛选 * @param int page 页码 * @param int size 每页数量 */ public function lists() { try { $page = input('page', 1, 'intval'); $size = input('size', 10, 'intval'); $cityId = input('city_id', 0, 'intval'); if (empty($cityId)) { $cityId = input('cityid', 0, 'intval'); } $start = ($page - 1) * $size; $params = [ 'is_new' => 0, 'is_error' => 0, 'is_delete_time' => 0, 'start' => $start, 'num' => $size, 'order_by' => 'add_time', 'sort' => 'desc', ]; // 城市筛选(如果有设置produce_region) if (!empty($cityId)) { $params['produce_region'] = $cityId; } $result = GoodsService::GoodsList($params); $list = self::formatGoodsList($result); return self::success([ 'list' => $list, 'count' => count($list), 'page' => $page, 'size' => $size, ]); } catch (\Exception $e) { return self::error('获取列表失败: ' . $e->getMessage()); } } /** * 获取周边商品 * * GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=merchandise */ public function merchandise() { try { $page = input('page', 1, 'intval'); $size = input('size', 20, 'intval'); $start = ($page - 1) * $size; // 获取VR票务相关的周边商品(非票务类型) $params = [ 'is_new' => 0, 'is_error' => 0, 'is_delete_time' => 0, 'start' => $start, 'num' => $size, 'order_by' => 'sales', 'sort' => 'desc', // 可以根据实际情况添加商品分类筛选 ]; $result = GoodsService::GoodsList($params); $list = self::formatGoodsList($result); return self::success([ 'list' => $list, 'count' => count($list), ]); } catch (\Exception $e) { return self::error('获取周边商品失败: ' . $e->getMessage()); } } /** * 获取商品详情 * * GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=detail&id=X */ public function detail() { $goodsId = input('id', 0, 'intval'); if ($goodsId <= 0) { return self::error('参数错误:商品ID无效'); } try { $goods = GoodsService::GoodsDetail($goodsId); if (empty($goods)) { return self::error('商品不存在', -404); } return self::success([ 'goods' => self::formatGoodsDetail($goods), ]); } catch (\Exception $e) { return self::error('获取详情失败: ' . $e->getMessage()); } } /** * 搜索商品 * * GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=search&keyword=X */ public function search() { $keyword = input('keyword', '', 'trim'); $page = input('page', 1, 'intval'); $size = input('size', 10, 'intval'); if (empty($keyword)) { return self::error('请输入搜索关键词'); } try { $start = ($page - 1) * $size; $params = [ 'is_new' => 0, 'is_error' => 0, 'is_delete_time' => 0, 'start' => $start, 'num' => $size, 'title_like' => $keyword, 'order_by' => 'sales', 'sort' => 'desc', ]; $result = GoodsService::GoodsList($params); $list = self::formatGoodsList($result); return self::success([ 'list' => $list, 'count' => count($list), 'keyword' => $keyword, ]); } catch (\Exception $e) { return self::error('搜索失败: ' . $e->getMessage()); } } /** * 获取座位图(含实时库存) * * GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=seatmap&goods_id=118 * * @return array { code, msg, data: { seatSpecMap, goods_spec_data } } */ public function seatmap() { $goodsId = input('goods_id', 0, 'intval'); if ($goodsId <= 0) { return self::error('参数错误:goods_id 无效'); } try { $data = SeatMapService::GetSeatMap($goodsId); return self::success($data); } catch (\Exception $e) { return self::error('获取座位图失败: ' . $e->getMessage()); } } /** * 获取层级树 API(Tree API) * * 返回:tree + seat_templates_flat + flat_inventory * 支持参数化 group_by 指定层级顺序 * * GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=tree&goods_id=118&group_by=venue,session,room,section * * @return array { code, msg, data: { goods_id, group_by, tree, seat_templates_flat, flat_inventory, meta } } */ public function tree() { $goodsId = input('goods_id', 0, 'intval'); $groupByStr = input('group_by', 'venue,session,room,section', 'trim'); if ($goodsId <= 0) { return self::error('goods_id 无效'); } // 解析 group_by 参数 $groupBy = array_filter(array_map('trim', explode(',', $groupByStr))); if (empty($groupBy)) { return self::error('group_by 参数无效'); } // 允许的维度 $allowedDims = ['venue', 'session', 'room', 'section']; foreach ($groupBy as $dim) { if (!in_array($dim, $allowedDims)) { return self::error("不支持的维度: {$dim},允许: " . implode(',', $allowedDims)); } } try { // 缓存检查(注意:旧缓存不含 goods 字段,需要补充) $cacheKey = 'vr_tree_v4_' . $goodsId . '_' . md5(implode(',', $groupBy)); $cached = \think\facade\Cache::get($cacheKey); if ($cached !== null) { $cached['meta']['cache_hit'] = true; // 旧缓存不含 goods,补充构建 if (!isset($cached['goods'])) { $cached['goods'] = self::buildGoodsField($goodsId); } return self::success($cached); } // 读取数据源 $seatMapData = SeatMapService::GetSeatMap($goodsId); $seatSpecMap = $seatMapData['seatSpecMap'] ?? []; if (empty($seatSpecMap)) { return self::success([ 'goods_id' => $goodsId, 'group_by' => $groupBy, 'tree' => [], 'goods' => self::buildGoodsField($goodsId), 'seat_templates' => new \stdClass(), 'meta' => [ 'seat_count' => 0, 'template_count' => 0, 'cache_hit' => false, 'computed_at' => time(), ], ]); } // 调用 QueryManager 构建数据 $treeData = QueryManager::buildTree($goodsId, $groupBy, $seatSpecMap); $templateList = QueryManager::buildTemplatePool($goodsId, $treeData['template_keys'] ?? []); $seatTemplates = QueryManager::transformTemplatePool($templateList); // 提取场次元数据(从 SKU extends JSON,供前端场次选择控件使用:禁用判断 + 倒计时) $sessionMeta = self::extractSessionMeta($goodsId); // 组装响应 $result = [ 'goods_id' => $goodsId, 'group_by' => $groupBy, 'tree' => $treeData['tree'], 'goods' => self::buildGoodsField($goodsId), 'seat_templates' => $seatTemplates, 'session_meta' => $sessionMeta, 'peer_goods' => QueryManager::getPeerGoods($goodsId), 'meta' => [ 'seat_count' => $treeData['seat_count'] ?? 0, 'template_count' => count($seatTemplates), 'cache_hit' => false, 'computed_at' => time(), ], ]; // 写入缓存(TTL = 60s) \think\facade\Cache::set($cacheKey, $result, 60); return self::success($result); } catch (\Exception $e) { return self::error('获取层级树失败: ' . $e->getMessage()); } } /** * 构建 goods 字段(包含手机详情 content_app) * * 复用 GoodsService::GoodsList,传入 is_content_app=1 以包含手机端详情数据。 * 这使得 uniapp 可以仅调用 tree API 一次就获取完整数据,无需二次请求 goods/detail。 * * @param int $goodsId * @return array */ private static function buildGoodsField(int $goodsId): array { $isUseMobileDetail = intval(\MyC('common_app_is_use_mobile_detail', 0, true)); $params = [ 'where' => [ ['id', '=', $goodsId], ['is_delete_time', '=', 0], ], 'is_content_app' => $isUseMobileDetail, 'is_spec' => 1, 'is_params' => 1, 'is_favor' => 1, 'm' => 0, 'n' => 1, ]; $ret = GoodsService::GoodsList($params); $goods = $ret['data'][0] ?? null; if (empty($goods)) { return []; } // 手机端详情模式下移除 PC 富文本,减少响应体积 if ($isUseMobileDetail == 1) { unset($goods['content_web']); } return $goods; } /** * 格式化商品列表数据 */ private static function formatGoodsList($result) { $list = []; if (!empty($result)) { foreach ($result as $goods) { $list[] = [ 'id' => $goods['id'], 'title' => $goods['title'], 'image' => $goods['image'], 'price' => $goods['price'], 'original_price' => $goods['original_price'] ?? $goods['price'], 'sales' => $goods['sales'] ?? 0, 'stock' => $goods['stock'] ?? 0, 'venue' => isset($goods['produce_venue']) ? $goods['produce_venue'] : '', 'date' => isset($goods['batch_number_expire']) && $goods['batch_number_expire'] > 0 ? date('Y-m-d', $goods['batch_number_expire']) : '', 'add_time' => $goods['add_time'] ?? '', ]; } } return $list; } /** * 格式化商品详情数据 */ private static function formatGoodsDetail($goods) { return [ 'id' => $goods['id'], 'title' => $goods['title'], 'image' => $goods['image'], 'images' => !empty($goods['images']) ? explode(',', $goods['images']) : [$goods['image']], 'price' => $goods['price'], 'original_price' => $goods['original_price'] ?? $goods['price'], 'sales' => $goods['sales'] ?? 0, 'stock' => $goods['stock'] ?? 0, 'content' => htmlspecialchars_decode($goods['content'] ?? ''), 'spec_type' => $goods['spec_type'] ?? 0, 'spec_value_id' => $goods['spec_value_id'] ?? '', // 票务相关字段 'venue' => $goods['produce_venue'] ?? '', 'date' => isset($goods['batch_number_expire']) && $goods['batch_number_expire'] > 0 ? date('Y-m-d', $goods['batch_number_expire']) : '', 'time' => $goods['produce_time'] ?? '', 'region' => $goods['produce_region'] ?? '', 'add_time' => $goods['add_time'] ?? '', ]; } /** * 从 SKU 的 extends JSON 中提取场次元数据(去重) * * 供前端场次选择控件使用: * - 判断场次是否已过期(disabled) * - 显示停售倒计时 * * @param int $goodsId * @return array [['session' => '19:30-21:30', 'start' => '19:30', 'end' => '21:30', 'session_date' => '2026-05-18', 'session_datetime' => '2026-05-18 19:30:00', 'batch_expire_ts' => 1747567200], ...] */ private static function extractSessionMeta(int $goodsId): array { $specs = \think\facade\Db::name('GoodsSpecBase') ->where('goods_id', $goodsId) ->select() ->toArray(); if (empty($specs)) { return []; } $seen = []; $result = []; foreach ($specs as $spec) { $extends = json_decode($spec['extends'] ?? '{}', true); $session = $extends['session_start'] ?? ''; $sessionEnd = $extends['session_end'] ?? ''; if (empty($session)) { continue; } $sessionStr = "{$session}-{$sessionEnd}"; // 去重:同一场次只保留一条 if (isset($seen[$sessionStr])) { continue; } $seen[$sessionStr] = true; $result[] = [ 'session' => $sessionStr, 'start' => $extends['session_start'] ?? '', 'end' => $extends['session_end'] ?? '', 'session_date' => $extends['session_date'] ?? '', 'session_datetime' => $extends['session_datetime'] ?? '', 'batch_expire_ts' => intval($extends['batch_expire_ts'] ?? 0), ]; } // 按场次时间排序 usort($result, function ($a, $b) { return strcmp($a['session'], $b['session']); }); return $result; } }