# 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 路由调试经验 |