diff --git a/docs/14_TREE_API_DESIGN.md b/docs/14_TREE_API_DESIGN.md new file mode 100644 index 0000000..e65fafd --- /dev/null +++ b/docs/14_TREE_API_DESIGN.md @@ -0,0 +1,398 @@ +# 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 路由调试经验 | \ No newline at end of file diff --git a/docs/15_FLAT_INVENTORY_QUERY_MANAGER.md b/docs/15_FLAT_INVENTORY_QUERY_MANAGER.md new file mode 100644 index 0000000..ecd4bf8 --- /dev/null +++ b/docs/15_FLAT_INVENTORY_QUERY_MANAGER.md @@ -0,0 +1,154 @@ +# 扁平数据 + 查询管理器 方案 + +> 讨论日期:2026-05-15 +> 参与:西莉雅、大头 + +--- + +## 一、核心思想 + +**后端做计算,前端做渲染。** + +- 扁平数据层(Flat Inventory):所有 SKU 以 spec_key 为索引,不预设层级 +- 查询管理器(Query Manager):按 `group_by` 参数聚合,生成层级树 +- 缓存层:结果缓存,避免重复计算 +- 前端:通过 `group_by` 参数指定想要的层级顺序,收到树后直接渲染 + +--- + +## 二、关键设计决策 + +### 2.1 模板去重 + +**原则**:同一 `venue + room + section` 的座位图模板在所有场次下共享,与 session 无关。 + +``` +例如:鸟巢 + 主厅 + A区 + → 6 个场次 × 1 个模板 = 1 份模板数据(不是 6 份) + → template_key = "鸟巢_主厅_A" + → 在 seat_templates_flat 中只存 1 份 +``` + +**优势**: +- 消除跨场次的模板数据冗余 +- 前端只需按 key 引用,无需处理重复模板 + +### 2.2 层级顺序由前端控制 + +``` +group_by=venue,session,room,section → 场馆优先(Joery 场景) +group_by=session,venue,room,section → 场次优先(当前实现) +``` + +后端不在意顺序,只负责聚合。 + +### 2.3 扁平数据全量返回 + +``` +API 返回: + ├── tree(层级,含 section 聚合) + ├── seat_templates_flat(模板去重池) + └── flat_inventory(所有 SKU,前端本地筛选) + +前端选座流程(全程零额外 API): + venue → session → room → section + ↓ + 查 template_key → 查 seat_templates_flat → 渲染座位图 + ↓ + spec_key 前缀匹配 flat_inventory → 标记可选座位 +``` + +### 2.4 实时查询接口(复用查询管理器) + +查询管理器同时支持实时查询: + +``` +GET /api/goods/inventory?goods_id=118&spec=venue:鸟巢,session:15:00-16:59 +→ 返回该条件下的总库存 +``` + +--- + +## 三、API 接口 + +``` +GET /api/goods/tree + ?goods_id=118 + &group_by=venue,session,room,section + &cache_ttl=120 +``` + +返回结构: + +```json +{ + "goods_id": 118, + "title": "VR演唱会", + "group_by": ["venue", "session", "room", "section"], + "tree": { + "鸟巢": { + "name": "鸟巢", + "min_price": 280, + "has_available": true, + "rooms": { "主厅": { "sections": { "A": { "template_key": "鸟巢_主厅_A", "price": 680, "inventory": 12 } } } }, + "sessions": { "15:00-16:59": { "min_price": 380, "has_available": true } } + } + }, + "seat_templates_flat": { + "鸟巢_主厅_A": { "map": [...], "sections": [...], "seats": {...} } + }, + "flat_inventory": [ + { "spec_key": "$vr-场次=15:00-16:59|...|$vr-座位号=1排1座", "price": 680, "inventory": 1, "seat_key": "room_0_A_1" } + ], + "meta": { "flat_count": 120, "template_count": 4, "cache_hit": false } +} +``` + +--- + +## 四、与现有系统衔接 + +``` +vr_goods_config + ↓ +SeatSkuService::buildSeatSpecMap() → 扁平 SKU(不预设层级) + ↓ +QueryManager(新增) + ├── buildTree():按 group_by 聚合 + ├── buildTemplatePool():模板去重 + └── buildFlatInventory():返回扁平 SKU 列表 + ↓ +缓存层(TTL=60s) + ↓ +API Response +``` + +**现有组件不变**: +- SeatMapService / SeatSkuService:继续作为数据源 +- vr_seat_templates 表:继续存储座位模板 +- vr_goods_config:继续作为商品配置 + +--- + +## 五、与"层级 JSON 树方案"的关系 + +| 维度 | 层级 JSON 树(方案 A) | 扁平 + 查询管理器(方案 B,采纳) | +|------|---------------------|--------------------------------| +| 前端复杂度 | 低(直接用树) | 中(本地筛选 + 模板引用) | +| 后端复杂度 | 中(预计算固定结构) | 中(查询管理器,按参数聚合) | +| 灵活性 | 差(固定层级) | 好(前端指定 group_by) | +| 模板去重 | 支持(但实现复杂) | ✅ 原生支持 | +| 数据冗余 | 高(模板重复) | 低(去重池) | +| 实时查询 | 不支持 | ✅ 支持 | + +**结论**:Joery 的方案更优,采纳方案 B。 + +--- + +## 六、注意事项 + +1. **template_key 生成算法**:由后端统一生成,格式为 `venue_room_section`,前端不感知算法 +2. **spec_key 排序**:前端匹配时必须使用与后端相同的排序规则(固定顺序,用 `|` 连接) +3. **缓存失效**:订单支付成功后同时清除 SeatMapService 缓存和 tree 缓存 +4. **inventory=0 的座位**:flat_inventory 需要包含已售座位(用于前端标记"已售"状态) +5. **缓存 TTL**:默认 60s,可通过 `cache_ttl` 参数调整 \ No newline at end of file diff --git a/docs/HANDOFF_TREE_API.md b/docs/HANDOFF_TREE_API.md new file mode 100644 index 0000000..f3c418f --- /dev/null +++ b/docs/HANDOFF_TREE_API.md @@ -0,0 +1,90 @@ +# 任务交接简报:VR Tree API 实现 + +> 创建时间:2026-05-15 +> 负责人:西莉雅(统筹),大头(产品决策) + +--- + +## 目标 + +实现新 API `/api/goods/tree`,解决当前前端 O(n²) 重建层级的问题,同时消除座位模板数据冗余。 + +--- + +## 核心设计 + +### 扁平数据 + 查询管理器 + +``` +扁平 SKU(spec_key 索引) + → 查询管理器(按 group_by 聚合) + → 层级树(叶节点含 template_key)+ 模板去重池 + 扁平 SKU 列表 + → 缓存(TTL=60s) + → API 返回 +``` + +### 模板去重原则 + +同一 `venue + room + section` 的座位图模板在所有场次下共享,只存一份,不按 session 重复。 + +### 前端交互(全程零额外 API 调用) + +``` +初始加载:GET /api/goods/tree → tree + seat_templates_flat + flat_inventory +用户选座:venue → session → room → section → 查 template_key → 查模板 → 渲染座位图 +spec_key 前缀匹配:前端在 flat_inventory 里本地过滤 +``` + +--- + +## 关键文件 + +| 文件 | 作用 | +|------|------| +| `docs/14_TREE_API_DESIGN.md` | 完整设计文档(必读) | +| `docs/TASK_TREE_API_IMPLEMENTATION.md` | 实施任务清单(执行依据) | +| `docs/15_FLAT_INVENTORY_QUERY_MANAGER.md` | 方案对比与决策记录 | +| `service/SeatSkuService.php` | 数据源(复用,不改) | +| `service/SeatMapService.php` | 数据源(复用,不改) | +| `api/Goods.php` | 新增 `tree()` 接口 | + +--- + +## 技术要点 + +1. **spec_key 格式**:`$vr-场次=X|$vr-场馆=X|$vr-演播室=X|$vr-分区=X|$vr-座位号=X`(固定顺序,`|` 连接) +2. **template_key 格式**:`venue_room_section`(由后端生成,前端只引用) +3. **group_by 参数**:前端指定层级顺序,如 `venue,session,room,section` +4. **缓存失效**:订单支付成功后同时清除 tree 缓存 + +--- + +## 不需要改动的内容 + +- `vr_seat_templates` 表结构 +- `vr_goods_config` 存储格式 +- `SeatSkuService::BatchGenerate()` 逻辑 +- 前端选座核心逻辑(只是数据源变了,渲染逻辑不变) + +--- + +## 测试命令 + +```bash +curl "http://localhost:10000/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=tree&goods_id=118&group_by=venue,session,room,section" \ + -H "X-Requested-With: XMLHttpRequest" +``` + +验证: +- `meta.flat_count` > 0(SKU 数量) +- `meta.template_count` > 0(模板数量,应 << flat_count) +- `tree` 有正确的嵌套层级结构 +- `seat_templates_flat` 中每个模板只出现一次 + +--- + +## 如遇问题 + +1. **spec_key 格式不对**:检查 `SeatSkuService::makeSpecKey()` 的排序逻辑 +2. **模板去重不生效**:检查 template_key 生成逻辑是否按 venue+room+section 组合 +3. **前端匹配失败**:确认前端 spec_key 排序与后端一致 \ No newline at end of file diff --git a/docs/TASK_TREE_API_IMPLEMENTATION.md b/docs/TASK_TREE_API_IMPLEMENTATION.md new file mode 100644 index 0000000..8dc7581 --- /dev/null +++ b/docs/TASK_TREE_API_IMPLEMENTATION.md @@ -0,0 +1,220 @@ +# Task Brief:VR Tree API 实现 + +> 目标:实现参数化层级生成器 API +> 参考文档:`docs/14_TREE_API_DESIGN.md` +> 状态:规划完成,待实现 + +--- + +## 背景 + +当前 `SeatMapService::GetSeatMap()` 返回扁平 `seatSpecMap`,前端需要 O(n²) 重建层级,且 session 作为顶层不符合业务逻辑。 + +新 API `/api/goods/tree` 返回: +1. **层级树**(按 `group_by` 参数动态生成,叶节点含 `template_key`) +2. **模板去重池**(`seat_templates_flat`,每个模板只存一份) +3. **扁平 SKU 列表**(用于前端本地 spec_key 前缀匹配) + +--- + +## 实现步骤 + +### Step 1:验证现有数据结构 + +**验证文件**: +- `service/SeatSkuService.php` — 确认 `SPEC_DIMS` 顺序和 `makeSpecKey` 算法 +- `service/SeatMapService.php` — 确认 `buildSeatSpecMap` 输出结构 + +**验证内容**: +1. `spec_key` 格式:维度按固定顺序排序,用 `|` 连接 +2. `template_key` 生成:当前代码中是否有 venue+room+section 组合的模板 key?格式是什么? +3. seat_specMap 中的 `inventory=0` 座位是否被过滤? + +```php +// 在 ShopXO Docker 容器里验证 +docker exec shopxo-php bash -c "php -r ' +// 读取 goods_id=118 的 seatSpecMap +// 打印前 3 条记录的 spec_key 格式 +// 打印 template_snapshot 的 venue+rooms 结构 +'" +``` + +### Step 2:实现 QueryManager 服务 + +**文件**:`service/QueryManager.php` + +**核心方法**: +```php +class QueryManager +{ + // 主入口:生成层级树 + public static function buildTree(int $goodsId, array $groupBy): array + + // 分组聚合(核心逻辑) + private static function aggregate(array $flatInventory, array $groupBy): array + + // 生成模板去重池 + private static function buildTemplatePool(array $flatInventory, array $tree): array + + // 生成扁平 SKU 列表(用于前端本地筛选) + private static function buildFlatInventory(array $seatSpecMap): array +} +``` + +**聚合逻辑**: +``` +输入:flatInventory (seatSpecMap), groupBy = ['venue', 'session', 'room', 'section'] +输出:层级树 + +遍历每个 SKU: + 1. 解析 spec_key,提取各维度值 + 2. 按 groupBy 顺序构建嵌套路径(如 tree[venue][session][room][section]) + 3. 累加 inventory,计算 min_price + 4. 生成 template_key = venue_venueName_room_roomName_section_sectionChar + 5. 标记 has_available = 累计 inventory > 0 + +模板去重: + 每个 section 节点只存 template_key,不存完整模板 + 模板池在最后统一挂载(从 vr_seat_templates 表读取,或从 seatMap 数据提取) +``` + +### Step 3:实现 API 接口 + +**文件**:`api/Goods.php` + +```php +/** + * 获取层级树(含模板去重池 + 扁平 SKU) + * GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=tree&goods_id=118&group_by=venue,session,room,section + */ +public function tree() +{ + $goodsId = input('goods_id', 0, 'intval'); + $groupBy = input('group_by', 'venue,session,room,section', 'trim'); // 默认 venue-first + $groupBy = array_filter(array_map('trim', explode(',', $groupBy))); + + if ($goodsId <= 0) { + return ['code' => -1, 'msg' => 'goods_id 无效']; + } + + // 缓存 key:goods_id + group_by 组合 + $cacheKey = 'vr_tree_' . $goodsId . '_' . md5(implode(',', $groupBy)); + $cached = \think\facade\Cache::get($cacheKey); + if ($cached !== null) { + $data = $cached; + $data['meta']['cache_hit'] = true; + return ['code' => 0, 'msg' => 'success', 'data' => $data]; + } + + // 1. 读取现有 seatSpecMap(复用 SeatMapService) + $seatSpecMap = SeatMapService::GetSeatMap($goodsId)['seatSpecMap'] ?? []; + + // 2. 调用 QueryManager 构建树 + $tree = QueryManager::buildTree($goodsId, $groupBy, $seatSpecMap); + + // 3. 构建模板去重池 + $templates = QueryManager::buildTemplatePool($goodsId, $tree); + + // 4. 构建扁平 SKU 列表 + $flatInventory = QueryManager::buildFlatInventory($seatSpecMap); + + // 5. 组装响应 + $data = [ + 'goods_id' => $goodsId, + 'group_by' => $groupBy, + 'tree' => $tree, + 'seat_templates_flat' => $templates, + 'flat_inventory' => $flatInventory, + 'meta' => [ + 'flat_count' => count($flatInventory), + 'template_count' => count($templates), + 'cache_hit' => false, + 'computed_at' => time(), + ], + ]; + + // 6. 写入缓存(TTL = 60s) + \think\facade\Cache::set($cacheKey, $data, 60); + + return ['code' => 0, 'msg' => 'success', 'data' => $data]; +} +``` + +### Step 4:模板去重池生成逻辑 + +```php +private static function buildTemplatePool(int $goodsId, array $tree): array +{ + $pool = []; + + // 遍历 tree,找到所有 template_key + // 从 vr_seat_templates 表读取对应模板数据 + // key = template_key(如 "鸟巢_主厅_A") + // value = 模板的 map + sections + seats + + // 或者从 seatMap 数据直接提取(如果模板数据已在 seatMap 中) + // 取决于当前数据结构中模板数据的存储位置 + + return $pool; +} +``` + +**关键**:模板只按 `venue + "_" + room + "_" + section` 存储一份,不管有多少个 session。 + +### Step 5:前端适配(ticket_detail.html) + +**改动点**: +1. 初始请求改用 `/api/goods/tree`(替换现有的 `GetGoodsViewData` PHP 渲染) +2. 从返回中提取 `tree` + `seat_templates_flat` + `flat_inventory` +3. 用 `tree` 渲染选择器(venue → session → room → section) +4. 用户选到 section 时: + - 查 `tree[venue][session][room][section]['template_key']` + - 查 `seat_templates_flat[template_key]` 获取座位图 + - 用 `flat_inventory` 做 spec_key 前缀匹配,标记可选座位 + +**spec_key 前缀匹配(前端 JS)**: +```javascript +function filterSeatsBySpec(flatInventory, selections) { + // selections = { venue: '鸟巢', session: '15:00-16:59', room: '主厅', section: 'A' } + // 构造前缀 + const prefix = '$vr-场馆=' + selections.venue + + '|$vr-场次=' + selections.session + + '|$vr-演播室=' + selections.room + + '|$vr-分区=' + selections.section + '|'; + + return flatInventory.filter(sku => + sku.spec_key.startsWith(prefix) && sku.inventory > 0 + ); +} +``` + +--- + +## 验证清单 + +实现完成后,用 curl 测试: + +```bash +# 测试 venue-first(Joery 的场景) +curl "http://localhost:10000/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=tree&goods_id=118&group_by=venue,session,room,section" \ + -H "X-Requested-With: XMLHttpRequest" | python3 -c " +import json,sys +d = json.load(sys.stdin)['data'] +print('flat_count:', d['meta']['flat_count']) +print('template_count:', d['meta']['template_count']) +print('venues:', list(d['tree'].keys())) +" + +# 测试 session-first +curl "http://localhost:10000/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=tree&goods_id=118&group_by=session,venue,room,section" ... +``` + +--- + +## 注意事项 + +1. **不要删除现有代码**(SeatMapService / SeatSkuService),它们是数据源,新 API 依赖它们 +2. **模板去重**:确保同一个 venue+room+section 的模板只出现一次在 `seat_templates_flat` 里 +3. **缓存失效**:订单支付成功后调用 `SeatMapService::ClearCache()` 时,同时清除 tree 缓存 +4. **inventory=0 的座位**:需要确认是否包含在 `flat_inventory` 里(前端的座位图需要知道哪些是已售的) +5. **spec_key 排序**:必须与前端使用的排序规则一致,否则前缀匹配失败 \ No newline at end of file