220 lines
7.5 KiB
Markdown
220 lines
7.5 KiB
Markdown
# Task Brief:VR Tree API 实现
|
||
|
||
> 目标:实现参数化层级生成器 API
|
||
> 参考文档:`docs/14_TREE_API_DESIGN.md`
|
||
> 状态:规划完成,待实现
|
||
|
||
---
|
||
|
||
## 背景
|
||
|
||
当前 `SeatMapService::GetSeatMap()` 返回扁平 `seatSpecMap`,前端需要 O(n²) 重建层级,且 session 作为顶层不符合业务逻辑。
|
||
|
||
新 API `/api/goods/tree` 返回:
|
||
1. **层级树**(按 `group_by` 参数动态生成,叶节点含 `template_key`)
|
||
2. **模板去重池**(`seat_templates_flat`,每个模板只存一份)
|
||
3. **扁平 SKU 列表**(用于前端本地 spec_key 前缀匹配)
|
||
|
||
---
|
||
|
||
## 实现步骤
|
||
|
||
### Step 1:验证现有数据结构
|
||
|
||
**验证文件**:
|
||
- `service/SeatSkuService.php` — 确认 `SPEC_DIMS` 顺序和 `makeSpecKey` 算法
|
||
- `service/SeatMapService.php` — 确认 `buildSeatSpecMap` 输出结构
|
||
|
||
**验证内容**:
|
||
1. `spec_key` 格式:维度按固定顺序排序,用 `|` 连接
|
||
2. `template_key` 生成:当前代码中是否有 venue+room+section 组合的模板 key?格式是什么?
|
||
3. seat_specMap 中的 `inventory=0` 座位是否被过滤?
|
||
|
||
```php
|
||
// 在 ShopXO Docker 容器里验证
|
||
docker exec shopxo-php bash -c "php -r '
|
||
// 读取 goods_id=118 的 seatSpecMap
|
||
// 打印前 3 条记录的 spec_key 格式
|
||
// 打印 template_snapshot 的 venue+rooms 结构
|
||
'"
|
||
```
|
||
|
||
### Step 2:实现 QueryManager 服务
|
||
|
||
**文件**:`service/QueryManager.php`
|
||
|
||
**核心方法**:
|
||
```php
|
||
class QueryManager
|
||
{
|
||
// 主入口:生成层级树
|
||
public static function buildTree(int $goodsId, array $groupBy): array
|
||
|
||
// 分组聚合(核心逻辑)
|
||
private static function aggregate(array $flatInventory, array $groupBy): array
|
||
|
||
// 生成模板去重池
|
||
private static function buildTemplatePool(array $flatInventory, array $tree): array
|
||
|
||
// 生成扁平 SKU 列表(用于前端本地筛选)
|
||
private static function buildFlatInventory(array $seatSpecMap): array
|
||
}
|
||
```
|
||
|
||
**聚合逻辑**:
|
||
```
|
||
输入:flatInventory (seatSpecMap), groupBy = ['venue', 'session', 'room', 'section']
|
||
输出:层级树
|
||
|
||
遍历每个 SKU:
|
||
1. 解析 spec_key,提取各维度值
|
||
2. 按 groupBy 顺序构建嵌套路径(如 tree[venue][session][room][section])
|
||
3. 累加 inventory,计算 min_price
|
||
4. 生成 template_key = venue_venueName_room_roomName_section_sectionChar
|
||
5. 标记 has_available = 累计 inventory > 0
|
||
|
||
模板去重:
|
||
每个 section 节点只存 template_key,不存完整模板
|
||
模板池在最后统一挂载(从 vr_seat_templates 表读取,或从 seatMap 数据提取)
|
||
```
|
||
|
||
### Step 3:实现 API 接口
|
||
|
||
**文件**:`api/Goods.php`
|
||
|
||
```php
|
||
/**
|
||
* 获取层级树(含模板去重池 + 扁平 SKU)
|
||
* GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=tree&goods_id=118&group_by=venue,session,room,section
|
||
*/
|
||
public function tree()
|
||
{
|
||
$goodsId = input('goods_id', 0, 'intval');
|
||
$groupBy = input('group_by', 'venue,session,room,section', 'trim'); // 默认 venue-first
|
||
$groupBy = array_filter(array_map('trim', explode(',', $groupBy)));
|
||
|
||
if ($goodsId <= 0) {
|
||
return ['code' => -1, 'msg' => 'goods_id 无效'];
|
||
}
|
||
|
||
// 缓存 key:goods_id + group_by 组合
|
||
$cacheKey = 'vr_tree_' . $goodsId . '_' . md5(implode(',', $groupBy));
|
||
$cached = \think\facade\Cache::get($cacheKey);
|
||
if ($cached !== null) {
|
||
$data = $cached;
|
||
$data['meta']['cache_hit'] = true;
|
||
return ['code' => 0, 'msg' => 'success', 'data' => $data];
|
||
}
|
||
|
||
// 1. 读取现有 seatSpecMap(复用 SeatMapService)
|
||
$seatSpecMap = SeatMapService::GetSeatMap($goodsId)['seatSpecMap'] ?? [];
|
||
|
||
// 2. 调用 QueryManager 构建树
|
||
$tree = QueryManager::buildTree($goodsId, $groupBy, $seatSpecMap);
|
||
|
||
// 3. 构建模板去重池
|
||
$templates = QueryManager::buildTemplatePool($goodsId, $tree);
|
||
|
||
// 4. 构建扁平 SKU 列表
|
||
$flatInventory = QueryManager::buildFlatInventory($seatSpecMap);
|
||
|
||
// 5. 组装响应
|
||
$data = [
|
||
'goods_id' => $goodsId,
|
||
'group_by' => $groupBy,
|
||
'tree' => $tree,
|
||
'seat_templates_flat' => $templates,
|
||
'flat_inventory' => $flatInventory,
|
||
'meta' => [
|
||
'flat_count' => count($flatInventory),
|
||
'template_count' => count($templates),
|
||
'cache_hit' => false,
|
||
'computed_at' => time(),
|
||
],
|
||
];
|
||
|
||
// 6. 写入缓存(TTL = 60s)
|
||
\think\facade\Cache::set($cacheKey, $data, 60);
|
||
|
||
return ['code' => 0, 'msg' => 'success', 'data' => $data];
|
||
}
|
||
```
|
||
|
||
### Step 4:模板去重池生成逻辑
|
||
|
||
```php
|
||
private static function buildTemplatePool(int $goodsId, array $tree): array
|
||
{
|
||
$pool = [];
|
||
|
||
// 遍历 tree,找到所有 template_key
|
||
// 从 vr_seat_templates 表读取对应模板数据
|
||
// key = template_key(如 "鸟巢_主厅_A")
|
||
// value = 模板的 map + sections + seats
|
||
|
||
// 或者从 seatMap 数据直接提取(如果模板数据已在 seatMap 中)
|
||
// 取决于当前数据结构中模板数据的存储位置
|
||
|
||
return $pool;
|
||
}
|
||
```
|
||
|
||
**关键**:模板只按 `venue + "_" + room + "_" + section` 存储一份,不管有多少个 session。
|
||
|
||
### Step 5:前端适配(ticket_detail.html)
|
||
|
||
**改动点**:
|
||
1. 初始请求改用 `/api/goods/tree`(替换现有的 `GetGoodsViewData` PHP 渲染)
|
||
2. 从返回中提取 `tree` + `seat_templates_flat` + `flat_inventory`
|
||
3. 用 `tree` 渲染选择器(venue → session → room → section)
|
||
4. 用户选到 section 时:
|
||
- 查 `tree[venue][session][room][section]['template_key']`
|
||
- 查 `seat_templates_flat[template_key]` 获取座位图
|
||
- 用 `flat_inventory` 做 spec_key 前缀匹配,标记可选座位
|
||
|
||
**spec_key 前缀匹配(前端 JS)**:
|
||
```javascript
|
||
function filterSeatsBySpec(flatInventory, selections) {
|
||
// selections = { venue: '鸟巢', session: '15:00-16:59', room: '主厅', section: 'A' }
|
||
// 构造前缀
|
||
const prefix = '$vr-场馆=' + selections.venue +
|
||
'|$vr-场次=' + selections.session +
|
||
'|$vr-演播室=' + selections.room +
|
||
'|$vr-分区=' + selections.section + '|';
|
||
|
||
return flatInventory.filter(sku =>
|
||
sku.spec_key.startsWith(prefix) && sku.inventory > 0
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 验证清单
|
||
|
||
实现完成后,用 curl 测试:
|
||
|
||
```bash
|
||
# 测试 venue-first(Joery 的场景)
|
||
curl "http://localhost:10000/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=tree&goods_id=118&group_by=venue,session,room,section" \
|
||
-H "X-Requested-With: XMLHttpRequest" | python3 -c "
|
||
import json,sys
|
||
d = json.load(sys.stdin)['data']
|
||
print('flat_count:', d['meta']['flat_count'])
|
||
print('template_count:', d['meta']['template_count'])
|
||
print('venues:', list(d['tree'].keys()))
|
||
"
|
||
|
||
# 测试 session-first
|
||
curl "http://localhost:10000/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=tree&goods_id=118&group_by=session,venue,room,section" ...
|
||
```
|
||
|
||
---
|
||
|
||
## 注意事项
|
||
|
||
1. **不要删除现有代码**(SeatMapService / SeatSkuService),它们是数据源,新 API 依赖它们
|
||
2. **模板去重**:确保同一个 venue+room+section 的模板只出现一次在 `seat_templates_flat` 里
|
||
3. **缓存失效**:订单支付成功后调用 `SeatMapService::ClearCache()` 时,同时清除 tree 缓存
|
||
4. **inventory=0 的座位**:需要确认是否包含在 `flat_inventory` 里(前端的座位图需要知道哪些是已售的)
|
||
5. **spec_key 排序**:必须与前端使用的排序规则一致,否则前缀匹配失败 |