24 KiB
VR Ticket Tree API 文档
状态: ✅ 已完成 版本: 1.2.0 更新日期: 2026-05-18
目录
一、概述
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.extendsJSON 中提取,去重后按场次时间排序
用途:
- 前端场次选择控件:判断场次是否已过期(禁用)
- 停售倒计时:计算
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_ts由SeatSkuService::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 性能优化
- 避免频繁请求: 使用本地缓存
- 懒加载模板: 仅在用户选择分区时加载模板
- 虚拟列表: 座位列表使用虚拟滚动
- 预加载: 进入页面时预加载最常用层级的数据
十一、错误处理
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_goods 中 date 为空串 |
| 缺编号 | 有效时间戳 (>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 生成