# 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}`(用于批量失效)