# 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 基本请求 ```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" ``` ### 2.2 使用 Python 验证 ```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 组合示例 ```bash # 场馆优先(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 参数示例 ```bash # 基本请求 goods_id=118 # 场次优先 group_by=session,venue,room,section # 开发环境禁用缓存 cache_ttl=0 ``` --- ## 五、响应结构 ### 5.1 成功响应 ```json { "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) ```json { "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 错误响应 ```json { "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_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 使用:场次禁用判断 + 倒计时 ```javascript // 从 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 使用:多日期切换导航 ```javascript // 从 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 完整选座流程 ```javascript 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 获取场馆列表 ```javascript const response = await fetch(apiUrl); const { tree, meta } = response.data; // 获取所有场馆 const venues = Object.keys(tree.venues); console.log(`共 ${venues.length} 个场馆`); ``` ### 9.2 获取特定分区的座位 ```javascript // 获取 场馆=测试场馆 -> 场次=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 获取座位模板 ```javascript 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 前端选座流程 ```javascript 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 前端缓存策略 ```javascript // 本地缓存优化 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 错误处理 ```javascript 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 座位图渲染 ```javascript 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 服务端错误日志 ```php // Goods.php tree() 方法中的异常处理 catch (\Exception $e) { return self::error('获取层级树失败: ' . $e->getMessage()); } ``` ### 11.3 停售验证错误(订单提交时) 当用户尝试在开场前5分钟内下单,后端 `Hook::BuyCheck` 返回: ```json { "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 手动清除缓存 ```php // 清除特定商品的 tree 缓存 QueryManager::clearCache(118); // 清除所有 tree 缓存(不推荐) \think\facade\Cache::tag('vr_tree_118', null); ``` --- ## 十三、字段校验与数据完整性约束 > **引入版本**: 1.2.0 > **关联文档**: [16_VR_TICKET_FIELD_VALIDATION_PLAN.md](../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 响应**: ```json { "code": -1, "msg": "「{商品标题}」未设置演出日期,暂时无法购买" } ``` **原因**: 管理员在保存商品时未填写演出日期字段(后台已强制必填,但可能存在历史遗留数据)。 **解决方案**: 前往商品编辑页,为票务商品设置有效的演出日期。 #### 错误码 -1:场次停售 **触发条件**: `BuyCheck` 发现 `now >= batch_expire_ts`(距开场不足 5 分钟) **API 响应**: ```json { "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](../14_TREE_API_DESIGN.md) | Tree API 设计文档 | | [15_FLAT_INVENTORY_QUERY_MANAGER.md](../15_FLAT_INVENTORY_QUERY_MANAGER.md) | 查询管理器设计文档 | | [16_VR_TICKET_FIELD_VALIDATION_PLAN.md](../16_VR_TICKET_FIELD_VALIDATION_PLAN.md) | 字段校验方案 | | [VR_GOODS_CONFIG_SPEC.md](../VR_GOODS_CONFIG_SPEC.md) | 商品配置规范 | | [DEVELOPMENT_LOG.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 生成*