# 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 方案(内嵌到每个 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) ```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,可配置 | ✅ 确认 | 平衡实时性和性能 | | 查询管理器同时支持实时查询接口 | ✅ 预留 | 后期扩展用,当前可以先用 | --- ## 十、待确认问题 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 路由调试经验 |