# VR 演唱会票务小程序 — 完整实现文档 > 最后更新:2026-04-21 > 用途:给任意 agent 独立阅读并推进事务 > 仓库:`http://xmhome.ow-my.com:3000/sileya-ai/vr-shopxo-plugin` > 本地路径:`/Users/bigemon/WorkSpace/vr-shopxo-plugin` > ShopXO 容器:localhost:10000(Web)/ localhost:10001(MySQL)/ localhost:9000(PHP-FPM) --- ## 一、项目概览 ### 1.1 目标产品 VR 演唱会票务微信小程序。用户选座 → 填观演人 → 微信支付 → 获取电子票二维码 → 现场扫码核销。 ### 1.2 技术栈 - **前端**:原生 HTML + CSS + JS(无框架),商品详情页使用 `ticket_detail.html` - **后端**:ShopXO(ThinkPHP 8)插件 `vr_ticket` - **数据库**:ShopXO MySQL,表前缀 `vrt_` - **微信支付**:ShopXO 原生微信支付 ### 1.3 核心表结构 | 表名 | 用途 | |------|------| | `vrt_vr_seat_templates` | 座位模板(座位图画法 + 绑定分类) | | `vrt_vr_tickets` | 电子票(order_id + seat_info + real_name/phone/id_card) | | `vrt_vr_verifiers` | 核销员 | | `vrt_vr_verifications` | 核销记录 | | `vrt_vr_audit_log` | 操作审计日志 | ShopXO 原生表: | 表名 | 用途 | |------|------| | `goods` | 商品(含 `vr_goods_config` 扩展 JSON 字段) | | `goods_spec_base` | SKU(库存/价格),`extends` 含 `seat_key` | | `goods_spec_value` | spec 维度值(4维度:场馆/分区/座位号/场次) | | `order` | 订单(含 `extension_data` JSON 字段) | | `order_detail` | 订单明细 | ### 1.4 spec 四维度说明 ShopXO 每个 GoodsSpecBase(SKU)由 4 个 spec type-value 联合确定: | type | 说明 | 示例 value | |------|------|-----------| | `$vr-场馆` | 场馆名 | `VR 体验馆` | | `$vr-分区` | 场馆+演播厅+分区 | `VR 体验馆-1号演播厅-VIP区` | | `$vr-座位号` | 完整路径座位名 | `VR 体验馆-1号演播厅-VIP区-A-1排3座` | | `$vr-场次` | 场次时间 | `15:00-16:59` | **注意**:spec value 是**完整路径字符串**,不是 `"A_3"` 或 `"roomId_A_3"` 这种短格式。 ### 1.5 座位的唯一标识(seatKey) 前后端共用同一个格式:`{roomId}_{rowLabel}_{colNum}` - `roomId`:`rooms[].id`,来自 `vr_goods_config.template_snapshot.rooms` - `rowLabel`:座位行标签,`A`/`B`/`C`(由 map 行索引计算:`String.fromCharCode(65 + rowIndex)`) - `colNum**:列号(从 1 开始:`colIndex + 1`) 示例:`"room_001_A_3"` = room_001 的 A排 第3列 seatKey 对应 `GoodsSpecBase.extends.seat_key`,用于关联 GoodsSpecBase 和前端座位 DOM。 --- ## 二、现状与已知问题 ### Phase 0/1 完成情况 ✅ `Goods.php` 判断 `item_type='ticket'` → 渲染 `ticket_detail.html` ✅ `ticket_detail.html` 座位图渲染 + 选座 JS + 观演人表单 ✅ `SeatSkuService::GetGoodsViewData()` 返回座位图数据 ✅ `TicketService::onOrderPaid()` 支付成功后生成 `vr_tickets` ✅ 4 个后台管理控制器(座位模板/票/核销员/核销记录) ✅ 基础防超卖幂等保护 ### Phase 2 待修复问题(源自 Council 评估 + 大头确认) | # | 问题 | 优先级 | 状态 | |---|------|--------|------| | Issue 1 | 购买提交流程失效(GET→POST 机制错误 + spec 格式错误 + 缺 seatSpecMap) | **P0** | 待修复 | | Issue 2 | 缩放时舞台不跟随 | **P1** | 待修复 | | Issue 3 | spec 加载(loadSoldSeats 空 stub + 无 sold_seats API) | **P1** | 待修复 | | Issue 4 | 商品详情/图片加载 | **P2** | 待修复 | | Issue 5 | GetGoodsViewData 只返回第一个场次 | **P2** | 待修复 | **核心问题说明**(Issue 1 P0): Issue 1 不是单一 bug,而是三层叠加问题: 1. `submit()` 用 `location.href`(GET),ShopXO `Buy::Index` 只在 POST 时调用 `BuyDataStorage` 2. spec 格式错误:只传 1 维度而非 4 维度 3. **最严重**:前端根本没有 seatSpecMap,无法把座位 DOM 映射到正确的 GoodsSpecBase --- ## 三、商品118 vr_goods_config(原始数据库数据) 存储位置:`goods` 表 `vr_goods_config` JSON 字段(商品 ID = 118) 这是从数据库直接读取的原始数据,**所有其他数据结构均派生于此**。 ```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": "VR 演唱会馆", "address": "北京市朝阳区建国路88号", "location": { "lng": "116.45792", "lat": "39.90745" }, "images": [ "/static/attachments/202603/venue_001.jpg", "/static/attachments/202603/venue_002.jpg" ] }, "rooms": [ { "id": "room_001", "name": "1号演播厅", "map": [ "AAAAA_____BBBBB", "AAAAA_____BBBBB", "AAAAA_____BBBBB", "CCCCCCCCCCCCCCC", "CCCCCCCCCCCCCCC" ], "sections": [ { "char": "A", "name": "VIP区", "price": 380, "color": "#f06292" }, { "char": "B", "name": "看台区", "price": 180, "color": "#4fc3f7" }, { "char": "C", "name": "普通区", "price": 80, "color": "#81c784" } ], "seats": { "A": { "char": "A", "name": "VIP区", "price": 380, "color": "#f06292" }, "B": { "char": "B", "name": "看台区", "price": 180, "color": "#4fc3f7" }, "C": { "char": "C", "name": "普通区", "price": 80, "color": "#81c784" } } }, { "id": "room_002", "name": "2号演播厅(副厅)", "map": [ "DDDDDDD", "DDDDDDD", "EEEEEEE" ], "sections": [ { "char": "D", "name": "互动区", "price": 280, "color": "#ffb74d" }, { "char": "E", "name": "站票区", "price": 50, "color": "#90a4ae" } ], "seats": { "D": { "char": "D", "name": "互动区", "price": 280, "color": "#ffb74d" }, "E": { "char": "E", "name": "站票区", "price": 50, "color": "#90a4ae" } } } ] } } ] ``` ### 字段说明 | 字段 | 含义 | 前端是否可用 | |------|------|------------| | `version` | 协议版本(当前 3.0) | ❌ 内部使用 | | `template_id` | 关联座位模板 ID | ❌ 内部使用 | | `selected_rooms` | 启用的房间 ID 列表 | ✅ 用于初始化 | | `selected_sections` | 每个房间选中的分区字符 | ✅ 用于默认高亮 | | `sessions` | 场次列表(start/end) | ✅ **场次选择器数据源** | | `template_snapshot.venue` | 场馆信息 | ✅ Banner/详情展示 | | `template_snapshot.rooms[].id` | 房间唯一 ID | ✅ **seatKey 构造必需** | | `template_snapshot.rooms[].map` | 座位图字符矩阵 | ✅ **座位图渲染必需** | | `template_snapshot.rooms[].sections` | 分区列表(char→name/price/color) | ✅ **图例+分区选择器** | | `template_snapshot.rooms[].seats` | char→座位属性映射 | ✅ **查座位详情** | ### map 格式说明 ``` "AAAAA_____BBBBB" ↓分解为字符数组↓ ['A','A','A','A','A','_','_','_','_','_','B','B','B','B','B'] ←VIP区×5→←空位×5→←看台区×5→ 字符含义: A/B/C/D/E = 座位(通过 rooms[i].seats[char] 查属性) '_' / '-' = 空位(不渲染座位) 其他非字母 = 不渲染 ``` ### rooms.seats 与 rooms.sections 的关系 同一个 char 在不同房间代表不同分区: - `room_001` 的 `A` = VIP区(红色,380元) - `room_002` 的 `D` = 互动区(橙色,280元) **分区信息在 `sections[]` 里**,不要直接用 char 本身判断分区名称或价格。 --- ## 四、后端注入的模板数据 `Goods.php` 在渲染 `ticket_detail.html` 前,通过 `SeatSkuService::GetGoodsViewData()` 向模板注入以下变量: ```php MyViewAssign([ 'vr_seat_template' => $viewData['vr_seat_template'], // 座位图原始数据 'goods_spec_data' => $viewData['goods_spec_data'], // 场次列表 'seatSpecMap' => $viewData['seatSpecMap'] ?? [], // 【待新增】座位→4维spec映射 ]); ``` 模板中接收方式: ```javascript var vrSeatTemplate = ; var goodsSpecData = ; var seatSpecMap = ; ``` ### 4.1 vr_seat_template(透传 template_snapshot) ```javascript { venue: { name: "VR 演唱会馆", address: "北京市朝阳区建国路88号", location: { lng: "116.45792", lat: "39.90745" }, images: ["/static/attachments/202603/venue_001.jpg"] }, rooms: [ { id: "room_001", name: "1号演播厅", map: ["AAAAA_____BBBBB", "AAAAA_____BBBBB", "AAAAA_____BBBBB", "CCCCCCCCCCCCCCC", "CCCCCCCCCCCCCCC"], sections: [ { char: "A", name: "VIP区", price: 380, color: "#f06292" }, { char: "B", name: "看台区", price: 180, color: "#4fc3f7" }, { char: "C", name: "普通区", price: 80, color: "#81c784" } ], seats: { /* 同第二章 seats */ } }, { id: "room_002", name: "2号演播厅(副厅)", map: ["DDDDDDD", "DDDDDDD", "EEEEEEE"], sections: [ /* 同第二章 sections */ ], seats: { /* 同第二章 seats */ } } ], sessions: [ { start: "15:00", end: "16:59" }, { start: "18:00", end: "20:59" } ], selectedRooms: ["room_001", "room_002"], selectedSections: { "room_001": ["A", "B"], "room_002": ["A"] } } ``` ### 4.2 goods_spec_data(场次列表) ```javascript // 来源:goods.vr_goods_config.sessions + ShopXO GoodsSpecBase.price [ { spec_id: 2001, spec_name: "15:00-16:59", price: 380, start: "15:00", end: "16:59" }, { spec_id: 2002, spec_name: "18:00-20:59", price: 280, start: "18:00", end: "20:59" } ] ``` ### 4.3 seatSpecMap(待新增,核心数据结构) **来源**:`GetGoodsViewData()` 查询 `GoodsSpecBase` + `GoodsSpecValue` + `GoodsSpecBase.extends`,动态构建 **用途**:前端选中座位后,查表获取该座位的完整 4 维 spec 数组 + 对应 GoodsSpecBase ```javascript // key 格式:{roomId}_{rowLabel}_{colNum} // 示例:room_001_A_3 = room_001 的 A排 第3列 { "room_001_A_1": { spec_base_id: 10001, price: 380, inventory: 1, // 0 = 已售,1 = 可购 rowLabel: "A", colNum: 3, roomId: "room_001", section: { char: "A", name: "VIP区", color: "#f06292" }, // === 4维 spec 数组(submit() 时直接使用)=== spec: [ { type: "$vr-场馆", value: "VR 演唱会馆" }, { type: "$vr-分区", value: "VR 演唱会馆-1号演播厅-VIP区" }, { type: "$vr-座位号", value: "VR 演唱会馆-1号演播厅-VIP区-A-1排1座" }, { type: "$vr-场次", value: "15:00-16:59" } ] }, "room_001_A_2": { /* 同上,A排第2座 */ }, "room_001_B_8": { spec_base_id: 10025, price: 180, inventory: 0, /* 已售 */ }, "room_002_D_1": { spec_base_id: 20001, price: 280, inventory: 1, /* 互动区 */ }, // ...每个可购座位一行 } ``` #### seatSpecMap 生成逻辑(GetGoodsViewData 中实现) ```php // 1. 查询所有有效 GoodsSpecBase(含 extends.seat_key) $specs = Db::name('GoodsSpecBase') ->where('goods_id', $goodsId) ->where('inventory', '>', 0) // 只取有库存的 ->select(); // 2. 查询对应的 GoodsSpecValue(4个维度的 type/value) $specIds = array_column($specs->toArray(), 'id'); $specValues = Db::name('GoodsSpecValue') ->whereIn('goods_spec_base_id', $specIds) ->select(); // 3. 按 spec_base_id 分组,构建 4维 spec 数组 $specByBaseId = []; foreach ($specValues as $sv) { $specByBaseId[$sv['goods_spec_base_id']][] = [ 'type' => $sv['type'], 'value' => $sv['value'], ]; } // 4. 构建 seatSpecMap $seatSpecMap = []; foreach ($specs as $spec) { $extends = json_decode($spec['extends'] ?? '{}', true); $seatKey = $extends['seat_key'] ?? ''; if (empty($seatKey)) continue; $seatSpecMap[$seatKey] = [ 'spec_base_id' => intval($spec['id']), 'price' => floatval($spec['price']), 'inventory' => intval($spec['inventory']), 'spec' => $specByBaseId[$spec['id']] ?? [], ]; } ``` --- ## 五、产品形态:多维度 spec 选择器 + 多座位选择 ### 5.1 界面结构 ``` ┌─────────────────────────────────────────────────────┐ │ 顶部 Banner(venue.images) │ │ │ │ 场次选择 │ │ [●15:00-16:59 ¥380] [ 18:00-20:59 ¥280 ] │ │ │ │ 场馆/分区选择(spec 选择器交互) │ │ [●1号演播厅] [ 2号演播厅 ] │ │ [●VIP区380] [ 看台区180 ] [ 普通区80 ] │ │ │ │ ─────────── 座位图(多选)───────────────────── │ │ 舞 台 │ │ A排 [■■■■■] ← 可选(VIP,红色) │ │ B排 [■■■■■] ← 可选(看台,蓝色) │ │ C排 [灰掉] ← 不在当前分区 │ │ │ │ 图例:[■]可选 [██]已售 [░░]不可选 │ │ │ │ ─────────── 观演人表单 ───────────────────────── │ │ 第1张票:张三 138****000 身份证(选填) │ │ 第2张票:李四 139****111 身份证(选填) │ │ │ │ ─────────── 底部价格栏 ───────────────────────── │ │ 已选 2 座,合计 ¥760 [提交订单] │ └─────────────────────────────────────────────────────┘ ``` ### 5.2 spec 选择器交互(参考原生 ShopXO spec 选择器行为) 用户切换场次/场馆/分区时,未在当前选择分支内的座位自动变灰/隐藏: ``` 切换场次 → 重置场馆/分区/座位 → 用 seatSpecMap 过滤出该场次所有座位 切换场馆 → 重置分区/座位 → 用 seatSpecMap 过滤出该场馆所有座位 切换分区 → 只灰掉其他分区座位 → 用 seatSpecMap 过滤出该分区座位 点击座位 → 复选/取消 → 更新 selectedSeats[] ``` ```javascript // 过滤函数 function filterSeatMap(currentSession, currentVenueName, currentSectionChar) { Object.entries(seatSpecMap).forEach(function([seatKey, seatInfo]) { var spec = seatInfo.spec; // 4维数组 var hasSession = spec.some(function(s) { return s.type === '$vr-场次' && s.value === currentSession; }); var hasVenue = spec.some(function(s) { return s.type === '$vr-场馆' && s.value.includes(currentVenueName); }); var hasSection = !currentSectionChar || spec.some(function(s) { return s.type === '$vr-分区' && s.value.includes(currentSectionChar); }); var isAvailable = seatInfo.inventory > 0; var seatEl = document.querySelector('[data-seat-key="' + seatKey + '"]'); if (!seatEl) return; if (hasSession && hasVenue && hasSection) { seatEl.classList.toggle('sold', !isAvailable); seatEl.classList.toggle('disabled', false); } else { seatEl.classList.add('disabled'); seatEl.classList.remove('sold'); } }); } ``` ### 5.3 从 vr_seat_template 渲染座位图 ```javascript function renderSeatMap() { var rooms = vrSeatTemplate.rooms; rooms.forEach(function(room) { room.map.forEach(function(rowStr, rowIndex) { var rowLabel = String.fromCharCode(65 + rowIndex); // 0→A, 1→B var chars = rowStr.split(''); // 逐字符(PHP mb_str_split 兼容) chars.forEach(function(char, colIndex) { if (char === '_' || char === '-') { // 渲染空白格子 return; } var colNum = colIndex + 1; // 列号从 1 开始 var seatKey = room.id + '_' + rowLabel + '_' + colNum; // "room_001_A_3" var seatInfo = room.seats[char]; // 查到座位属性 // 创建座位 DOM 元素 var seatEl = document.createElement('div'); seatEl.className = 'vr-seat'; seatEl.dataset.seatKey = seatKey; seatEl.dataset.rowLabel = rowLabel; seatEl.dataset.colNum = colNum; seatEl.dataset.char = char; seatEl.dataset.roomId = room.id; seatEl.style.backgroundColor = seatInfo.color; seatEl.textContent = rowLabel + colNum; // 点击事件:选座/取消 seatEl.addEventListener('click', function() { toggleSeat(seatEl, seatKey); }); document.getElementById('room_' + room.id + '_seats').appendChild(seatEl); }); }); }); } ``` --- ## 六、submit() 正确实现(P0 Issue 1 核心修复) ### 6.1 当前错误代码 原始 `ticket_detail.html` 中的 `submit()` 使用 `location.href`(GET),ShopXO `Buy::Index` 只在 POST 时存储数据,导致购买流程失效。 ### 6.2 修复后的 submit() ```javascript // var self = this; — 原始代码第6行已有此声明 submit: function() { var self = this; // 1. 收集观演人 var inputs = document.querySelectorAll('#attendeeList input'); var attendeeData = []; inputs.forEach(function(input) { var idx = parseInt(input.dataset.index); if (!attendeeData[idx]) attendeeData[idx] = {}; attendeeData[idx][input.dataset.field] = input.value; }); // 2. 验证已选座位和观演人数量匹配 if (this.selectedSeats.length === 0) { alert('请至少选择一个座位'); return; } if (this.selectedSeats.length !== attendeeData.length) { alert('座位数与观演人信息数量不匹配'); return; } // 3. 构建 ShopXO 原生 goods_data 格式 // // ⚠️ 【必须】order_base.extension_data 是 BuyService 隐含契约(BuyService.php 第86行) // 必须嵌套在 order_base 内!不能平铺在 goods_data 第一层! // // ⚠️ 【必须】直接传 JSON 字符串,不需要 base64 // BuyService.php 第60行:!is_array($_POST['goods_data']) → json_decode() // ShopXO 标准 buy/index.html 第871行也是直接输出 JSON 字符串,不 base64 // // ⚠️ 【必须】spec 是完整的 4维数组,不是 1 维! // 从 seatSpecMap[seatKey].spec 读取,不要自己构造 // // ⚠️ requestUrl 来自 PHP 模板注入:var requestUrl = ''; // 确认 Goods.php 在传给票务模板时已在 $assign 中注入 host_url // var goodsDataList = this.selectedSeats.map(function(seat, i) { var seatInfo = seatSpecMap[seat.seatKey]; // 从注入的 seatSpecMap 查 if (!seatInfo) { console.error('seatSpecMap missing key:', seat.seatKey); return null; } return { goods_id: self.goodsId, spec: seatInfo.spec, // 4维完整 spec 数组!从 seatSpecMap 来! stock: 1, order_base: { // ← 必须嵌套!不能平铺! extension_data: { attendee: { real_name: attendeeData[i]?.real_name || '', phone: attendeeData[i]?.phone || '', id_card: attendeeData[i]?.id_card || '' } } } }; }).filter(Boolean); // 4. 过滤无效座位 if (goodsDataList.length === 0) { alert('座位信息无效,请重新选择'); return; } // 5. 隐藏表单 POST 到 ShopXO Buy 链路 var form = document.createElement('form'); form.method = 'POST'; form.action = requestUrl + '?s=index/buy/index'; document.body.appendChild(form); var input = document.createElement('input'); input.type = 'hidden'; input.name = 'goods_data'; input.value = JSON.stringify(goodsDataList); // 直接 JSON,BuyService 自动处理 form.appendChild(input); form.submit(); // POST → Buy::Index → BuyDataStorage → 跳转确认页 } ``` ### 6.3 ShopXO Buy 链路完整数据流(已验证可用) ``` submit() POST goods_data(含 4维spec + extension_data) │ ├─→ Buy::Index (POST) → BuyDataStorage(user_id, data_post) [存入 session, TTL=21600s] │ ↑ │ goods_data 是数组,json_encode 存入 session │ └─→ 跳转 Buy::Index (GET) → BuyDataRead → 显示确认页 │ ┌───────────────────────────────┘ │ └─→ form submit → Buy::Add → BuyService::OrderInsert($params) │ BuyTypeGoodsList($params) → BuyGoods($params) │ foreach($params['goods_data'] as $v) ← 多 SKU 原生遍历 │ GoodsSpecificationsHandle($v) → GoodsSpecDetail() │ 4维 type-value 匹配 GoodsSpecValue 表 ↓ OrderInsertHandle($order_data) │ BuyService.php 第773行: 'extension_data' => json_encode($v['order_base']['extension_data']) │ Db::name('order')->insertGetId($order) ← extension_data 写入 Order 表 │ 微信支付... │ ┌────────────────────────────────┘ │ └─→ 支付成功 → Hook: plugins_service_order_pay_success_handle_end │ TicketService::onOrderPaid($params) │ Db::name('order')->find($order_id) ↓ json_decode($order['extension_data']) → 观演人信息 ↓ foreach($order_goods as $og) { issueTicket($order, $og) // 幂等保护:seat_info 查重 } │ Db::name('vr_tickets')->insertGetId([ 'order_id' => $order['id'], 'seat_info' => $spec_name, 'real_name' => $attendee['real_name'], 'phone' => $attendee['phone'], 'id_card' => $attendee['id_card'], 'ticket_code'=> $uuid, 'qr_data' => AES加密(payload), ]); ``` --- ## 七、完整修复清单 | 优先级 | Issue | 任务 | 依赖 | 负责 | |--------|-------|------|------|------| | **P0** | Issue 1 | 重构 `GetGoodsViewData()` 新增 `seatSpecMap` | 后端 | BackendArchitect | | **P0** | Issue 1 | 前端 JS 用 `seatSpecMap` 替代 `specBaseIdMap` | P0 前置 | FrontendDev | | **P0** | Issue 1 | 修复 `submit()`:GET→POST + 正确 4维 spec 数组 | P0 前置 | FrontendDev | | **P0** | Issue 1 | Goods.php `MyViewAssign` 加入 `seatSpecMap` | P0 前置 | BackendArchitect | | **P1** | Issue 1 | 实现场次/场馆/分区 spec 选择器 UI + `filterSeatMap()` | P0 前置 | FrontendDev | | **P1** | Issue 1 | `selectSession()` / `selectVenue()` / `selectSection()` 联动逻辑 | P1 前置 | FrontendDev | | **P1** | Issue 2 | 缩放时舞台跟随(zoom wrapper 方案) | 无 | FrontendDev | | **P1** | Issue 3 | 新增 `sold_seats` API 端点 | 无 | BackendArchitect | | **P1** | Issue 3 | 前端 `loadSoldSeats()` 调用 API + 标记 `.sold` | P1 前置 | FrontendDev | | **P2** | Issue 4 | 商品详情图片展示(确认需求,补充 CSS) | 无 | FrontendDev | | **P2** | Issue 5 | `GetGoodsViewData()` 返回数组而非 `validConfigs[0]` | 无 | BackendArchitect | | **P2** | 审计 | 验证 `onOrderPaid` spec 匹配 + 幂等保护(FOR UPDATE) | 无 | BackendArchitect | --- ## 八、关键代码索引 | 文件 | 行号 | 说明 | |------|------|------| | `Buy.php` | 58-61 | Index() — POST/GET 分支,BuyDataStorage/BuyDataRead | | `BuyService.php` | 51-62 | BuyGoods — goods_data 参数校验 + JSON decode(非 base64) | | `BuyService.php` | 86 | `foreach($params['goods_data'] as $v)` — 多 SKU 原生遍历 | | `BuyService.php` | 104-109 | GoodsSpecDetail — 4维 type-value 匹配 GoodsSpecValue | | `BuyService.php` | 773 | `extension_data => json_encode($v['order_base']['extension_data'])` | | `BuyService.php` | 1932 | BuyDataStorage — 21600s TTL session 缓存 | | `buy/index.html` | 871 | 原生 form hidden goods_data field(JSON 字符串,非 base64) | | `TicketService.php` | 21-22 | Hook: `plugins_service_order_pay_success_handle_end` → `onOrderPaid` | | `TicketService.php` | 141-143 | `issueTicket` — 从 `$order['extension_data']` 读观演人 | | `SeatSkuService.php` | 40-45 | `SPEC_DIMS = ['$vr-场馆','$vr-分区','$vr-座位号','$vr-场次']` | | `SeatSkuService.php` | ~368 | GetGoodsViewData — validConfigs[0] 多场次 Bug | | `SeatSkuService.php` | ~131 | BatchGenerate — 4维 spec value 构建(完整路径字符串) | | `Hook.php` | 21-22 | `plugins_service_order_pay_success_handle_end` → TicketService::onOrderPaid | --- ## 九、第一性原则(设计决策记录) 1. **座位唯一性靠 ShopXO 原生 inventory**:每个 GoodsSpecBase 的 `inventory=1`,ShopXO 在 `OrderInsertHandle` 中扣库存,防超卖由 ShopXO 原生保证,不需要自己实现锁。 2. **`spec_base_id_map` 是性能缓存**:理想情况下 `onOrderPaid` 通过 `seat_key` 查询 GoodsSpecBase 即可,不需要 map 字段。但保留是合理的优化。 3. **`extension_data` 存储完全在 ShopXO 生态内**:不新建表,不扩展 ShopXO 字段,`order.extension_data` → `onOrderPaid` → `vr_tickets` 全链路 ShopXO 原生。 4. **`onOrderPaid` spec 匹配存在潜在 bug**(⚠️ 未来需关注): - `BatchGenerate` 写入 GoodsSpecValue.value 格式:`"VR 演唱会馆-1号演播厅-VIP区-A-1排3座"`(长路径字符串) - 前端 seatKey 格式:`"room_001_A_3"`(短格式) - 两者不匹配,`issueTicket` 第57-77行的反向 spec 查找会失效 - 目前不影响功能(幂等靠 `seat_info` 字段,不依赖 spec_base_id) - 未来如需精确关联,需修复 BatchGenerate 的 value 写入格式 5. **最小修复原则**:Issue 1 的修复只需改 `submit()` 函数(POST + 正确 4维 spec 格式 + extension_data)。不需要重构 spec 系统,不需要绕过 Buy 链路。 --- *本文档为 vr-shopxo-plugin Phase 2 完整实现文档,Agent 可独立阅读并推进事务。*