843 lines
24 KiB
Markdown
843 lines
24 KiB
Markdown
|
|
# 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 生成*
|