13 KiB
13 KiB
VR 票务 API 重构方案:参数化层级生成器
讨论日期:2026-05-15 参与:西莉雅(统筹)、大头(产品决策) 状态:规划中,待实现
一、问题背景
现有 API 的局限
当前 SeatMapService 提供两个数据视图:
| 接口 | 数据结构 | 问题 |
|---|---|---|
GET /seatmap |
seatSpecMap(扁平 key→value) |
前端需要自己重建层级,计算成本高 |
GetGoodsViewData (PHP) |
seatSpecMap + specTypeList + vr_seat_template |
前端仍需大量客户端逻辑,且 session 被放在顶层 |
核心问题:
- 前端重建层级需要全量扫描 seatSpecMap,时间复杂度 O(n²)
- session 作为顶层维度不符合实际业务逻辑(场次是场馆的子节点)
- 座位模板数据在不同场次间重复嵌入,造成大量冗余
- 前端需要同时维护 specTypeList + seatSpecMap + seatMap 三套数据,复杂度高
业务需求
- 场次是场馆的子节点:不同场馆可设置不同场次(某些只有 ABC,某些只有 CDF),场次由合作方(电影院)自主决定
- 未来重播场景:院线方像排片一样自行安排重映场次
- 多模板商品:一个商品下多个场馆,每场馆多场次,每场次多演播室/分区/座位
- 模板去重:同一分区的座位模板在所有场次下共享,不应重复嵌入
二、设计目标
- 后端生成层级结构:前端只需渲染,不需计算
- 参数化接口:前端通过
group_by参数指定想要的层级顺序 - 模板去重:座位模板按
template_key只存一份,前端按需引用 - 可缓存:相同参数的请求直接返回缓存结果
- 相辅相成:底层查询管理器同时支持实时库存查询
三、数据结构
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 参数动态生成:
{
"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 方案(内嵌到每个 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 |
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)
-- 原始数据: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 两种场景 |
| flat_inventory 随 API 一起返回,不拆分 | ✅ 确认 | 前端本地筛选,零额外 API 调用 |
| 缓存 TTL 默认 60s,可配置 | ✅ 确认 | 平衡实时性和性能 |
| 查询管理器同时支持实时查询接口 | ✅ 预留 | 后期扩展用,当前可以先用 |
十、待确认问题
- spec_key 排序规则:当前 SeatSkuService 的
makeSpecKey按 type 排序,具体顺序是否固定?需确认 SPEC_DIMS 顺序。 - template_key 生成算法:后端生成 tree 时用什么算法?是否与前端 spec_key 生成逻辑一致?需验证现有代码。
- 分组维度是否完整:当前 4 维(venue/session/room/section)是否覆盖所有场景?是否需要加 "日期" 等维度?
- 库存为 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 路由调试经验 |