docs: add VR Tree API design docs (2026-05-15)

- 14_TREE_API_DESIGN.md: complete design document with flat inventory + query manager architecture
- 15_FLAT_INVENTORY_QUERY_MANAGER.md: scheme comparison and key decisions
- TASK_TREE_API_IMPLEMENTATION.md: implementation task list for antigravity
- HANDOFF_TREE_API.md: task handoff brief

Key design decisions:
- template_key deduplication (venue+room+section, not per-session)
- group_by parameter for frontend to specify hierarchy order
- flat_inventory returned in full for client-side spec_key prefix matching
- zero additional API calls during seat selection flow
feat/b-verification-clean
Council 2026-05-15 18:47:31 +08:00
parent d0176eeeff
commit 40a9b0ad1d
4 changed files with 862 additions and 0 deletions

398
docs/14_TREE_API_DESIGN.md Normal file
View File

@ -0,0 +1,398 @@
# VR 票务 API 重构方案:参数化层级生成器
> 讨论日期2026-05-15
> 参与:西莉雅(统筹)、大头(产品决策)
> 状态:**规划中,待实现**
---
## 一、问题背景
### 现有 API 的局限
当前 `SeatMapService` 提供两个数据视图:
| 接口 | 数据结构 | 问题 |
|------|---------|------|
| `GET /seatmap` | `seatSpecMap`(扁平 key→value | 前端需要自己重建层级,计算成本高 |
| `GetGoodsViewData` (PHP) | `seatSpecMap` + `specTypeList` + `vr_seat_template` | 前端仍需大量客户端逻辑,且 session 被放在顶层 |
**核心问题**
1. 前端重建层级需要全量扫描 seatSpecMap时间复杂度 O(n²)
2. session 作为顶层维度不符合实际业务逻辑(场次是场馆的子节点)
3. 座位模板数据在不同场次间重复嵌入,造成大量冗余
4. 前端需要同时维护 specTypeList + seatSpecMap + seatMap 三套数据,复杂度高
### 业务需求
- **场次是场馆的子节点**:不同场馆可设置不同场次(某些只有 ABC某些只有 CDF场次由合作方电影院自主决定
- **未来重播场景**:院线方像排片一样自行安排重映场次
- **多模板商品**:一个商品下多个场馆,每场馆多场次,每场次多演播室/分区/座位
- **模板去重**:同一分区的座位模板在所有场次下共享,不应重复嵌入
---
## 二、设计目标
1. **后端生成层级结构**:前端只需渲染,不需计算
2. **参数化接口**:前端通过 `group_by` 参数指定想要的层级顺序
3. **模板去重**:座位模板按 `template_key` 只存一份,前端按需引用
4. **可缓存**:相同参数的请求直接返回缓存结果
5. **相辅相成**:底层查询管理器同时支持实时库存查询
---
## 三、数据结构
### 3.1 扁平数据层Flat Inventory
所有 SKU 以 `spec_key` 为索引,**不预设任何层级**
```
vr_sku_flat:
goods_id | spec_key | price | inventory | seat_key | roomId | sectionChar | ...
spec_key 格式(各维度按固定顺序排序,用 | 连接):
"$vr-场次=15:00-16:59|$vr-场馆=鸟巢|$vr-演播室=主厅|$vr-分区=A|$vr-座位号=1排1座"
template_key 格式(由后端统一生成,前端不需感知算法):
由 venue + room + section 组合决定,同一分区共用一个 template_key
```
### 3.2 层级树Hierarchy Tree
API 返回的结构,按 `group_by` 参数动态生成:
```json
{
"goods_id": 118,
"title": "VR演唱会",
"group_by": ["venue", "session", "room", "section"],
"tree": {
"鸟巢": {
"name": "鸟巢",
"min_price": 280,
"has_available": true,
"rooms": {
"主厅": {
"name": "主厅",
"min_price": 380,
"has_available": true,
"sections": {
"A": {
"template_key": "鸟巢_主厅_A",
"price": 680,
"inventory": 12,
"has_available": true
},
"B": {
"template_key": "鸟巢_主厅_B",
"price": 380,
"inventory": 0,
"has_available": false
}
}
}
},
"sessions": {
"15:00-16:59": { "min_price": 380, "has_available": true },
"20:00-21:59": { "min_price": 280, "has_available": false }
}
},
"国家大剧院": {
"name": "国家大剧院",
"min_price": 380,
"has_available": true,
"rooms": { ... },
"sessions": { ... }
}
},
"seat_templates_flat": {
"鸟巢_主厅_A": {
"id": 1,
"name": "鸟巢-主厅-A区",
"map": ["AAAAA", "AAB__AA", "BBBBB"],
"sections": [
{ "char": "A", "name": "VIP区", "price": 680, "color": "#ff4d4f" },
{ "char": "B", "name": "看台", "price": 380, "color": "#409eff" }
],
"seats": { "A": {...}, "B": {...} }
},
"鸟巢_主厅_B": { ... }
},
"flat_inventory": [
{
"spec_key": "$vr-场次=15:00-16:59|$vr-场馆=鸟巢|$vr-演播室=主厅|$vr-分区=A|$vr-座位号=1排1座",
"price": 680,
"inventory": 1,
"seat_key": "room_0_A_1",
"rowLabel": "A",
"colNum": 1
},
...
],
"meta": {
"flat_count": 120,
"template_count": 4,
"cache_hit": false,
"computed_at": 1750000000
}
}
```
### 3.3 模板去重逻辑
**关键原则**:同一 `venue + room + section` 的座位图模板在所有场次下共享。
```
模板 key 由以下维度决定(与 session 无关):
venue + "_" + room + "_" + section
示例:
鸟巢 + 主厅 + A区 → template_key = "鸟巢_主厅_A"
6 个场次 × 1 个模板 = 1 份模板数据(不是 6 份)
数据冗余对比:
naive 方案(内嵌到每个 section18 份完整模板
当前方案template_key 引用1 份模板 × 4 个 key
```
### 3.4 venue-first vs session-first
业务逻辑支持两种 `group_by` 顺序:
```
场景 1当前session → venue → room → section
适用:用户先选场次,再选场馆
场景 2Joery 场景venue → session → room → section
适用:用户先选场馆(电影院),再看该场馆有哪些场次
原因:场次是场馆的属性,不同场馆场次不同
```
API 通过 `group_by` 参数支持任意顺序:
```
GET /api/goods/tree?goods_id=118&group_by=session,venue,room,section
GET /api/goods/tree?goods_id=118&group_by=venue,session,room,section
```
---
## 四、API 设计
### 4.1 接口定义
```
GET /api/goods/tree
?goods_id=118 # 商品ID必填
&group_by=venue,session,room,section # 分组维度顺序(必填)
&filter= # 可选过滤条件(预留)
&cache_ttl=120 # 可选自定义缓存时间
```
**返回字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| `goods_id` | int | 商品ID |
| `title` | string | 商品标题 |
| `group_by` | array | 本次请求的分组维度 |
| `tree` | object | 层级树结构,叶节点含 `template_key` |
| `seat_templates_flat` | object | 模板去重池key → 模板数据 |
| `flat_inventory` | array | 扁平的 SKU 列表(用于前端本地筛选) |
| `meta.flat_count` | int | 原始 SKU 总数 |
| `meta.template_count` | int | 去重后模板数量 |
| `meta.cache_hit` | bool | 是否命中缓存 |
| `meta.computed_at` | int | 计算时间戳 |
### 4.2 分组维度说明
| 维度 | 说明 | 示例 |
|------|------|------|
| `venue` | 场馆(合作方/电影院) | 鸟巢、国家大剧院 |
| `session` | 场次时间段 | 15:00-16:59 |
| `room` | 演播室 | 主厅、次厅 |
| `section` | 分区 | A区、VIP区 |
**维度数量**:当前实现 4 维venue / session / room / section扩展维度时只需修改 `SPEC_DIMS` 常量。
---
## 五、前端交互流程
```
阶段 1初始加载一次 API 调用)
GET /api/goods/tree?goods_id=118&group_by=venue,session,room,section
→ 前端收到 tree + seat_templates_flat + flat_inventory
阶段 2用户交互纯本地计算零额外 API 调用)
venue=鸟巢 → session=15:00-16:59 → room=主厅 → section=A
前端查 tree["鸟巢"]["sessions"]["15:00-16:59"]["rooms"]["主厅"]["sections"]["A"]
拿到 template_key = "鸟巢_主厅_A"
前端查 seat_templates_flat["鸟巢_主厅_A"] → 渲染座位图模板
前端用 spec_key 前缀匹配在 flat_inventory 里过滤出该分区的所有座位
标记有库存的座位为可选 → 用户选座
```
**spec_key 前缀匹配示例**
```
用户选venue=鸟巢, session=15:00-16:59, room=主厅, section=A
前端构造前缀:
"$vr-场馆=鸟巢|$vr-场次=15:00-16:59|$vr-演播室=主厅|$vr-分区=A|"
flat_inventory 中匹配:
"$vr-场馆=鸟巢|$vr-场次=15:00-16:59|$vr-演播室=主厅|$vr-分区=A|$vr-座位号=1排1座" → 可选
"$vr-场馆=鸟巢|$vr-场次=15:00-16:59|$vr-演播室=主厅|$vr-分区=A|$vr-座位号=1排2座" → 可选
"$vr-场馆=鸟巢|$vr-场次=15:00-16:59|$vr-演播室=主厅|$vr-分区=B|..." → 不匹配,跳过
```
---
## 六、查询管理器Query Manager
### 6.1 职责
- 接收 `group_by``filter_by` 参数
- 从扁平数据seatSpecMap按维度聚合
- 生成层级树结构
- 写入缓存,返回结果
### 6.2 聚合逻辑(伪 SQL
```sql
-- 原始数据vr_sku_flat所有 SKU
-- 1. 过滤(如果 filter_by 不为空)
SELECT * FROM vr_sku_flat
WHERE goods_id = 118
AND spec_key LIKE '%venue=鸟巢%'
AND spec_key LIKE '%session=15:00-16:59%'
-- 2. 分组聚合(按 group_by 顺序)
GROUP BY venue, session, room, section
→ 每组计算MIN(price), SUM(inventory), has_available = SUM(inventory) > 0
→ 模板 keyvenue + "_" + room + "_" + section
```
### 6.3 缓存策略
```
CacheKey = hash(goods_id + group_by_str + filter_str)
CacheTTL = 60s可配置默认 60s
失效机制:订单支付成功 → 清除该 goods_id 的所有相关缓存
```
### 6.4 实时查询接口(复用查询管理器)
除了层级树,查询管理器还暴露实时查询能力:
```
GET /api/goods/inventory?goods_id=118&spec=venue:鸟巢,session:15:00-16:59
→ 返回该条件下的总库存
GET /api/goods/inventory?goods_id=118&spec=venue:鸟巢,session:20:00-21:59,room:主厅
→ 返回更精确的库存
```
---
## 七、与现有系统衔接
### 7.1 数据来源
```
vr_goods_configgoods.vr_goods_config
└── template_id, selected_rooms, selected_sections, sessions[]
SeatSkuService::buildSeatSpecMap()
→ 扁平 SKU 数据seatSpecMap
Query Manager新增
→ 按 group_by 聚合 → 层级树
→ 模板去重池 seat_templates_flat
缓存层ShopXO Cache
API Response
```
### 7.2 现有组件保持不变
- `SeatMapService` / `SeatSkuService`:继续作为数据源,不改逻辑
- `vr_seat_templates` 表:继续存储座位模板,不变
- `vr_goods_config`:继续作为商品配置,不变
### 7.3 新增组件
| 组件 | 路径 | 职责 |
|------|------|------|
| `QueryManager` | `service/QueryManager.php` | 分组聚合、模板去重、缓存 |
| `/api/goods/tree` | `api/Goods.php::tree()` | HTTP 接口 |
| `/api/goods/inventory` | `api/Goods.php::inventory()` | 实时查询接口(可选) |
---
## 八、实施阶段
### Phase A数据验证
- 用现有 seatSpecMap 数据,验证 group_by 聚合逻辑
- 测试 500 SKU 级别的聚合性能
- 确认模板 key 生成算法venue + "_" + room + "_" + section
### Phase BAPI 实现
- 实现 `QueryManager` 服务
- 实现 `/api/goods/tree` 接口
- 配置缓存策略
### Phase C前端改造
- 改造 `ticket_detail.html`
- 替换 4 个选择器的数据源
- 实现 spec_key 前缀匹配逻辑
- 验证模板按 key 引用、去重效果
### Phase D联调测试
- 层级选择 → 座位图渲染 → 选座 → 提交订单 完整流程
- 多场馆 × 多场次 × 多分区 组合测试
- 缓存失效机制验证
---
## 九、关键设计决策
| 决策 | 结论 | 原因 |
|------|------|------|
| 模板 key 由 venue+room+section 决定,与 session 无关 | ✅ 确认 | 同一分区座位图固定,跨 session 共享 |
| 分组维度顺序由前端通过 `group_by` 参数指定 | ✅ 确认 | 支持 venue-first 和 session-first 两种场景 |
| flat_inventory 随 API 一起返回,不拆分 | ✅ 确认 | 前端本地筛选,零额外 API 调用 |
| 缓存 TTL 默认 60s可配置 | ✅ 确认 | 平衡实时性和性能 |
| 查询管理器同时支持实时查询接口 | ✅ 预留 | 后期扩展用,当前可以先用 |
---
## 十、待确认问题
1. **spec_key 排序规则**:当前 SeatSkuService 的 `makeSpecKey` 按 type 排序,具体顺序是否固定?需确认 SPEC_DIMS 顺序。
2. **template_key 生成算法**:后端生成 tree 时用什么算法?是否与前端 spec_key 生成逻辑一致?需验证现有代码。
3. **分组维度是否完整**:当前 4 维venue/session/room/section是否覆盖所有场景是否需要加 "日期" 等维度?
4. **库存为 0 的 seat 是否返回**:当前 buildSeatSpecMap 过滤了 inventory=0 的座位,新 API 是否需要包含已售座位?
---
## 十一、相关文档索引
| 文档 | 路径 | 说明 |
|------|------|------|
| VR_GOODS_CONFIG_SPEC | `docs/VR_GOODS_CONFIG_SPEC.md` | vr_goods_config 存储格式 |
| MIGRATION_5DIM_SPEC | `docs/MIGRATION_5DIM_SPEC.sql` | 5维 spec SQL 迁移 |
| DEVELOPMENT_LOG | `docs/DEVELOPMENT_LOG.md` | Phase 0/1/2/3 开发日志 |
| DEBUGGING_ROUTING | `docs/DEBUGGING_ROUTING.md` | ShopXO 路由调试经验 |

View File

@ -0,0 +1,154 @@
# 扁平数据 + 查询管理器 方案
> 讨论日期2026-05-15
> 参与:西莉雅、大头
---
## 一、核心思想
**后端做计算,前端做渲染。**
- 扁平数据层Flat Inventory所有 SKU 以 spec_key 为索引,不预设层级
- 查询管理器Query Manager`group_by` 参数聚合,生成层级树
- 缓存层:结果缓存,避免重复计算
- 前端:通过 `group_by` 参数指定想要的层级顺序,收到树后直接渲染
---
## 二、关键设计决策
### 2.1 模板去重
**原则**:同一 `venue + room + section` 的座位图模板在所有场次下共享,与 session 无关。
```
例如:鸟巢 + 主厅 + A区
→ 6 个场次 × 1 个模板 = 1 份模板数据(不是 6 份)
→ template_key = "鸟巢_主厅_A"
→ 在 seat_templates_flat 中只存 1 份
```
**优势**
- 消除跨场次的模板数据冗余
- 前端只需按 key 引用,无需处理重复模板
### 2.2 层级顺序由前端控制
```
group_by=venue,session,room,section → 场馆优先Joery 场景)
group_by=session,venue,room,section → 场次优先(当前实现)
```
后端不在意顺序,只负责聚合。
### 2.3 扁平数据全量返回
```
API 返回:
├── tree层级含 section 聚合)
├── seat_templates_flat模板去重池
└── flat_inventory所有 SKU前端本地筛选
前端选座流程(全程零额外 API
venue → session → room → section
查 template_key → 查 seat_templates_flat → 渲染座位图
spec_key 前缀匹配 flat_inventory → 标记可选座位
```
### 2.4 实时查询接口(复用查询管理器)
查询管理器同时支持实时查询:
```
GET /api/goods/inventory?goods_id=118&spec=venue:鸟巢,session:15:00-16:59
→ 返回该条件下的总库存
```
---
## 三、API 接口
```
GET /api/goods/tree
?goods_id=118
&group_by=venue,session,room,section
&cache_ttl=120
```
返回结构:
```json
{
"goods_id": 118,
"title": "VR演唱会",
"group_by": ["venue", "session", "room", "section"],
"tree": {
"鸟巢": {
"name": "鸟巢",
"min_price": 280,
"has_available": true,
"rooms": { "主厅": { "sections": { "A": { "template_key": "鸟巢_主厅_A", "price": 680, "inventory": 12 } } } },
"sessions": { "15:00-16:59": { "min_price": 380, "has_available": true } }
}
},
"seat_templates_flat": {
"鸟巢_主厅_A": { "map": [...], "sections": [...], "seats": {...} }
},
"flat_inventory": [
{ "spec_key": "$vr-场次=15:00-16:59|...|$vr-座位号=1排1座", "price": 680, "inventory": 1, "seat_key": "room_0_A_1" }
],
"meta": { "flat_count": 120, "template_count": 4, "cache_hit": false }
}
```
---
## 四、与现有系统衔接
```
vr_goods_config
SeatSkuService::buildSeatSpecMap() → 扁平 SKU不预设层级
QueryManager新增
├── buildTree():按 group_by 聚合
├── buildTemplatePool():模板去重
└── buildFlatInventory():返回扁平 SKU 列表
缓存层TTL=60s
API Response
```
**现有组件不变**
- SeatMapService / SeatSkuService继续作为数据源
- vr_seat_templates 表:继续存储座位模板
- vr_goods_config继续作为商品配置
---
## 五、与"层级 JSON 树方案"的关系
| 维度 | 层级 JSON 树(方案 A | 扁平 + 查询管理器(方案 B采纳 |
|------|---------------------|--------------------------------|
| 前端复杂度 | 低(直接用树) | 中(本地筛选 + 模板引用) |
| 后端复杂度 | 中(预计算固定结构) | 中(查询管理器,按参数聚合) |
| 灵活性 | 差(固定层级) | 好(前端指定 group_by |
| 模板去重 | 支持(但实现复杂) | ✅ 原生支持 |
| 数据冗余 | 高(模板重复) | 低(去重池) |
| 实时查询 | 不支持 | ✅ 支持 |
**结论**Joery 的方案更优,采纳方案 B。
---
## 六、注意事项
1. **template_key 生成算法**:由后端统一生成,格式为 `venue_room_section`,前端不感知算法
2. **spec_key 排序**:前端匹配时必须使用与后端相同的排序规则(固定顺序,用 `|` 连接)
3. **缓存失效**:订单支付成功后同时清除 SeatMapService 缓存和 tree 缓存
4. **inventory=0 的座位**flat_inventory 需要包含已售座位(用于前端标记"已售"状态)
5. **缓存 TTL**:默认 60s可通过 `cache_ttl` 参数调整

90
docs/HANDOFF_TREE_API.md Normal file
View File

@ -0,0 +1,90 @@
# 任务交接简报VR Tree API 实现
> 创建时间2026-05-15
> 负责人:西莉雅(统筹),大头(产品决策)
---
## 目标
实现新 API `/api/goods/tree`,解决当前前端 O(n²) 重建层级的问题,同时消除座位模板数据冗余。
---
## 核心设计
### 扁平数据 + 查询管理器
```
扁平 SKUspec_key 索引)
→ 查询管理器(按 group_by 聚合)
→ 层级树(叶节点含 template_key+ 模板去重池 + 扁平 SKU 列表
→ 缓存TTL=60s
→ API 返回
```
### 模板去重原则
同一 `venue + room + section` 的座位图模板在所有场次下共享,只存一份,不按 session 重复。
### 前端交互(全程零额外 API 调用)
```
初始加载GET /api/goods/tree → tree + seat_templates_flat + flat_inventory
用户选座venue → session → room → section → 查 template_key → 查模板 → 渲染座位图
spec_key 前缀匹配:前端在 flat_inventory 里本地过滤
```
---
## 关键文件
| 文件 | 作用 |
|------|------|
| `docs/14_TREE_API_DESIGN.md` | 完整设计文档(必读) |
| `docs/TASK_TREE_API_IMPLEMENTATION.md` | 实施任务清单(执行依据) |
| `docs/15_FLAT_INVENTORY_QUERY_MANAGER.md` | 方案对比与决策记录 |
| `service/SeatSkuService.php` | 数据源(复用,不改) |
| `service/SeatMapService.php` | 数据源(复用,不改) |
| `api/Goods.php` | 新增 `tree()` 接口 |
---
## 技术要点
1. **spec_key 格式**`$vr-场次=X|$vr-场馆=X|$vr-演播室=X|$vr-分区=X|$vr-座位号=X`(固定顺序,`|` 连接)
2. **template_key 格式**`venue_room_section`(由后端生成,前端只引用)
3. **group_by 参数**:前端指定层级顺序,如 `venue,session,room,section`
4. **缓存失效**:订单支付成功后同时清除 tree 缓存
---
## 不需要改动的内容
- `vr_seat_templates` 表结构
- `vr_goods_config` 存储格式
- `SeatSkuService::BatchGenerate()` 逻辑
- 前端选座核心逻辑(只是数据源变了,渲染逻辑不变)
---
## 测试命令
```bash
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"
```
验证:
- `meta.flat_count` > 0SKU 数量)
- `meta.template_count` > 0模板数量<< flat_count
- `tree` 有正确的嵌套层级结构
- `seat_templates_flat` 中每个模板只出现一次
---
## 如遇问题
1. **spec_key 格式不对**:检查 `SeatSkuService::makeSpecKey()` 的排序逻辑
2. **模板去重不生效**:检查 template_key 生成逻辑是否按 venue+room+section 组合
3. **前端匹配失败**:确认前端 spec_key 排序与后端一致

View File

@ -0,0 +1,220 @@
# Task BriefVR 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 无效'];
}
// 缓存 keygoods_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-firstJoery 的场景)
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 排序**:必须与前端使用的排序规则一致,否则前缀匹配失败