feat(seatmap-api): 新增 SeatMapService + seatmap action + 支付后清除缓存

- 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
feat/b-verification-clean
Council 2026-05-14 12:58:05 +08:00
parent ff5e80df22
commit d0176eeeff
3 changed files with 639 additions and 0 deletions

View File

@ -0,0 +1,300 @@
<?php
/**
* VR票务插件 - C端商品API控制器
*
* 路由: /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=xxx
*
* @package vr_ticket\api
*/
namespace app\plugins\vr_ticket\api;
use app\plugins\vr_ticket\service\SeatMapService;
use app\service\GoodsService;
/**
* C端商品 API
*/
class Goods
{
private static function success($data = [], string $msg = 'success')
{
return [
'code' => 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'] ?? '',
];
}
}

View File

@ -0,0 +1,334 @@
<?php
namespace app\plugins\vr_ticket\service;
use think\facade\Db;
/**
* VR票务 - 座位图服务UniApp seatmap API 专用)
*
* 提供 seatSpecMap含实时库存+ goods_spec_data
* 模板快照走 ShopXO CacheTTL 60s库存实时读 DB
*
* @package vr_ticket\service
*/
class SeatMapService
{
/**
* 缓存 key 前缀
*/
const CACHE_KEY_PREFIX = 'vr_seatmap_';
/**
* 模板快照缓存 TTL
*/
const CACHE_TTL = 60;
/**
* 获取座位图完整数据(含实时库存)
*
* @param int $goodsId
* @return array [
* 'seatSpecMap' => [...], // 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 CacheTTL 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. 构建 seatSpecMapseatKey → 完整规格
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;
}
}

View File

@ -121,6 +121,11 @@ class TicketService extends BaseService
'tickets_issued' => $count, 'tickets_issued' => $count,
]); ]);
// 清除座位图缓存(确保下一位买家看到最新库存)
if ($count > 0) {
SeatMapService::ClearCache(intval($goodsId));
}
return $count > 0; return $count > 0;
} }