455 lines
15 KiB
PHP
455 lines
15 KiB
PHP
<?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\plugins\vr_ticket\service\QueryManager;
|
||
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());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取层级树 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 {
|
||
// 缓存检查
|
||
$cacheKey = 'vr_tree_v4_' . $goodsId . '_' . md5(implode(',', $groupBy));
|
||
$cached = \think\facade\Cache::get($cacheKey);
|
||
if ($cached !== null) {
|
||
$cached['meta']['cache_hit'] = true;
|
||
return self::success($cached);
|
||
}
|
||
|
||
// 读取数据源
|
||
$seatMapData = SeatMapService::GetSeatMap($goodsId);
|
||
$seatSpecMap = $seatMapData['seatSpecMap'] ?? [];
|
||
|
||
if (empty($seatSpecMap)) {
|
||
return self::success([
|
||
'goods_id' => $goodsId,
|
||
'group_by' => $groupBy,
|
||
'tree' => [],
|
||
'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'],
|
||
'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());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 格式化商品列表数据
|
||
*/
|
||
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;
|
||
}
|
||
}
|