vr-shopxo-plugin/docs/14_TREE_API_DESIGN.md

475 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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 三套数据,复杂度高
5. 已售座位inventory=0是否返回影响前端座位图渲染需标记已售状态
### 业务需求
- **场次是场馆的子节点**:不同场馆可设置不同场次(某些只有 ABC某些只有 CDF场次由合作方电影院自主决定
- **未来重播场景**:院线方像排片一样自行安排重映场次
- **多模板商品**:一个商品下多个场馆,每场馆多场次,每场次多演播室/分区/座位
- **模板去重**:同一分区的座位模板在所有场次下共享,不应重复嵌入
---
## 二、设计目标
1. **后端生成层级结构**:前端只需渲染,不需计算
2. **参数化接口**:前端通过 `group_by` 参数指定想要的层级顺序
3. **模板去重**:座位模板按 `template_key` 只存一份,前端按需引用
4. **可缓存**:相同参数的请求直接返回缓存结果
5. **相辅相成**:底层查询管理器同时支持实时库存查询
6. **保留已售座位**inventory=0 的座位也返回给前端,用于座位图标记已售状态
---
## 三、数据结构
### 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座"
> **排序决策**spec_key 按字母顺序排序而非固定业务顺序,原因如下:
> - 未来可能扩展新维度(如日期、场次类型等)
> - 字母排序更灵活,新增维度自动融入正确位置,无需修改排序逻辑
> - 前端构建 spec_key 时也必须使用相同的字母排序规则
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,
"max_price": 680,
"inventory": 50,
"has_available": true,
"sessions": {
"15:00-16:59": {
"name": "15:00-16:59",
"min_price": 380,
"max_price": 680,
"inventory": 30,
"has_available": true,
"rooms": {
"主厅": {
"name": "主厅",
"min_price": 380,
"max_price": 680,
"inventory": 30,
"has_available": true,
"sections": {
"A": {
"name": "A",
"min_price": 680,
"max_price": 680,
"inventory": 12,
"has_available": true,
"template_key": "鸟巢_主厅_A",
"seats": {
"1排1座": {
"spec_key": "$vr-场次=15:00-16:59|$vr-场馆=鸟巢|$vr-演播室=主厅|$vr-分区=A|$vr-座位号=1排1座",
"venue": "鸟巢",
"session": "15:00-16:59",
"room": "主厅",
"section": "A",
"seat": "1排1座",
"price": 680,
"inventory": 1,
"original_price": 880
},
"1排2座": { ... }
}
}
}
}
}
}
}
}
},
"seat_templates": {
"鸟巢_主厅_A": {
"template_key": "鸟巢_主厅_A",
"name": "鸟巢",
"room_name": "主厅",
"section_name": "A",
"seat_map": { "rows": 10, "cols": 10, "rooms": [...] },
"layout_rows": 10,
"layout_cols": 10
},
"鸟巢_主厅_B": { ... }
},
"meta": {
"seat_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 |
| `group_by` | array | 本次请求的分组维度 |
| `tree` | object | 层级树结构,叶节点含 `seats``template_key` |
| `seat_templates` | object | 模板去重池,键值对格式 {template_key: 模板数据} |
| `meta.seat_count` | int | 座位总数 |
| `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` 常量。
> **层级设计说明**tree 4层 vs spec 5维的关系
> - spec_key 包含完整的 5 个维度(场次、场馆、演播室、分区、座位号)
> - tree 只组织前 4 层venue → session → room → section用于层级选择导航
> - 第 5 维(座位号)是扁平条目,用于精确库存查询,不参与 tree 组织
> - 用户选到 section 后,通过 spec_key 前缀匹配在 `flat_inventory` 中查找具体座位
> - 这种设计将"选择导航"和"座位精确匹配"分离,职责清晰
---
## 五、前端交互流程
```
阶段 1初始加载一次 API 调用)
GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=tree&goods_id=118&group_by=venue,session,room,section
→ 前端收到 tree + seat_templates + meta
阶段 2用户交互纯本地操作零额外 API 调用)
venue=测试场馆 → session=07:00-09:59 → room=老展厅 1 → section=A
前端查 tree.venues['测试场馆'].sessions['07:00-09:59'].rooms['老展厅 1'].sections['A']
拿到 template_key = "测试场馆_老展厅 1_A" 和 seats 对象
前端查 seat_templates["测试场馆_老展厅 1_A"] → 渲染座位图模板
前端遍历 seats 对象,标记有库存的座位为可选 → 用户选座
```
**遍历座位示例**
```
用户选venue=测试场馆, session=07:00-09:59, room=老展厅 1, section=A
前端直接获取 seats 对象:
tree.venues['测试场馆'].sessions['07:00-09:59'].rooms['老展厅 1'].sections['A'].seats
seats 对象结构:
{
"1排1座": { "price": 0, "inventory": 1, ... },
"1排2座": { "price": 0, "inventory": 0, ... } // 已售,但仍返回用于标记状态
}
```
---
## 六、查询管理器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 两种场景 | ✅ 已实现 |
| 座位数据嵌入 tree 最深层 seats 属性中 | ✅ 确认 | 前端直接从 tree 获取座位,无需额外请求 | ✅ 已实现 |
| seat_templates 改为键值对格式 {key: {}} | ✅ 确认 | 前端可直接通过 key 访问模板,无需遍历数组 | ✅ 已实现 |
| 移除 flat_inventoryseats 已嵌入 tree | ✅ 确认 | 消除数据冗余,前端从 tree.seats 获取座位 | ✅ 已实现 |
| 每个层级节点包含 inventory, min_price, max_price, has_available | ✅ 确认 | 前端可快速统计和展示,无需额外计算 | ✅ 已实现 |
| 缓存 TTL 默认 60s可配置 | ✅ 确认 | 平衡实时性和性能 | ✅ 已实现 |
---
## 十、已确认问题
| 问题 | 结论 | 状态 |
|------|------|------|
| spec_key 排序规则 | 按 type 字母顺序排序SPEC_DIMS 定义了固定顺序 | ✅ 已确认 |
| template_key 生成算法 | `venue + "_" + room + "_" + section`,与 session 无关 | ✅ 已确认 |
| 分组维度是否完整 | 当前 4 维venue/session/room/section覆盖所有场景 | ✅ 已确认 |
| 库存为 0 的 seat 是否返回 | 已售座位仍返回,用于前端标记已售状态 | ✅ 已确认 |
---
## 十一、相关文档索引
| 文档 | 路径 | 说明 |
|------|------|------|
| 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 路由调试经验 |
---
## 十二、实现说明
### 12.1 核心文件
| 组件 | 路径 | 职责 |
|------|------|------|
| `QueryManager` | `service/QueryManager.php` | 树构建、统计聚合、模板转换 |
| `/api/goods/tree` | `api/Goods.php::tree()` | HTTP 接口、缓存、响应组装 |
### 12.2 API 响应格式
```json
{
"code": 0,
"data": {
"goods_id": 118,
"group_by": ["venue", "session", "room", "section"],
"tree": { ... },
"seat_templates": { ... },
"meta": {
"seat_count": 59,
"template_count": 2,
"cache_hit": false,
"computed_at": 1778861766
}
}
}
```
### 12.3 层级树结构
树结构按 `group_by` 参数动态生成,`seats` 数据永远嵌入在最深层:
- `group_by = [venue, session, room, section]` → seats 在 sections 节点
- `group_by = [section, venue, session, room]` → seats 在 rooms 节点
### 12.4 查询座位数据
前端从 tree 最深层 seats 获取座位,通过 spec_key 直接定位:
```javascript
// 示例:获取特定座位的库存
const seats = tree.venues['鸟巢'].sessions['15:00-16:59'].rooms['主厅'].sections['A'].seats;
const seat = seats['1排1座']; // spec_key 作为 key
console.log(seat.inventory, seat.price);
```
### 12.5 查询座位模板
```javascript
// 通过 template_key 直接获取模板
const template = data.seat_templates['鸟巢_主厅_A'];
console.log(template.layout_rows, template.layout_cols);
```
### 12.6 缓存策略
- 缓存 Key`vr_tree_{goods_id}_{md5(group_by)}`
- TTL60 秒
- 缓存标签:`vr_tree_{goods_id}`(用于批量失效)