From d0176eeeff7449222a7c90f2305ad64984d1a200 Mon Sep 17 00:00:00 2001 From: Council Date: Thu, 14 May 2026 12:58:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(seatmap-api):=20=E6=96=B0=E5=A2=9E=20SeatM?= =?UTF-8?q?apService=20+=20seatmap=20action=20+=20=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E5=90=8E=E6=B8=85=E9=99=A4=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SeatMapService.php: GetSeatMap() 含实时 inventory,模板快照 Cache TTL 60s - api/Goods.php: 新增 seatmap action,调用 SeatMapService::GetSeatMap() - TicketService.php: onOrderPaid 成功后调用 SeatMapService::ClearCache() - 座位图 API: GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=seatmap&goods_id=X --- shopxo/app/plugins/vr_ticket/api/Goods.php | 300 ++++++++++++++++ .../vr_ticket/service/SeatMapService.php | 334 ++++++++++++++++++ .../vr_ticket/service/TicketService.php | 5 + 3 files changed, 639 insertions(+) create mode 100644 shopxo/app/plugins/vr_ticket/api/Goods.php create mode 100644 shopxo/app/plugins/vr_ticket/service/SeatMapService.php diff --git a/shopxo/app/plugins/vr_ticket/api/Goods.php b/shopxo/app/plugins/vr_ticket/api/Goods.php new file mode 100644 index 0000000..0dbb8bd --- /dev/null +++ b/shopxo/app/plugins/vr_ticket/api/Goods.php @@ -0,0 +1,300 @@ + 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()); + } + } + + /** + * 格式化商品列表数据 + */ + 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['produce_date']) ? $goods['produce_date'] : '', + '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' => $goods['produce_date'] ?? '', + 'time' => $goods['produce_time'] ?? '', + 'region' => $goods['produce_region'] ?? '', + 'add_time' => $goods['add_time'] ?? '', + ]; + } +} diff --git a/shopxo/app/plugins/vr_ticket/service/SeatMapService.php b/shopxo/app/plugins/vr_ticket/service/SeatMapService.php new file mode 100644 index 0000000..f6731c1 --- /dev/null +++ b/shopxo/app/plugins/vr_ticket/service/SeatMapService.php @@ -0,0 +1,334 @@ + [...], // seatKey → {inventory, price, spec, ...} + * 'goods_spec_data' => [...], // 场次列表(含最低价) + * ] + */ + public static function GetSeatMap(int $goodsId): array + { + // 1. 读取 vr_goods_config(数组,取第一项) + $vrConfigRaw = Db::name('Goods') + ->where('id', $goodsId) + ->value('vr_goods_config'); + + $configs = json_decode($vrConfigRaw ?? '', true); + if (empty($configs) || !is_array($configs)) { + return ['seatSpecMap' => [], 'goods_spec_data' => []]; + } + $config = $configs[0]; + + $templateId = intval($config['template_id'] ?? 0); + if ($templateId <= 0) { + return ['seatSpecMap' => [], 'goods_spec_data' => []]; + } + + // 2. 获取座位模板(含 rooms[] / sections[] / map[]) + $seatTemplate = self::getSeatTemplate($templateId); + if (empty($seatTemplate)) { + return ['seatSpecMap' => [], 'goods_spec_data' => []]; + } + + // 3. 构建 seatSpecMap(含 inventory,实时读 DB,包含已售) + $seatSpecMap = self::buildSeatSpecMap($goodsId, $seatTemplate); + + // 4. 构建场次列表(从 sessions[] + seatSpecMap 合并最低价) + $goodsSpecData = self::buildGoodsSpecData($config['sessions'] ?? [], $seatSpecMap); + + return [ + 'seatSpecMap' => $seatSpecMap, + 'goods_spec_data' => $goodsSpecData, + ]; + } + + /** + * 清除座位图缓存(订单支付成功后调用) + * + * @param int $goodsId + * @return bool + */ + public static function ClearCache(int $goodsId): bool + { + return \think\facade\Cache::delete(self::CACHE_KEY_PREFIX . $goodsId); + } + + // ───────────────────────────────────────────────────────── + // 私有方法 + // ───────────────────────────────────────────────────────── + + /** + * 获取座位模板(走 ShopXO Cache,TTL 60s) + * + * @param int $templateId + * @return array|null + */ + private static function getSeatTemplate(int $templateId) + { + $cacheKey = self::CACHE_KEY_PREFIX . $templateId; + $cached = \think\facade\Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + $row = Db::name('vr_seat_templates')->find($templateId); + if (empty($row)) { + return null; + } + + $seatMap = json_decode($row['seat_map'] ?? '{}', true); + if (empty($seatMap)) { + return null; + } + + // 缓存(TTL = self::CACHE_TTL) + \think\facade\Cache::set($cacheKey, $seatMap, self::CACHE_TTL); + + return $seatMap; + } + + /** + * 构建座位规格映射表(含 inventory,实时读 DB) + * + * 遍历所有 GoodsSpecBase(含 inventory=0 的已售座位), + * 与 GoodsSpecType + GoodsSpecValue 关联, + * 输出 seatSpecMap。 + * + * @param int $goodsId + * @param array $seatTemplate seat_map JSON(已解析) + * @return array seatSpecMap + */ + private static function buildSeatSpecMap(int $goodsId, array $seatTemplate): array + { + $seatSpecMap = []; + + // 1. 查询当前商品所有 GoodsSpecBase(不过滤 inventory,获取所有座位含已售) + $specs = Db::name('GoodsSpecBase') + ->where('goods_id', $goodsId) + ->select() + ->toArray(); + + if (empty($specs)) { + return $seatSpecMap; + } + + // 2. 查询 GoodsSpecType 获取维度映射(name → index) + $specTypes = Db::name('GoodsSpecType') + ->where('goods_id', $goodsId) + ->order('id', 'asc') + ->select() + ->toArray(); + + $dimIndexByName = []; + $dimValuesByName = []; // name → [value1, value2, ...] + foreach ($specTypes as $idx => $type) { + $dimName = $type['name'] ?? ''; + if (!empty($dimName)) { + $dimIndexByName[$dimName] = $idx; + $values = json_decode($type['value'] ?? '[]', true); + $dimValuesByName[$dimName] = []; + foreach ($values as $v) { + if (isset($v['name'])) { + $dimValuesByName[$dimName][] = $v['name']; + } + } + } + } + + // 3. 查询每个 spec_base_id 对应的 GoodsSpecValue + $specBaseIds = array_column($specs, 'id'); + $specValues = Db::name('GoodsSpecValue') + ->whereIn('goods_spec_base_id', $specBaseIds) + ->select() + ->toArray(); + + // 4. 按 spec_base_id 分组,通过值匹配找到维度名 + $specByBaseId = []; + foreach ($specValues as $sv) { + $baseId = $sv['goods_spec_base_id']; + $value = $sv['value'] ?? ''; + + $dimName = ''; + foreach ($dimValuesByName as $name => $values) { + if (in_array($value, $values)) { + $dimName = $name; + break; + } + } + + $specByBaseId[$baseId][] = [ + 'type' => $dimName, + 'value' => $value, + ]; + } + + // 5. 解析座位模板中的 room 信息(用于提取 rowLabel, colNum, section 等) + $rooms = $seatTemplate['rooms'] ?? []; + $roomSeatInfo = []; // roomId → [rowLabel_colNum → [...]] + foreach ($rooms as $rIdx => $room) { + $roomId = !empty($room['id']) ? $room['id'] : ('room_' . $rIdx); + $sections = $room['sections'] ?? []; + $map = $room['map'] ?? []; + $seatsData = $room['seats'] ?? []; + + foreach ($map as $rowIndex => $rowStr) { + $rowLabel = chr(65 + $rowIndex); + $chars = preg_split('//u', $rowStr, -1, PREG_SPLIT_NO_EMPTY); + + foreach ($chars as $colIndex => $char) { + if ($char === '_' || $char === '-' || !isset($seatsData[$char])) { + continue; + } + $colNum = $colIndex + 1; + + $sectionInfo = null; + foreach ($sections as $sec) { + if (($sec['char'] ?? '') === $char) { + $sectionInfo = $sec; + break; + } + } + + $roomSeatInfo[$roomId][$rowLabel . '_' . $colNum] = [ + 'rowLabel' => $rowLabel, + 'colNum' => $colNum, + 'section' => $sectionInfo, + 'char' => $char, + ]; + } + } + } + + // 6. 构建 seatSpecMap:seatKey → 完整规格 + foreach ($specs as $spec) { + $extends = json_decode($spec['extends'] ?? '{}', true); + $seatKey = $extends['seat_key'] ?? ''; + if (empty($seatKey)) continue; + + // 解析 seatKey 格式:roomId_rowLabel_colNum + $parts = explode('_', $seatKey); + if (count($parts) < 3) continue; + $roomId = $parts[0]; + $rowLabel = $parts[1]; + $colNum = intval($parts[2]); + + // 提取各维度值 + $venueName = ''; + $sectionName = ''; + $seatName = ''; + $sessionName = ''; + $roomName = ''; + foreach ($specByBaseId[$spec['id']] ?? [] as $specItem) { + $specType = $specItem['type'] ?? ''; + $specVal = $specItem['value'] ?? ''; + switch ($specType) { + case '$vr-场次': + $sessionName = $specVal; + break; + case '$vr-场馆': + $venueName = $specVal; + break; + case '$vr-演播室': + $roomName = $specVal; + break; + case '$vr-分区': + $sectionName = $specVal; + break; + case '$vr-座位号': + $seatName = $specVal; + break; + } + } + + $seatMeta = $roomSeatInfo[$roomId][$rowLabel . '_' . $colNum] ?? [ + 'rowLabel' => $rowLabel, + 'colNum' => $colNum, + 'section' => null, + 'char' => '', + ]; + + $seatSpecMap[$seatKey] = [ + 'spec_base_id' => intval($spec['id']), + 'price' => floatval($spec['price']), + 'inventory' => intval($spec['inventory']), // ← 关键字段(0=已售) + 'spec' => $specByBaseId[$spec['id']] ?? [], + 'rowLabel' => $seatMeta['rowLabel'], + 'colNum' => $seatMeta['colNum'], + 'roomId' => $roomId, + 'roomName' => $roomName, + 'section' => $seatMeta['section'], + 'venueName' => $venueName, + 'sectionName' => $sectionName, + 'seatName' => $seatName, + 'sessionName' => $sessionName, + ]; + } + + return $seatSpecMap; + } + + /** + * 构建 goods_spec_data(场次列表,含最低价) + * + * @param array $sessions vr_goods_config.sessions[] + * @param array $seatSpecMap + * @return array + */ + private static function buildGoodsSpecData(array $sessions, array $seatSpecMap): array + { + if (empty($sessions)) { + return []; + } + + $result = []; + foreach ($sessions as $session) { + $specName = ($session['start'] ?? '') . '-' . ($session['end'] ?? ''); + $minPrice = PHP_FLOAT_MAX; + + // 从 seatSpecMap 中找该场次的最低价 + foreach ($seatSpecMap as $info) { + if (($info['sessionName'] ?? '') === $specName) { + $p = $info['price'] ?? PHP_FLOAT_MAX; + if ($p < $minPrice) { + $minPrice = $p; + } + } + } + + $result[] = [ + 'spec_name' => $specName, + 'price' => $minPrice < PHP_FLOAT_MAX ? $minPrice : 0, + 'start' => $session['start'] ?? '', + 'end' => $session['end'] ?? '', + ]; + } + + return $result; + } +} \ No newline at end of file diff --git a/shopxo/app/plugins/vr_ticket/service/TicketService.php b/shopxo/app/plugins/vr_ticket/service/TicketService.php index 028b187..8a46463 100644 --- a/shopxo/app/plugins/vr_ticket/service/TicketService.php +++ b/shopxo/app/plugins/vr_ticket/service/TicketService.php @@ -121,6 +121,11 @@ class TicketService extends BaseService 'tickets_issued' => $count, ]); + // 清除座位图缓存(确保下一位买家看到最新库存) + if ($count > 0) { + SeatMapService::ClearCache(intval($goodsId)); + } + return $count > 0; }