vr-shopxo-plugin/docs/api/VR_TICKET_TREE_API.md

24 KiB
Raw Blame History

VR Ticket Tree API 文档

状态: 已完成 版本: 1.2.0 更新日期: 2026-05-18


目录

  1. 概述
  2. 快速开始
  3. 接口详情
  4. 请求参数
  5. 响应结构
  6. 新增字段详解
  7. 数据字段说明
  8. 前端使用指南
  9. 使用示例
  10. 最佳实践
  11. 错误处理
  12. 缓存策略
  13. 字段校验与数据完整性约束
  14. 订单提交错误处理

一、概述

1.1 什么是 Tree API

Tree API 是 VR Ticket 插件的核心接口,用于获取座位库存的层级树结构。该 API 将座位数据组织成动态层级树,前端可以根据 group_by 参数指定层级顺序,实现灵活的视图展示。

1.2 核心特性

特性 说明
动态层级 层级顺序由前端通过 group_by 参数控制
自底向上聚合 每个层级节点自动聚合子节点统计(库存、价格)
模板去重 同一 venue+room+section 的模板只返回一份
座位嵌入 座位数据直接嵌入树的最深层,无需额外请求
多场次关联 通过 peer_goods 支持同演出多日期切换
停售控制 通过 session_meta 支持场次禁用与倒计时
字段校验 Phase 1-3 校验体系,前后端多层拦截保障数据完整性
缓存机制 60秒缓存减少重复计算

1.3 与旧版对比

特性 旧版 API Tree API v1.2
数据结构 固定层级 动态层级
模板去重 原生支持
前端复杂度
数据冗余
座位查询 需额外计算 直接嵌入
多场次导航 peer_goods
停售倒计时 session_meta
字段校验 前端+保存+下单三层拦截
下单拦截 BuyCheck 停售校验

二、快速开始

2.1 基本请求

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"

2.2 使用 Python 验证

import requests
import json

response = requests.get("http://localhost:10000/api.php", params={
    "s": "plugins/index",
    "pluginsname": "vr_ticket",
    "pluginscontrol": "goods",
    "pluginsaction": "tree",
    "goods_id": 118,
    "group_by": "venue,session,room,section"
})

data = response.json()["data"]
print(f"场馆数量: {len(data['tree']['venues'])}")
print(f"模板数量: {data['meta']['template_count']}")
print(f"座位总数: {data['meta']['seat_count']}")
print(f"同场次商品: {data['peer_goods']}")
print(f"场次元数据: {data['session_meta']}")

三、接口详情

3.1 基本信息

属性
URL /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=tree
方法 GET
认证 无需认证
缓存 60秒

3.2 支持的 group_by 维度

维度 说明
venue 场馆
session 场次
room 演播室/影厅
section 分区A区、B区等

3.3 group_by 组合示例

# 场馆优先Joery 场景)
group_by=venue,session,room,section

# 场次优先
group_by=session,venue,room,section

# 自定义顺序
group_by=section,venue,session,room

四、请求参数

4.1 参数列表

参数 类型 必填 默认值 说明
goods_id int - 商品ID
group_by string venue,session,room,section 层级顺序,逗号分隔
cache_ttl int 60 缓存TTL仅开发环境使用

4.2 参数示例

# 基本请求
goods_id=118

# 场次优先
group_by=session,venue,room,section

# 开发环境禁用缓存
cache_ttl=0

五、响应结构

5.1 成功响应

{
  "code": 0,
  "msg": "success",
  "data": {
    "goods_id": 118,
    "group_by": ["venue", "session", "room", "section"],
    "tree": { ... },
    "seat_templates": { ... },
    "session_meta": [ ... ],
    "peer_goods": [ ... ],
    "meta": { ... }
  }
}

5.2 完整响应示例v1.1

