docs: add VR ticket UniApp supplement (data structures, API specs, interaction spec)
parent
2c13951249
commit
abab933b9b
|
|
@ -0,0 +1,737 @@
|
|||
# VR 票务 UniApp 补充文档
|
||||
|
||||
> 创建时间:2026-05-14
|
||||
> 背景:基于 vr-shopxo-plugin H5 实现(ticket_detail.html)+ 插件后端,为 vr-shopxo-uniapp 移植提供完整的数据结构、后端接口、交互规范
|
||||
> 依赖:vr-ticket-integration-plan.md(原有 Phase 1-4 规划不变)
|
||||
|
||||
---
|
||||
|
||||
## 一、核心数据结构
|
||||
|
||||
### 1.1 vr_goods_config(v3.0 协议)
|
||||
|
||||
商品表 `goods.vr_goods_config` 存储票务配置快照,发布时写入,读取时直接使用不做关联查询:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 3.0,
|
||||
"template_id": 4,
|
||||
"selected_rooms": ["room_001", "room_002"],
|
||||
"selected_sections": {
|
||||
"room_001": ["A", "B"],
|
||||
"room_002": ["A"]
|
||||
},
|
||||
"sessions": [
|
||||
{ "start": "15:00", "end": "16:59" },
|
||||
{ "start": "18:00", "end": "20:59" }
|
||||
],
|
||||
"template_snapshot": {
|
||||
"venue": {
|
||||
"name": "测试场馆",
|
||||
"address": "北京市朝阳区",
|
||||
"location": { "lng": "116.4", "lat": "39.9" }
|
||||
},
|
||||
"rooms": [
|
||||
{
|
||||
"id": "room_001",
|
||||
"name": "主要展厅",
|
||||
"sections": [
|
||||
{ "char": "A", "name": "VIP区", "color": "#e74c3c" },
|
||||
{ "char": "B", "name": "看台", "color": "#3498db" },
|
||||
{ "char": "C", "name": "普通", "color": "#2ecc71" }
|
||||
],
|
||||
"map": ["AAAAAA", "BBBBBB", "CCCCCC"],
|
||||
"seats": {
|
||||
"A": { "price": 899, "color": "#e74c3c", "label": "VIP" },
|
||||
"B": { "price": 599, "color": "#3498db", "label": "看台" },
|
||||
"C": { "price": 299, "color": "#2ecc71", "label": "普通" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "room_002",
|
||||
"name": "新放映室 2",
|
||||
"sections": [
|
||||
{ "char": "A", "name": "VIP区", "color": "#e74c3c" },
|
||||
{ "char": "B", "name": "普通", "color": "#2ecc71" }
|
||||
],
|
||||
"map": ["AAAAA", "BBBBB"],
|
||||
"seats": {
|
||||
"A": { "price": 699, "color": "#e74c3c", "label": "VIP" },
|
||||
"B": { "price": 399, "color": "#2ecc71", "label": "普通" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键说明**:
|
||||
- `selected_sections` 以 `room_id` 为 key(因为不同 room 的相同 char 指向不同分区)
|
||||
- `template_snapshot` 在 Admin 发布时从 `vr_seat_templates.seat_map` 读取并存储,不做实时关联查询
|
||||
- `rooms[].id` 为 UUID 格式(如 `room_001`),用于前端座位 DOM 的 `data-seat-key` 属性
|
||||
|
||||
---
|
||||
|
||||
### 1.2 seatSpecMap(座位规格映射)
|
||||
|
||||
`seatSpecMap` 是前端选座的核心数据,按 `seat_key` 索引每个座位的完整规格信息:
|
||||
|
||||
```json
|
||||
// seatSpecMap(后端 GetGoodsViewData 动态构建,前端只读)
|
||||
{
|
||||
"room_001_A_1": {
|
||||
"spec_base_id": 10001,
|
||||
"price": 899.00,
|
||||
"inventory": 1,
|
||||
"spec": [
|
||||
{ "type": "$vr-场次", "value": "15:00-16:59" },
|
||||
{ "type": "$vr-场馆", "value": "测试场馆" },
|
||||
{ "type": "$vr-演播室", "value": "主要展厅" },
|
||||
{ "type": "$vr-分区", "value": "测试场馆-主要展厅-A" },
|
||||
{ "type": "$vr-座位号", "value": "测试场馆-主要展厅-A-A1" }
|
||||
],
|
||||
"venueName": "测试场馆",
|
||||
"roomId": "room_001",
|
||||
"roomName": "主要展厅",
|
||||
"section": { "char": "A", "name": "VIP区", "color": "#e74c3c" },
|
||||
"rowLabel": "A",
|
||||
"colNum": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**构建方式**(后端 `SeatSkuService::buildSeatSpecMap`):
|
||||
1. 查询 `GoodsSpecBase`(含 `extends.seat_key`)+ `GoodsSpecValue`(含 `value`)
|
||||
2. 通过 `GoodsSpecValue.value` 匹配 `GoodsSpecType.value` JSON 中的 `name` 确定维度
|
||||
3. 遍历 `seat_map.rooms[].map` 提取 `rowLabel`(`chr(65+rowIndex)`)和 `colNum`(从1开始)
|
||||
4. 合并以上信息输出 `seatSpecMap`
|
||||
|
||||
**前端用途**:
|
||||
- `seatSpecMap[seatKey].price` → 座位价格
|
||||
- `seatSpecMap[seatKey].inventory` → 是否可售(≤0 = 已售)
|
||||
- `seatSpecMap[seatKey].spec` → submit 时提交完整 5 维规格数组
|
||||
|
||||
---
|
||||
|
||||
### 1.3 SPEC_DIMS(5 维规格维度常量)
|
||||
|
||||
```php
|
||||
// PHP 后端(SeatSkuService.php)
|
||||
const SPEC_DIMS = [
|
||||
'$vr-场次', // 第1维
|
||||
'$vr-场馆', // 第2维
|
||||
'$vr-演播室', // 第3维
|
||||
'$vr-分区', // 第4维
|
||||
'$vr-座位号', // 第5维
|
||||
];
|
||||
```
|
||||
|
||||
前端用 `seatSpecMap[seatKey].spec` 数组代替直接访问 `SPEC_DIMS`。
|
||||
|
||||
---
|
||||
|
||||
### 1.4 座位图字符矩阵
|
||||
|
||||
```json
|
||||
// seat_map.rooms[].map — 字符串数组,每字符对应一列
|
||||
map: ["AAAAAA", "BBBBBB", "CCCCCC"]
|
||||
// A = VIP区座位,B = 看台座位,C = 普通座位
|
||||
// _ 或 - = 过道/空位(不渲染座位)
|
||||
```
|
||||
|
||||
**渲染规则**:
|
||||
- 字符 = `_` / `-`:渲染空白占位 div(维持对齐)
|
||||
- 字符 in `sections[].char`:渲染可用座位(带分区颜色)
|
||||
- 该座位 `inventory ≤ 0` 或在 `soldSeats` 中:渲染灰色已售座位
|
||||
|
||||
**座位 DOM `data-seat-key` 格式**:`{roomId}_{rowLabel}_{colNum}`(例如 `room_001_A_1`)
|
||||
|
||||
---
|
||||
|
||||
### 1.5 goods_spec_data(场次列表)
|
||||
|
||||
```json
|
||||
// 后端 GetGoodsViewData 从 sessions[] + seatSpecMap 构建
|
||||
[
|
||||
{ "spec_id": 0, "spec_name": "15:00-16:59", "price": 299, "start": "15:00", "end": "16:59" },
|
||||
{ "spec_id": 0, "spec_name": "18:00-20:59", "price": 399, "start": "18:00", "end": "20:59" }
|
||||
]
|
||||
```
|
||||
|
||||
前端用于渲染场次选择器横向滚动卡片。
|
||||
|
||||
---
|
||||
|
||||
## 二、后端 API 接口
|
||||
|
||||
### 2.1 商品详情 API(VR 扩展字段)
|
||||
|
||||
**请求**:`POST /api/goods/detail`
|
||||
```json
|
||||
{ "id": 118 }
|
||||
```
|
||||
|
||||
**响应**(关键字段):
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"id": 118,
|
||||
"title": "VR演唱会",
|
||||
"images": "[\"https://...jpg\"]",
|
||||
"price": "299-899",
|
||||
"is_vr_ticket": 1,
|
||||
"vr_goods_config": {
|
||||
"version": 3.0,
|
||||
"template_id": 4,
|
||||
"sessions": [...],
|
||||
"template_snapshot": {
|
||||
"venue": {...},
|
||||
"rooms": [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:`vr_goods_config` 直接嵌入商品详情响应,前端无需额外请求。
|
||||
|
||||
---
|
||||
|
||||
### 2.2 购物车提交 API(多座位下单)
|
||||
|
||||
**请求**:`POST /api/cart/save`
|
||||
```json
|
||||
{
|
||||
"goods_data": [
|
||||
{
|
||||
"goods_id": 118,
|
||||
"spec_base_id": 10001,
|
||||
"stock": 1,
|
||||
"extension_data": "{\"attendee\":{\"real_name\":\"张三\",\"phone\":\"13800138000\",\"id_card\":\"...\"},\"seat\":{\"seat_key\":\"room_001_A_1\",\"row\":\"A\",\"col\":1,\"section\":\"VIP区\"}}"
|
||||
},
|
||||
{
|
||||
"goods_id": 118,
|
||||
"spec_base_id": 10002,
|
||||
"stock": 1,
|
||||
"extension_data": "{\"attendee\":{\"real_name\":\"李四\",\"phone\":\"13900139000\",\"id_card\":\"...\"},\"seat\":{\"seat_key\":\"room_001_A_2\",\"row\":\"A\",\"col\":2,\"section\":\"VIP区\"}}"
|
||||
}
|
||||
],
|
||||
"buy_type": "goods",
|
||||
"address_id": "0"
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 每个座位单独一条 `goods_data` 记录
|
||||
- `spec_base_id` 从 `seatSpecMap[seatKey].spec_base_id` 获取
|
||||
- `extension_data` 为 JSON 序列化的观演人 + 座位信息
|
||||
- 后端 ShopXO BuyService 按 `spec_base_id` 原子扣库存(`FOR UPDATE SKIP LOCKED`)
|
||||
|
||||
---
|
||||
|
||||
### 2.3 票夹 API
|
||||
|
||||
**列表**:`GET /api/plugins/index?pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=list`
|
||||
```
|
||||
无参数(依赖 C 端 session)
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"tickets": [
|
||||
{
|
||||
"id": 482815,
|
||||
"goods_id": 118,
|
||||
"goods_title": "VR演唱会",
|
||||
"seat_info": "主要展厅 A区 1排1座",
|
||||
"session_time": "15:00-16:59",
|
||||
"venue_name": "测试场馆",
|
||||
"real_name": "张三",
|
||||
"verify_status": 0,
|
||||
"issued_at": "2026-05-01 12:00:00",
|
||||
"short_code": "003a2hgmgety"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| verify_status | 含义 |
|
||||
|---|---|
|
||||
| 0 | 未核销 |
|
||||
| 1 | 已核销 |
|
||||
| 2 | 已退款 |
|
||||
|
||||
**票详情**:`GET /api/plugins/index?...&pluginsaction=detail&id={ticketId}`
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"ticket": {
|
||||
"short_code": "003a2hgmgety",
|
||||
"qr_data": "eyJpZCI6NDgyODE1LCJnIjoxMTh9...",
|
||||
"qr_expires_at": 1745291400,
|
||||
"qr_expires_in": 1800,
|
||||
"verify_status": 0,
|
||||
"phone": "138****8000"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**QR payload(签名前)**:
|
||||
```json
|
||||
{ "id": 482815, "g": 118, "iat": 1745286000, "exp": 1745287800 }
|
||||
// iat = 签发时间戳,exp = 过期时间戳(签发后30分钟)
|
||||
// sig = HMAC-SHA256( payload_json, per-goods_secret )
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、交互规范(从 ticket_detail.html 移植)
|
||||
|
||||
### 3.1 选择器级联流程
|
||||
|
||||
```
|
||||
用户选择场次
|
||||
→ 重置:场馆/演播室/分区/座位图(全部清空+隐藏)
|
||||
→ 更新 spec options 可用性(场次售罄检查)
|
||||
|
||||
用户选择场馆
|
||||
→ 重置:演播室/分区/座位图(全部清空+隐藏)
|
||||
→ 更新 spec options 可用性(场馆售罄检查)
|
||||
|
||||
用户选择演播室
|
||||
→ 重置:分区/座位图(清空+隐藏分区)
|
||||
→ 过滤分区选项(只显示属于该演播室的分区)
|
||||
→ 动态加载该演播室的座位图(匹配 rooms[].name === currentRoom)
|
||||
→ 更新 spec options 可用性(演播室售罄检查)
|
||||
|
||||
用户选择分区
|
||||
→ 显示座位图(之前已加载好)
|
||||
→ filterSeats():只高亮符合当前 5 维选择的座位
|
||||
→ 其他座位 opacity:0.3 不可点击
|
||||
|
||||
用户点击座位
|
||||
→ toggleSeat:加入/移出 selectedSeats[]
|
||||
→ 更新已选座位 UI + 底部总价
|
||||
→ 显示观演人表单(每座一个)
|
||||
```
|
||||
|
||||
### 3.2 座位图渲染逻辑
|
||||
|
||||
```javascript
|
||||
// renderSeatMap() — 渲染座位矩阵
|
||||
mapData.forEach((rowStr, rowIndex) => {
|
||||
const rowLabel = String.fromCharCode(65 + rowIndex); // 0→A, 1→B...
|
||||
const chars = rowStr.split('');
|
||||
chars.forEach((char, colIndex) => {
|
||||
const colNum = colIndex + 1;
|
||||
const seatKey = `${roomId}_${rowLabel}_${colNum}`;
|
||||
const seatInfo = seatSpecMap[seatKey] || {};
|
||||
const section = seatInfo.section || {};
|
||||
const color = section.color || '#EA4C89';
|
||||
const price = seatInfo.price || 0;
|
||||
|
||||
if (char === '_' || char === '-') {
|
||||
// 过道空白
|
||||
} else if (seatInfo.inventory > 0 && !soldSeats[seatKey]) {
|
||||
// 可用座位(可点击)
|
||||
} else {
|
||||
// 已售座位(灰色)
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3.3 filterSeats(5 维过滤)
|
||||
|
||||
```javascript
|
||||
filterSeats: function() {
|
||||
// 当前 5 维全部匹配 + inventory > 0 → 高亮可用
|
||||
// 否则 → opacity:0.3, pointerEvents:none
|
||||
document.querySelectorAll('.vr-seat.available').forEach(function(el) {
|
||||
const seatKey = el.dataset.seatKey;
|
||||
const seatInfo = seatSpecMap[seatKey] || {};
|
||||
let match = { session: true, venue: true, room: true, section: true };
|
||||
|
||||
if (self.currentSession) {
|
||||
match.session = seatInfo.spec.some(s => s.type === '$vr-场次' && s.value === self.currentSession);
|
||||
}
|
||||
if (self.currentVenue) {
|
||||
match.venue = seatInfo.spec.some(s => s.type === '$vr-场馆' && s.value === self.currentVenue);
|
||||
}
|
||||
if (self.currentRoom) {
|
||||
match.room = seatInfo.spec.some(s => s.type === '$vr-演播室' && s.value === self.currentRoom);
|
||||
}
|
||||
if (self.currentSection) {
|
||||
match.section = seatInfo.spec.some(s => s.type === '$vr-分区' && s.value === self.currentSection);
|
||||
}
|
||||
|
||||
const available = match.session && match.venue && match.room && match.section && seatInfo.inventory > 0;
|
||||
el.style.opacity = available ? '1' : '0.3';
|
||||
el.style.pointerEvents = available ? 'auto' : 'none';
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 售罄级联灰化(从底向上)
|
||||
|
||||
```javascript
|
||||
// updateSpecOptionsAvailability()
|
||||
// 层级 1: 分区 — 有无可用座位 → 售罄变灰 + "(售罄)"标签
|
||||
// 层级 2: 演播室 — 所有分区都售罄 → 演播室变灰
|
||||
// 层级 3: 场馆 — 所有演播室都售罄 → 场馆变灰
|
||||
// 层级 4: 场次 — 所有场馆都售罄 → 场次变灰
|
||||
//
|
||||
// 遍历 seatSpecMap 统计各层级可用座位数(只统计当前场次)
|
||||
// 灰化时: opacity:0.4 + pointerEvents:none + 添加"(售罄)"span
|
||||
// 恢复时: 移除灰化样式 + 移除"(售罄)"span
|
||||
```
|
||||
|
||||
### 3.5 已选座位 UI
|
||||
|
||||
```javascript
|
||||
// selectedSeats[] 数组,每选一座 push 一个对象
|
||||
selectedSeats = [
|
||||
{
|
||||
seatKey: 'room_001_A_1',
|
||||
price: 899,
|
||||
rowLabel: 'A',
|
||||
colNum: 1,
|
||||
section: { char: 'A', name: 'VIP区', color: '#e74c3c' }
|
||||
}
|
||||
];
|
||||
|
||||
// updateSelectedUI()
|
||||
// 合计总价 = selectedSeats.reduce((sum, s) => sum + s.price, 0)
|
||||
// 每座显示一个观演人表单(姓名/手机/身份证)
|
||||
// 底部购票按钮 disabled = selectedSeats.length === 0
|
||||
```
|
||||
|
||||
### 3.6 观演人表单
|
||||
|
||||
```javascript
|
||||
// renderAttendeeForms()
|
||||
// 每个已选座位渲染一个表单块
|
||||
`
|
||||
<div class="vr-attendee-block" data-index="${i}">
|
||||
<div class="vr-attendee-title">观演人 ${i+1} (${seatLabel})</div>
|
||||
<div class="vr-form-row">
|
||||
<label>姓名</label>
|
||||
<input type="text" data-index="${i}" data-field="real_name" placeholder="请输入姓名" />
|
||||
</div>
|
||||
<div class="vr-form-row">
|
||||
<label>手机</label>
|
||||
<input type="tel" data-index="${i}" data-field="phone" placeholder="请输入手机号" />
|
||||
</div>
|
||||
<div class="vr-form-row">
|
||||
<label>身份证</label>
|
||||
<input type="text" data-index="${i}" data-field="id_card" placeholder="选填" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、UniApp 移植注意事项
|
||||
|
||||
### 4.1 页面路由
|
||||
|
||||
```
|
||||
pages/goods-vr-ticket/goods-vr-ticket.vue — VR 票务详情页(已创建)
|
||||
pages/goods-vr-ticket/components/
|
||||
├── ticket-header/ — 顶部海报+收藏(已完成)
|
||||
├── venue-card/ — 场馆卡片(已完成)
|
||||
└── ticket-popup/ — 购票弹窗(已完成,待接入数据)
|
||||
├── vr-session-select/ — 场次选择器(待实现)
|
||||
├── vr-booking-block/ — 规格级联选择(场→馆→室→分区)(待实现)
|
||||
├── vr-seat-selector/ — 座位选择器全屏遮罩(待实现)
|
||||
└── vr-attendee-form/ — 观演人表单(待实现)
|
||||
```
|
||||
|
||||
### 4.2 关键适配点
|
||||
|
||||
| H5 实现 | UniApp 适配 |
|
||||
|---------|------------|
|
||||
| `document.querySelectorAll()` | `uni.createSelectorQuery()` |
|
||||
| `classList.add/remove` | Vue data driven: `:class="{ 'sold-out': isSoldOut }"` |
|
||||
| `onclick="fn(this)"` | `@click="fn(item)"` — Vue 事件传参 |
|
||||
| `style="display:none"` | `v-show` 或 `:style="{ display: isVisible ? 'block' : 'none' }"` |
|
||||
| `scrollIntoView` | `uni.pageScrollTo({ selector })` 或 `scroll-view` |
|
||||
| `CryptoJS.enc.Base64` | `btoa(unescape(encodeURIComponent(str)))` |
|
||||
| `sessionStorage` | `uni.setStorageSync()` / `uni.getStorageSync()` |
|
||||
| `window.location.href` | `uni.redirectTo()` / `uni.reLaunch()` |
|
||||
| `$.ajax` | `uni.request()` |
|
||||
| `_` 和 `-` 占位符 | UniApp 中 `_` 和 `-` 同样适用 |
|
||||
|
||||
### 4.3 API 请求封装
|
||||
|
||||
```javascript
|
||||
// main.js 或 common/request.js 中封装
|
||||
const request = (options) => {
|
||||
const app = getApp();
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: app.globalData.get_request_url(options.action, options.controller || 'goods'),
|
||||
method: options.method || 'POST',
|
||||
data: options.data,
|
||||
success: (res) => {
|
||||
if (res.data.code == 0) {
|
||||
resolve(res.data);
|
||||
} else if (res.data.code == -400) {
|
||||
// 未登录,跳转登录页
|
||||
uni.redirectTo({ url: '/pages/login/login' });
|
||||
} else {
|
||||
uni.showToast({ title: res.data.msg || '请求失败', icon: 'none' });
|
||||
reject(res.data);
|
||||
}
|
||||
},
|
||||
fail: reject
|
||||
});
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### 4.4 vr_goods_config 解析流程
|
||||
|
||||
```javascript
|
||||
// goods-vr-ticket.vue onLoad()
|
||||
onLoad(params) {
|
||||
const app = getApp();
|
||||
const goodsId = params.id;
|
||||
|
||||
// 1. 尝试从全局缓存获取(goods-detail 已缓存)
|
||||
var goods = app.globalData.goods_data_cache_handle(goodsId);
|
||||
if (goods != null) {
|
||||
this.handleGoodsData(goods);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 请求 API
|
||||
request({ action: 'detail', controller: 'goods', data: { id: goodsId } })
|
||||
.then(res => {
|
||||
this.handleGoodsData(res.data.goods);
|
||||
});
|
||||
}
|
||||
|
||||
handleGoodsData(goods) {
|
||||
this.goodsData = goods;
|
||||
|
||||
// 解析 vr_goods_config
|
||||
var vrConfig = goods.vr_goods_config;
|
||||
if (typeof vrConfig === 'string') {
|
||||
vrConfig = JSON.parse(vrConfig);
|
||||
}
|
||||
this.vrConfig = vrConfig;
|
||||
|
||||
// 提取数据
|
||||
this.venues = vrConfig.template_snapshot.venues;
|
||||
this.sessions = vrConfig.sessions;
|
||||
this.seatMap = vrConfig.template_snapshot;
|
||||
|
||||
// 构建 seatSpecMap(由后端 GetGoodsViewData 返回,前端直接使用)
|
||||
// TODO: 需要后端提供 seatSpecMap API 或嵌入 goods 响应中
|
||||
this.buildSeatSpecMap();
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 待确认:seatSpecMap 获取方式
|
||||
|
||||
**现状**:`seatSpecMap` 在 H5 版由 PHP `SeatSkuService::GetGoodsViewData()` 注入模板变量,前端直接用。
|
||||
|
||||
**UniApp 方案 A(推荐)**:后端在商品详情 API 中直接返回 `seatSpecMap`(嵌入 `goods.vr_goods_config` 或单独字段)
|
||||
|
||||
**UniApp 方案 B**:前端按 `seatSpecMap` 相同逻辑用 JS 重建(不推荐:GoodsSpecValue 无法从前端获取)
|
||||
|
||||
**UniApp 方案 C**:新增 `GET /api/plugins/vr_ticket/seatmap?goods_id=xxx` 接口
|
||||
|
||||
---
|
||||
|
||||
## 五、购买链路完整流程
|
||||
|
||||
```
|
||||
用户选择座位(多选)
|
||||
↓
|
||||
填写观演人信息(每座一个表单)
|
||||
↓
|
||||
点击"立即购票"
|
||||
↓
|
||||
前端校验:座位数 === 观演人数
|
||||
↓
|
||||
POST /api/cart/save
|
||||
goods_data[] = selectedSeats.map(seat => ({
|
||||
goods_id: goodsId,
|
||||
spec_base_id: seatSpecMap[seat.seatKey].spec_base_id,
|
||||
stock: 1,
|
||||
extension_data: JSON.stringify({
|
||||
attendee: { real_name, phone, id_card },
|
||||
seat: { seat_key, row, col, section }
|
||||
})
|
||||
}))
|
||||
buy_type: 'goods'
|
||||
address_id: '0'
|
||||
↓
|
||||
后端 CartSave → BuyService::BuyGoods
|
||||
FOR UPDATE SKIP LOCKED 原子扣库存
|
||||
↓
|
||||
库存不足 → 返回错误,提示用户重选
|
||||
库存足够 → 写入订单
|
||||
↓
|
||||
ShopXO 微信支付(Native/H5/小程序)
|
||||
↓
|
||||
支付成功 → Hook: plugins_service_order_pay_success_handle_end
|
||||
→ TicketService::onOrderPaid()
|
||||
→ 解析 extension_data 中的 5 维 spec
|
||||
→ issueTicket() 生成短码 + QR 签名
|
||||
→ 写入 vr_tickets 表
|
||||
↓
|
||||
用户可在票夹页查看电子票(QR码 + 短码)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、票夹与 QR 票规范
|
||||
|
||||
### 6.1 QR 票数据结构
|
||||
|
||||
```json
|
||||
// QR payload(Base64 编码)
|
||||
{
|
||||
"id": 482815,
|
||||
"g": 118,
|
||||
"iat": 1745286000,
|
||||
"exp": 1745287800
|
||||
}
|
||||
|
||||
// qr_data 存储格式(vr_tickets.qr_data)
|
||||
"{short_code}|{base64(payload)}"
|
||||
// 例: "003a2hgmgety|eyJpZCI6NDgyODE1LCJnIjoxMTh9..."
|
||||
|
||||
// 本地验证(前端)
|
||||
const payload = JSON.parse(atob(qr_data.split('|')[1]));
|
||||
if (payload.exp < Date.now() / 1000) {
|
||||
// 已过期,显示倒计时刷新提示
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 短码格式
|
||||
|
||||
```
|
||||
4位 goods_id(base36) + HMAC-XOR 混淆的 ticket_id
|
||||
总计约 12 位,可读性好,无需搜索
|
||||
```
|
||||
|
||||
### 6.3 票夹页面结构
|
||||
|
||||
```
|
||||
pages/ticket-wallet/ticket-wallet.vue — 票夹列表(待实现)
|
||||
├── ticket-card 组件(复用)
|
||||
│ ├── 座位信息(场次/场馆/座位号)
|
||||
│ ├── QR码(大图,可放大)
|
||||
│ ├── 短码(便于手动输入核销)
|
||||
│ └── 状态标签(已核销/未核销/已退款)
|
||||
└── Realtime 订阅 orders 表状态变更
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、B 端扫码核销(UniApp Admin)
|
||||
|
||||
### 7.1 核销页面
|
||||
|
||||
```
|
||||
pages/admin-verify/admin-verify.vue — 扫码核销(待实现)
|
||||
├── 摄像头扫码(uni.scanCode)
|
||||
├── 短码/UUID 手动输入
|
||||
└── 核销结果展示
|
||||
```
|
||||
|
||||
### 7.2 核销 API
|
||||
|
||||
**UUID 核销**:`POST /api/plugins/index?...&pluginsaction=verify`
|
||||
```json
|
||||
{ "ticket_code": "uuid格式-xxx-xxx" }
|
||||
```
|
||||
|
||||
**短码自动路由**:后端 `verifyByShortCode()` 先尝试 UUID 格式,失败则解析短码。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{ "code": 0, "msg": "核销成功", "data": { "seat_info": "主要展厅 A区 1排1座", "real_name": "张三", "goods_name": "VR演唱会" }}
|
||||
```
|
||||
| code | 含义 |
|
||||
|------|------|
|
||||
| 0 | 核销成功 |
|
||||
| -1 | 票不存在 |
|
||||
| -2 | 已核销 |
|
||||
| -3 | 已退款 |
|
||||
| -999 | 系统异常 |
|
||||
|
||||
---
|
||||
|
||||
## 八、UniApp 待开发清单
|
||||
|
||||
### Phase 2 — 规格选择与选座(核心)
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 接入 vr_goods_config 数据 | 待开发 | 解析场次/场馆/演播室/分区数据 |
|
||||
| 场次选择器 | 待开发 | 横向滚动卡片,售罄灰化 |
|
||||
| 场馆选择器 | 待开发 | 级联重置,售罄灰化 |
|
||||
| 演播室选择器 | 待开发 | 动态过滤分区,显示座位图 |
|
||||
| 分区选择器 | 待开发 | 演播室激活后显示,按 room 过滤 |
|
||||
| 座位图渲染 | 待开发 | 字符矩阵渲染,可选/已售状态 |
|
||||
| 多座位选择 | 待开发 | toggleSeat,selectedSeats[] |
|
||||
| 售罄级联灰化 | 待开发 | 从底向上 4 层灰化 + "(售罄)"标签 |
|
||||
| 座位过滤 filterSeats | 待开发 | 按当前 5 维规格过滤 |
|
||||
| 观演人表单 | 待开发 | 每座一个表单块(姓名/手机/身份证) |
|
||||
|
||||
### Phase 3 — 购买与支付
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 确认购票提交 | 待开发 | CartSave API,多座位 goods_data |
|
||||
| 支付成功跳转 | 待开发 | 等待 ShopXO 支付回调 |
|
||||
| 订单状态同步 | 待开发 | Realtime 订阅 orders 表 |
|
||||
|
||||
### Phase 4 — 票夹与核销
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 票夹列表页 | 待开发 | Ticket.list API |
|
||||
| 票详情页(QR+短码) | 待开发 | Ticket.detail API,QR 缓存逻辑 |
|
||||
| B 端核销页 | 待开发 | 扫码 + 手动输入,verify API |
|
||||
| Realtime 状态更新 | 待开发 | 核销后 QR 页面自动变灰 |
|
||||
|
||||
---
|
||||
|
||||
## 九、后端待配合事项
|
||||
|
||||
1. **seatSpecMap 返回**:建议在商品详情 API 响应中直接嵌入 `seatSpecMap`,UniApp 无 GoodsSpecValue 读取权限
|
||||
2. **CartSave API**:确认 `extension_data` 字段接受 JSON 序列化字符串
|
||||
3. **ticket/list API**:确认 C 端 session 鉴权方式(JWT 或 cookie)
|
||||
4. **QR 刷新机制**:前端 15 分钟阈值,前端先查 localStorage 缓存
|
||||
|
||||
---
|
||||
|
||||
## 十、参考文件索引
|
||||
|
||||
| 源文件 | 说明 |
|
||||
|--------|------|
|
||||
| `vr-shopxo-plugin/.../view/goods/ticket_detail.html` | H5 实现完整参考(JS 逻辑 100% 可移植) |
|
||||
| `vr-shopxo-plugin/.../service/SeatSkuService.php` | 后端 SKU 构建 + buildSeatSpecMap |
|
||||
| `vr-shopxo-plugin/docs/VR_GOODS_CONFIG_SPEC.md` | v3.0 vr_goods_config 协议 |
|
||||
| `vr-shopxo-plugin/docs/PLAN_5DIM_REFACTOR.md` | 5 维 SPEC_DIMS 详细说明 |
|
||||
| `vr-shopxo-plugin/docs/PHASE_4_PLAN.md` | QR/短码/票夹/核销完整规范 |
|
||||
| `vr-shopxo-plugin/docs/PHASE_4_API.md` | 后端 API 完整文档 |
|
||||
| `vr-shopxo-plugin/docs/SPEC_SELECTOR_DESIGN.md` | 选择器交互规范 |
|
||||
| `vr-shopxo-plugin/docs/SPEC_SELECTOR_DATA_DICTIONARY.md` | 模板变量数据字典 |
|
||||
| `vr-shopxo-uniapp/docs/vr-ticket-integration-plan.md` | UniApp Phase 1-4 原规划 |
|
||||
Loading…
Reference in New Issue