From abab933b9bc3e3cd4c18039a36fb93c63444fdcf Mon Sep 17 00:00:00 2001 From: bigemon Date: Thu, 14 May 2026 08:39:10 +0800 Subject: [PATCH] docs: add VR ticket UniApp supplement (data structures, API specs, interaction spec) --- docs/vr-ticket-uniapp-supplement.md | 737 ++++++++++++++++++++++++++++ 1 file changed, 737 insertions(+) create mode 100644 docs/vr-ticket-uniapp-supplement.md diff --git a/docs/vr-ticket-uniapp-supplement.md b/docs/vr-ticket-uniapp-supplement.md new file mode 100644 index 00000000..34eb8895 --- /dev/null +++ b/docs/vr-ticket-uniapp-supplement.md @@ -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() +// 每个已选座位渲染一个表单块 +` +
+
观演人 ${i+1} (${seatLabel})
+
+ + +
+
+ + +
+
+ + +
+
+` +``` + +--- + +## 四、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 原规划 |