# 扁平数据 + 查询管理器 方案 > **状态**: ✅ 已完成 > **讨论日期**:2026-05-15 > **参与**:西莉雅、大头 --- ## 一、核心思想 **后端做计算,前端做渲染。** - **动态层级树**:根据 `group_by` 参数动态生成层级结构 - **查询管理器(Query Manager)**:按 `group_by` 参数聚合,生成层级树 - **自底向上聚合**:每个层级节点自动计算 `inventory`、`min_price`、`max_price`、`has_available` - **座位嵌入**:座位数据直接嵌入树的最深层 `seats` 属性 - **模板去重池**:同一 `venue + room + section` 的模板只返回一份,格式为键值对 --- ## 二、关键设计决策 ### 2.1 模板去重 **原则**:同一 `venue + room + section` 的座位图模板在所有场次下共享,与 session 无关。 ``` 例如:测试场馆 + 老展厅 1 + A → 多个场次 × 1 个模板 = 1 份模板数据 → template_key = "测试场馆_老展厅 1_A" → 在 seat_templates 中只存 1 份,格式为键值对 ``` ### 2.2 层级顺序由前端控制 ``` group_by=venue,session,room,section → 场馆优先(Joery 场景) group_by=session,venue,room,section → 场次优先 group_by=section,venue,session,room → 自定义顺序 ``` ### 2.3 座位数据嵌入树 ``` API 返回: ├── tree(层级,含 seats 嵌入在最深层) ├── seat_templates(模板去重池,键值对格式) └── meta(元数据) 前端选座流程(全程零额外 API): venue → session → room → section ↓ 直接从 tree 最深层获取 seats ↓ 查 template_key → 查 seat_templates → 渲染座位图 ``` ### 2.4 移除 flat_inventory `flat_inventory` 已被移除,因为: - 座位数据已嵌入 tree 最深层 - 前端直接从 `seats` 获取座位,无需额外遍历 --- ## 三、API 接口 ``` GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=tree ?goods_id=118 &group_by=venue,session,room,section ``` 返回结构(当前实现): ```json { "code": 0, "data": { "goods_id": 118, "group_by": ["venue", "session", "room", "section"], "tree": { "venues": { "测试场馆": { "name": "测试场馆", "min_price": 0, "max_price": 0, "has_available": true, "inventory": 31, "sessions": { "07:00-09:59": { "name": "07:00-09:59", "inventory": 31, "rooms": { "老展厅 1": { "name": "老展厅 1", "inventory": 16, "sections": { "A": { "name": "A", "min_price": 0, "max_price": 0, "has_available": true, "inventory": 10, "template_key": "测试场馆_老展厅 1_A", "price": 0, "seats": { "1排1座": { "spec_key": "$vr-分区=A|...", "venue": "测试场馆", "session": "07:00-09:59", "room": "老展厅 1", "section": "A", "seat": "1排1座", "price": 0, "inventory": 1 } } } } } } } } } } }, "seat_templates": { "测试场馆_老展厅 1_A": { "template_key": "测试场馆_老展厅 1_A", "name": "测试场馆", "room_name": "老展厅 1", "section_name": "A", "seat_map": { ... }, "layout_cols": 10, "layout_rows": 10 } }, "meta": { "seat_count": 59, "template_count": 6, "cache_hit": false, "computed_at": 1778861766 } } } ``` --- ## 四、与现有系统衔接 ``` vr_goods_config ↓ SeatSkuService::buildSeatSpecMap() → 扁平 SKU ↓ QueryManager(新增) ├── buildTree():按 group_by 聚合,嵌入 seats ├── buildTemplatePool():模板去重 └── transformTemplatePool():转换为键值对格式 ↓ 缓存层(TTL=60s) ↓ API Response ``` **现有组件不变**: - SeatMapService / SeatSkuService:继续作为数据源 - vr_seat_templates 表:继续存储座位模板 - vr_goods_config:继续作为商品配置 --- ## 五、实现状态 | 任务 | 状态 | 说明 | |------|------|------| | QueryManager 核心逻辑 | ✅ 已完成 | buildTree, computeStatsRecursive | | 动态层级树生成 | ✅ 已完成 | 根据 group_by 动态构建 | | Seats 嵌入最深层 | ✅ 已完成 | seats 属性嵌入 tree 最深层 | | 自底向上统计 | ✅ 已完成 | inventory, min_price, max_price, has_available | | seat_templates 键值对 | ✅ 已完成 | transformTemplatePool() | | 移除 flat_inventory | ✅ 已完成 | seats 已嵌入 tree | | 缓存机制 | ✅ 已完成 | Cache::set/get + 标签失效 | | API 文档 | ✅ 已完成 | `docs/api/VR_TICKET_TREE_API.md` | | peer_goods 多场次关联 | ✅ 已完成 | 按 coding 关联同演出不同日期商品 | | session_meta 场次元数据 | ✅ 已完成 | 从 SKU extends 提取停售时间戳 | | SKU batch_expire_ts 写入 | ✅ 已完成 | BatchGenerate 时写入 extends | | BuyCheck 停售验证钩子 | ✅ 已完成 | 开场前5分钟禁止下单 | | 字段修正 | ✅ 已完成 | goods_code→coding, produce_date→batch_number_expire | --- ## 六、注意事项 1. **template_key 生成算法**:`venue_room_section`,前端不感知算法 2. **缓存失效**:订单支付成功后同时清除 SeatMapService 缓存和 tree 缓存 3. **inventory=0 的座位**:仍保留在 seats 中,用于前端标记"已售"状态 4. **缓存 TTL**:默认 60s,通过 `cache_ttl` 参数可调整 --- ## 七、相关文档 | 文档 | 说明 | |------|------| | [api/VR_TICKET_TREE_API.md](./api/VR_TICKET_TREE_API.md) | 完整 API 文档 | | [14_TREE_API_DESIGN.md](./14_TREE_API_DESIGN.md) | Tree API 设计文档 |