475 lines
16 KiB
Markdown
475 lines
16 KiB
Markdown
# 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 方案(内嵌到每个 section):18 份完整模板
|
||
当前方案(template_key 引用):1 份模板 × 4 个 key
|
||
```
|
||
|
||
### 3.4 venue-first vs session-first
|
||
|
||
业务逻辑支持两种 `group_by` 顺序:
|
||
|
||
```
|
||
场景 1(当前):session → venue → room → section
|
||
适用:用户先选场次,再选场馆
|
||
|
||
场景 2(Joery 场景):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
|
||
→ 模板 key:venue + "_" + 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_config(goods.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 B:API 实现
|
||
- 实现 `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_inventory,seats 已嵌入 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)}`
|
||
- TTL:60 秒
|
||
- 缓存标签:`vr_tree_{goods_id}`(用于批量失效) |