{
  "code": 0,
  "msg": "success",
  "data": {
    "goods_id": 119,
    "group_by": ["venue", "session", "room", "section"],
    "tree": {
      "venues": {
        "测试场馆": {
          "name": "测试场馆",
          "min_price": 0,
          "max_price": 0,
          "has_available": true,
          "inventory": 16,
          "sessions": {
            "08:00-23:59": {
              "name": "08:00-23:59",
              "inventory": 16,
              "rooms": {
                "老展厅 1": {
                  "name": "老展厅 1",
                  "sections": {
                    "A": {
                      "name": "A",
                      "seats": { ... }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "seat_templates": {
      "测试场馆_老展厅 1_A": { ... }
    },
    "session_meta": [
      {
        "session": "08:00-23:59",
        "start": "08:00",
        "end": "23:59",
        "session_date": "2026-05-18",
        "session_datetime": "2026-05-18 08:00:00",
        "batch_expire_ts": 1746914400
      }
    ],
    "peer_goods": [
      {
        "id": 116,
        "title": "测试3",
        "date": ""
      },
      {
        "id": 117,
        "title": "测试4",
        "date": "2026-05-19"
      }
    ],
    "meta": {
      "seat_count": 30,
      "template_count": 4,
      "cache_hit": false,
      "computed_at": 1779080970
    }
  }
}

5.3 错误响应

{
  "code": 400,
  "msg": "goods_id 参数无效",
  "data": []
}

六、新增字段详解

6.1 session_meta场次元数据

引入版本: 1.1.0 来源: 从 SKU 的 GoodsSpecBase.extends JSON 中提取,去重后按场次时间排序

用途

  • 前端场次选择控件:判断场次是否已过期(禁用)
  • 停售倒计时:计算 batch_expire_ts - now() 毫秒数

字段说明

字段 类型 说明
session string 场次字符串,格式 HH:MM-HH:MM,如 "19:30-21:30"
start string 场次开始时间,如 "19:30"
end string 场次结束时间,如 "21:30"
session_date string 演出日期,格式 YYYY-MM-DD,从 goods.batch_number_expire 转换
session_datetime string 完整演出时间,格式 YYYY-MM-DD HH:MM:SS
batch_expire_ts int 停售截止时间戳Unix 秒),= 演出开始时间 - 5分钟。前端用此字段判断是否过期。

业务规则

  • batch_expire_ts <= 当前时间戳 时,该场次已停止售票
  • 前端应在场次选择 UI 中禁用对应选项
  • batch_expire_tsSeatSkuService::BatchGenerate() 在 SKU 生成时计算并写入

6.2 peer_goods同场次关联商品

引入版本: 1.1.0 来源: 查询 goods 表中 coding 字段相同的其他上架商品

用途

  • 前端渲染"多日期切换"控件(如顶部日期标签栏)
  • 同一演出(如"五月天演唱会")的不同日期场次商品通过 coding 关联

字段说明

字段 类型 说明
id int 商品ID
title string 商品标题
date string 演出日期,格式 YYYY-MM-DD,从 batch_number_expire 转换;未设置时为空字符串

业务规则

  • 仅返回上架且未删除的同 coding 商品(排除自身)
  • batch_number_expire 升序排列(最早的日期在最前)
  • batch_number_expire = 0(未设置),date 返回空字符串,前端应提示用户完善日期

七、数据字段说明

7.1 tree 节点字段

字段 类型 说明
name string 节点名称
min_price float 该层级及子节点的最低价格
max_price float 该层级及子节点的最高价格
has_available bool 是否有可用库存
inventory int 可用库存数量
template_key string 座位模板唯一标识(仅 section 节点)
price float 该分区的统一价格(仅 section 节点)
seats object 座位数据(仅最深层节点)

7.2 seat_templates 字段

字段 类型 说明
template_key string 模板唯一标识venue_room_section
name string 场馆名称
room_name string 演播室名称
section_name string 分区名称
seat_map object 座位图数据
layout_cols int 列数
layout_rows int 行数

7.3 meta 字段

字段 类型 说明
seat_count int 可用座位总数
template_count int 模板数量
cache_hit bool 是否命中缓存
computed_at int 计算时间戳

7.4 seats 字段结构

字段 类型 说明
spec_key string SKU 唯一标识
venue string 场馆名称
session string 场次时间
room string 演播室
section string 分区
seat string 座位号
price float 当前价格
inventory int 库存0=已售)
original_price float 原价

八、前端使用指南

8.1 session_meta 使用:场次禁用判断 + 倒计时

// 从 tree API 响应中获取
const { session_meta } = apiData;

// 判断场次是否可售
function isSessionAvailable(sessionKey, meta) {
  const info = meta.find(s => s.session === sessionKey);
  if (!info) return true; // 未找到按可用处理
  return info.batch_expire_ts > Date.now() / 1000;
}

// 获取停售倒计时(秒)
function getCountdown(sessionKey, meta) {
  const info = meta.find(s => s.session === sessionKey);
  if (!info) return null;
  const remaining = info.batch_expire_ts - Math.floor(Date.now() / 1000);
  return remaining > 0 ? remaining : 0; // 已过期返回 0
}

// 渲染场次选择器
function renderSessionTabs(meta) {
  const now = Math.floor(Date.now() / 1000);
  return meta.map(session => {
    const expired = session.batch_expire_ts <= now;
    const countdown = getCountdown(session.session, meta);

    return {
      label: `${session.session_date} ${session.start}-${session.end}`,
      value: session.session,
      disabled: expired,
      expired,
      countdown, // 秒数,前端自行定时刷新
    };
  });
}

8.2 peer_goods 使用:多日期切换导航

// 从 tree API 响应中获取
const { peer_goods } = apiData;

// 渲染日期切换栏
function renderDateSwitcher(currentGoodsId, peers) {
  if (!peers || peers.length === 0) return null; // 无同场次商品则不展示

  return peers.map(peer => ({
    id: peer.id,
    title: peer.title,
    date: peer.date, // 格式 YYYY-MM-DD 或空字符串
    active: peer.id === currentGoodsId,
  }));
}

// 切换日期时,重新调用 tree API
function switchToSession(targetGoodsId) {
  navigateTo(`/pages/goods-vr-ticket/goods-vr-ticket?goods_id=${targetGoodsId}`);
}

8.3 完整选座流程

class VRTicketSelector {
  constructor(apiData) {
    this.tree = apiData.tree;
    this.templates = apiData.seat_templates;
    this.sessionMeta = apiData.session_meta;
    this.peerGoods = apiData.peer_goods;
  }

  // 获取可用户选择的场次列表(过滤已过期)
  getAvailableSessions() {
    const now = Math.floor(Date.now() / 1000);
    return this.sessionMeta
      .filter(s => s.batch_expire_ts > now)
      .map(s => ({
        value: s.session,
        label: `${s.session_date} ${s.start}~${s.end}`,
        countdown: s.batch_expire_ts - now,
      }));
  }

  // 切换场次时,过滤 tree 中的座位数据
  filterBySession(sessionKey) {
    // 根据 group_by 找到对应的 sessions 节点
    const venues = Object.values(this.tree.venues);
    // ... 过滤逻辑由前端 UI 框架实现
  }
}

九、使用示例

9.1 获取场馆列表

const response = await fetch(apiUrl);
const { tree, meta } = response.data;

// 获取所有场馆
const venues = Object.keys(tree.venues);
console.log(`共 ${venues.length} 个场馆`);

9.2 获取特定分区的座位

// 获取 场馆=测试场馆 -> 场次=07:00-09:59 -> 演播室=老展厅 1 -> 分区=A 的座位
const section = tree.venues['测试场馆']
                  .sessions['07:00-09:59']
                  .rooms['老展厅 1']
                  .sections['A'];

const seats = section.seats;
console.log(`共 ${Object.keys(seats).length} 个座位`);

9.3 获取座位模板

const { seat_templates } = response.data;

// 通过 template_key 获取模板
const templateKey = section.template_key; // "测试场馆_老展厅 1_A"
const template = seat_templates[templateKey];

// 渲染座位图
console.log(template.seat_map.rooms[0].map);

9.4 前端选座流程

class SeatSelector {
  constructor(apiData) {
    this.tree = apiData.tree;
    this.templates = apiData.seat_templates;
    this.sessionMeta = apiData.session_meta;
    this.peerGoods = apiData.peer_goods;
  }

  // 选择场馆
  selectVenue(name) {
    return this.tree.venues[name];
  }

  // 选择场次
  selectSession(venueNode, session) {
    return venueNode.sessions[session];
  }

  // 选择演播室
  selectRoom(sessionNode, room) {
    return sessionNode.rooms[room];
  }

  // 选择分区,获取座位和模板
  selectSection(roomNode, section) {
    const sectionNode = roomNode.sections[section];
    const seats = sectionNode.seats;
    const template = this.templates[sectionNode.template_key];
    return { seats, template, ...sectionNode };
  }

  // 获取可用座位
  getAvailableSeats(seats) {
    return Object.entries(seats)
      .filter(([_, seat]) => seat.inventory > 0)
      .map(([name, data]) => ({ name, ...data }));
  }

  // 判断场次是否过期
  isSessionExpired(sessionKey) {
    const now = Math.floor(Date.now() / 1000);
    const info = this.sessionMeta.find(s => s.session === sessionKey);
    return info ? info.batch_expire_ts <= now : false;
  }
}

// 使用
const selector = new SeatSelector(data);
const venue = selector.selectVenue('测试场馆');
const session = selector.selectSession(venue, '07:00-09:59');
const room = selector.selectRoom(session, '老展厅 1');
const { seats, template } = selector.selectSection(room, 'A');
const available = selector.getAvailableSeats(seats);

十、最佳实践

10.1 前端缓存策略

// 本地缓存优化
const cache = new Map();
const CACHE_TTL = 60 * 1000; // 60秒

async function fetchTree(goodsId, groupBy) {
  const key = `${goodsId}_${groupBy.join(',')}`;
  const cached = cache.get(key);

  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.data;
  }

  const response = await fetch(`/api.php?s=plugins/index&...&goods_id=${goodsId}&group_by=${groupBy}`);
  const data = await response.json();

  cache.set(key, { data, timestamp: Date.now() });
  return data;
}

10.2 错误处理

async function safeFetchTree(goodsId, groupBy) {
  try {
    const response = await fetch(url);

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    const result = await response.json();

    if (result.code !== 0) {
      throw new Error(result.msg);
    }

    return result.data;
  } catch (error) {
    console.error('获取座位数据失败:', error);
    // 降级处理或显示错误
    return null;
  }
}

10.3 座位图渲染

function renderSeatMap(template, availableSeats) {
  const { seat_map, layout_rows, layout_cols } = template;
  const room = seat_map.rooms[0]; // 获取第一个演播室

  // 生成座位矩阵
  const seatMatrix = room.map.map((row, rowIndex) => {
    return row.split('').map((char, colIndex) => {
      const section = room.seats[char];
      return {
        row: rowIndex + 1,
        col: colIndex + 1,
        section: char,
        available: availableSeats.some(s =>
          s.row === rowIndex + 1 && s.col === colIndex + 1
        ),
        price: section?.price || 0,
        color: section?.color || '#ccc'
      };
    });
  });

  return seatMatrix;
}

10.4 性能优化

  1. 避免频繁请求: 使用本地缓存
  2. 懒加载模板: 仅在用户选择分区时加载模板
  3. 虚拟列表: 座位列表使用虚拟滚动
  4. 预加载: 进入页面时预加载最常用层级的数据

十一、错误处理

11.1 常见错误

code msg 解决方案
400 goods_id 参数无效 检查 goods_id 是否为有效整数
404 商品不存在 确认 goods_id 对应的商品存在
-1 获取层级树失败: Column not found 检查数据库字段名是否正确coding/batch_number_expire
-1 获取层级树失败: ... 检查服务端日志,联系后端

11.2 服务端错误日志

// Goods.php tree() 方法中的异常处理
catch (\Exception $e) {
    return self::error('获取层级树失败: ' . $e->getMessage());
}

11.3 停售验证错误(订单提交时)

当用户尝试在开场前5分钟内下单后端 Hook::BuyCheck 返回:

{
  "code": -1,
  "msg": "该场次19:30  21:30距开场已不足5分钟已停止售票请选择其他场次"
}

此错误由 plugins_service_buy_order_insert_begin 钩子触发,会导致订单事务回滚。


十二、缓存策略

12.1 缓存 Key 格式

vr_tree_v4_{goods_id}_{md5(group_by)}

示例:

  • vr_tree_v4_118_a1b2c3d4 (group_by = venue,session,room,section)
  • vr_tree_v4_118_e5f6g7h8 (group_by = session,venue,room,section)

12.2 缓存失效

当发生以下操作时,需要清除缓存:

操作 清除方法
订单支付成功 SeatMapService::ClearCache()
座位图修改 SeatMapService::ClearCache()
商品配置修改 QueryManager::clearCache($goodsId)

12.3 手动清除缓存

// 清除特定商品的 tree 缓存
QueryManager::clearCache(118);

// 清除所有 tree 缓存(不推荐)
\think\facade\Cache::tag('vr_tree_118', null);

十三、字段校验与数据完整性约束

引入版本: 1.2.0 关联文档: 16_VR_TICKET_FIELD_VALIDATION_PLAN.md

13.1 受约束字段

Tree API 的返回数据依赖于 goods 表中两个字段的正确填写:

字段 数据库列 影响 API 输出 必填(票务商品)
商品编号 goods.coding peer_goods(同演出多日期关联) 选填
演出日期 goods.batch_number_expire session_meta(场次元数据/停售倒计时) 必填

13.2 字段缺失时的 API 行为

场景 batch_number_expire coding API 响应表现
正常 有效时间戳 (>0) 有值 session_meta 返回完整场次数据;peer_goods 返回关联商品列表
缺日期 0 或 null 有值 session_meta 为空数组 [];前端无法展示场次选择卡、无倒计时;peer_goodsdate 为空串
缺编号 有效时间戳 (>0) session_meta 正常返回;peer_goods 为空数组 [],前端不展示多日期切换控件
双缺 0 或 null session_meta 为空;peer_goods 为空;保存时会被 AdminGoodsSaveHandle 拦截

13.3 校验体系架构

┌─────────────────────────────────────────────────────┐
│                   Phase 1 — 前端                      │
│  AdminGoodsSave.php · applyTicketRequired()           │
│  动态注入 required + 红色星号 + placeholder 提示        │
│  校验时机:后台管理员填写表单时                           │
├─────────────────────────────────────────────────────┤
│                   Phase 2 — 保存钩子                    │
│  AdminGoodsSaveHandle.php                            │
│  ① batch_number_expire > 0必填                     │
│  ② (coding, batch_number_expire) 组合唯一              │
│  校验时机plugins_service_goods_save_thing_end         │
├─────────────────────────────────────────────────────┤
│                   Phase 3 — 下单拦截                    │
│  Hook.php · BuyCheck()                               │
│  ① batch_number_expire > 0数据完整性                │
│  ② now < batch_expire_ts停售时效                    │
│  校验时机plugins_service_buy_order_insert_begin       │
└─────────────────────────────────────────────────────┘

十四、订单提交错误处理

14.1 票务专属错误

以下错误仅在商品为票务商品(vr_goods_config 非空)时触发。普通商品不受影响。

错误码 -1演出日期未设置

触发条件: BuyCheck 发现票务商品的 batch_number_expire <= 0

API 响应:

{
  "code": -1,
  "msg": "「{商品标题}」未设置演出日期,暂时无法购买"
}

原因: 管理员在保存商品时未填写演出日期字段(后台已强制必填,但可能存在历史遗留数据)。

解决方案: 前往商品编辑页,为票务商品设置有效的演出日期。

错误码 -1场次停售

触发条件: BuyCheck 发现 now >= batch_expire_ts(距开场不足 5 分钟)

API 响应:

{
  "code": -1,
  "msg": "该场次19:30  21:30距开场已不足5分钟已停止售票请选择其他场次"
}

技术细节: batch_expire_ts = 演出开始时间戳 - 300秒,由 SeatSkuService::BatchGenerate() 在 SKU 生成时写入 extends。

14.2 通用错误

code msg 触发源 说明
400 goods_id 参数无效 Goods.php tree() goods_id 非有效整数
404 商品不存在 Goods.php tree() 商品已删除或不存在
-1 获取层级树失败: ... Goods.php tree() 服务端异常,检查日志

14.3 保存错误(后台管理)

以下错误由 AdminGoodsSaveHandle 在管理员保存商品时返回:

错误消息 触发条件
票务商品必须设置演出日期(批号有效期),请填写后重新保存 batch_number_expire <= 0
该商品编号「{coding}」在此演出日期已存在商品「{title}」,请检查是否重复创建或修改演出日期 (coding, batch_number_expire) 组合重复
该演出日期已存在其他未设置编号的票务商品「{title}」,请先为已有商品设置商品编号或修改演出日期 空 coding + 相同日期冲突

附录 A相关文档

文档 说明
14_TREE_API_DESIGN.md Tree API 设计文档
15_FLAT_INVENTORY_QUERY_MANAGER.md 查询管理器设计文档
16_VR_TICKET_FIELD_VALIDATION_PLAN.md 字段校验方案
VR_GOODS_CONFIG_SPEC.md 商品配置规范
DEVELOPMENT_LOG.md 开发日志

附录 B变更历史

版本 日期 变更说明
1.0.0 2026-05-15 初始版本,完成基础功能
1.1.0 2026-05-18 新增 session_meta场次元数据/停售控制、peer_goods同场次关联商品、BuyCheck 停售验证钩子修正数据库字段名coding/batch_number_expire
1.2.0 2026-05-18 新增字段校验与数据完整性约束章节;新增订单提交错误处理章节;新增前端数据完整性检测示例;整合 Phase 1-3 校验体系文档

文档由 Antigravity 生成