vr-shopxo-plugin/docs/14_TREE_API_DESIGN.md

16 KiB
Raw Permalink Blame History

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 参数动态生成:

{
  "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 方案(内嵌到每个 section18 份完整模板
  当前方案template_key 引用1 份模板 × 4 个 key

3.4 venue-first vs session-first

业务逻辑支持两种 group_by 顺序:

场景 1当前session → venue → room → section
  适用:用户先选场次,再选场馆

场景 2Joery 场景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 层级树结构,叶节点含 seatstemplate_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_byfilter_by 参数
  • 从扁平数据seatSpecMap按维度聚合
  • 生成层级树结构
  • 写入缓存,返回结果

6.2 聚合逻辑(伪 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
   模板 keyvenue + "_" + 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_configgoods.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 BAPI 实现

  • 实现 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_inventoryseats 已嵌入 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 响应格式

{
  "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 直接定位:

// 示例:获取特定座位的库存
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 查询座位模板

// 通过 template_key 直接获取模板
const template = data.seat_templates['鸟巢_主厅_A'];
console.log(template.layout_rows, template.layout_cols);

12.6 缓存策略

  • 缓存 Keyvr_tree_{goods_id}_{md5(group_by)}
  • TTL60 秒
  • 缓存标签:vr_tree_{goods_id}(用于批量失效)