# 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 排序**：必须与前端使用的排序规则一致，否则前缀匹配失败