diff --git a/docs/AGENT_PROMPT.md b/docs/AGENT_PROMPT.md
new file mode 100644
index 0000000..e393fee
--- /dev/null
+++ b/docs/AGENT_PROMPT.md
@@ -0,0 +1,160 @@
+# Agent 执行 Prompt — VR 演唱会票务小程序 Phase 2
+
+## 前提条件(必读)
+
+你正在帮助开发一个 **ShopXO 票务插件(vr_ticket)**。
+
+- 仓库:`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)
+- DB 用户:root / shopxo_root_2024,表前缀:`vrt_`
+
+**完整文档路径**:`/Users/bigemon/WorkSpace/vr-shopxo-plugin/docs/FULL_PLAN.md`
+
+> ⚠️ 在做任何事情之前,**必须先读 `FULL_PLAN.md`**,理解完整上下文后再开始。
+
+---
+
+## 项目背景(一句话)
+
+VR 演唱会票务微信小程序插件。用户选座 → 填观演人 → 微信支付 → 电子票二维码 → 现场扫码核销。
+
+---
+
+## 当前优先级
+
+### P0(阻塞一切)
+
+**Issue 1 修复**:购买提交流程完全失效,有三层叠加问题。
+
+**顺序**:
+
+1. **后端**:修改 `SeatSkuService::GetGoodsViewData()`,新增 `seatSpecMap` 生成逻辑
+ - 查询 `GoodsSpecBase` + `GoodsSpecValue` + `GoodsSpecBase.extends.seat_key`
+ - 输出 `seatSpecMap[seatKey] = {spec_base_id, price, inventory, spec: [...]}`
+ - 完整逻辑见 `FULL_PLAN.md` 第 4.3 节
+
+2. **后端**:修改 `Goods.php`,在 `MyViewAssign` 中加入 `seatSpecMap`
+
+3. **前端**:修改 `ticket_detail.html`,用 `seatSpecMap` 替代当前错误的 `specBaseIdMap`
+
+4. **前端**:修复 `submit()` 函数
+ - 改 GET → POST 隐藏表单(**不是** `location.href`)
+ - spec 必须是**完整的 4 维数组**:`[{type:'$vr-场馆',value:'...'},{type:'$vr-分区',value:'...'},{type:'$vr-座位号',value:'...'},{type:'$vr-场次',value:'...'}]`
+ - **不是** `{type:'$vr-座位号', value: seatKey}` — 这是错的
+ - spec 从 `seatSpecMap[seatKey].spec` 读取,**不要自己构造**
+ - `extension_data` 必须嵌套在 `order_base` 内,**不是平铺在第一层**
+ - 直接 `JSON.stringify`,**不需要 base64**
+
+### P1
+
+5. **前端**:`ticket_detail.html` 新增场次/场馆/分区选择器 UI + `filterSeatMap()` 联动过滤
+6. **前端**:缩放时舞台跟随(zoom wrapper 方案)
+7. **后端**:新增 `sold_seats` API 端点 + 前端 `loadSoldSeats()` 调用
+
+### P2
+
+8. 商品详情图片展示(确认需求)
+9. `GetGoodsViewData()` 多场次返回数组而非 `validConfigs[0]`
+10. `onOrderPaid` spec 匹配审计(未来关注,不阻塞)
+
+---
+
+## 绝对禁止事项
+
+- ❌ **不要**用 `location.href` 传递购买参数(ShopXO 只在 POST 时存储数据)
+- ❌ **不要**把 spec 格式写成 `{type:'$vr-座位号', value: 'room_001_A_3'}` — 这是错的
+- ❌ **不要**把 `extension_data` 平铺在 `goods_data` 第一层 — 必须嵌套在 `order_base` 里
+- ❌ **不要**在 submit() 里对 `goods_data` 做 base64 — 直接 `JSON.stringify` 即可
+- ❌ **不要**修改 `BuyService.php` 的核心逻辑 — 所有修复都在前端和插件后端做
+- ❌ **不要**新建数据库表来存观演人信息 — 用 ShopXO 原生的 `order.extension_data`
+
+---
+
+## 常见错误警告
+
+### spec 数组格式(最高频错误)
+
+```
+错误:
+spec: [{type:'$vr-座位号', value: 'room_001_A_3'}]
+
+正确(完整4维):
+spec: [
+ {type:'$vr-场馆', value: 'VR 演唱会馆'},
+ {type:'$vr-分区', value: 'VR 演唱会馆-1号演播厅-VIP区'},
+ {type:'$vr-座位号', value: 'VR 演唱会馆-1号演播厅-VIP区-A-1排3座'},
+ {type:'$vr-场次', value: '15:00-16:59'}
+]
+```
+
+### order_base 嵌套(第二高频错误)
+
+```
+错误:
+{
+ goods_id: 118,
+ spec: [...],
+ extension_data: {...} ← 平铺!错!
+}
+
+正确:
+{
+ goods_id: 118,
+ spec: [...],
+ order_base: { ← 必须嵌套在 order_base 内!
+ extension_data: {...}
+ }
+}
+```
+
+### goods_data 编码(第三高频错误)
+
+```
+错误:
+input.value = btoa(JSON.stringify(goodsDataList)) ← 不需要 base64!
+
+正确:
+input.value = JSON.stringify(goodsDataList) ← 直接 JSON 字符串
+```
+
+ShopXO `BuyService::BuyGoods` 第60行判断 `!is_array($_POST['goods_data'])` 才会 decode,直接 POST JSON 字符串即可。
+
+---
+
+## 快速参考
+
+| 我需要知道 | 去哪里找 |
+|-----------|---------|
+| 完整上下文 + 修复方案 | `FULL_PLAN.md` |
+| 原始 goods.vr_goods_config 数据 | `FULL_PLAN.md` 第二章 |
+| seatSpecMap 正确结构 | `FULL_PLAN.md` 4.3 节 |
+| submit() 正确实现 | `FULL_PLAN.md` 第六章 |
+| Buy 链路数据流 | `FULL_PLAN.md` 6.3 节 |
+| 关键代码行号索引 | `FULL_PLAN.md` 第八章 |
+| spec 选择器设计 | `FULL_PLAN.md` 第五章 |
+| 座位图渲染方法 | `FULL_PLAN.md` 5.3 节 |
+
+---
+
+## 工作流程
+
+1. **读** `FULL_PLAN.md` 全文(必读)
+2. **确认**你理解了 4 维 spec 结构 + seatSpecMap 映射关系
+3. **按优先级顺序**处理 P0 → P1 → P2
+4. **每完成一个模块**,在本地测试验证后再继续
+5. **commit 前**:`git status` 检查暂存区,不提交 binary(图片/压缩包),不在本仓库 push 到远程
+
+---
+
+## commit 规范
+
+```
+feat(Phase2): [模块名] [简短描述]
+
+示例:
+feat(Phase2): SeatSkuService GetGoodsViewData 新增 seatSpecMap 生成
+feat(Phase2): ticket_detail.html 修复 submit() POST + 4维spec数组
+```
+
+**注意**:本仓库是 fork,不直接 push 到 upstream。只 commit 到本地,汇报给大头后由他处理上游合并。
diff --git a/docs/COUNCIL_PHASE2_ASSESSMENT_CORRECTED.md b/docs/COUNCIL_PHASE2_ASSESSMENT_CORRECTED.md
new file mode 100644
index 0000000..3056691
--- /dev/null
+++ b/docs/COUNCIL_PHASE2_ASSESSMENT_CORRECTED.md
@@ -0,0 +1,255 @@
+# VR 演唱会票务小程序 Phase 2 技术评估报告(修正版)
+
+> 日期:2026-04-21
+> 协作产出:BackendArchitect、FrontendDev、FirstPrinciples
+> 修正:大头 + 西莉雅(2026-04-21 上午)
+> 源码依据:BuyService.php、GoodsCartService.php、SeatSkuService.php、ticket_detail.html、vr_tickets install.sql
+
+---
+
+## 执行摘要
+
+Phase 2 完成 4 个已知问题的根因分析 + 1 个新发现潜在 Bug。**经大头确认后,修正了 FirstPrinciples 的关键错误结论。**
+
+**核心修正**:FirstPrinciples「购物车对票务无价值」的结论是**错误的**。Buy 链路是正确方向,ShopXO 原生支持多 SKU 合并下单 + extension_data 透传 + onOrderPaid 写入 vr_tickets。只需修复 submit() 的传递方式。
+
+---
+
+## 问题总览
+
+| # | 问题 | 优先级 | 根因 |
+|---|------|--------|------|
+| 1 | 购买提交流程失效 | **P0** | GET→POST 机制错误 + spec 字段格式错误 |
+| 2 | 缩放时舞台不跟随 | **P1** | DOM 结构导致 transform 不共享 |
+| 3 | spec 加载问题(已回滚) | **P1** | loadSoldSeats() 是空 stub + 需 sold_seats API |
+| 4 | 商品详情/图片加载 | **P2** | 模板未引入内容组件 |
+
+**新发现**:
+| # | 问题 | 优先级 |
+|---|------|--------|
+| 5 | GetGoodsViewData() 只返回第一个场次 | **P2 潜在** |
+
+---
+
+## Issue 1(P0):购买提交流程失效
+
+### 根因(三层叠加)
+
+**第一层(致命)**:`location.href` 产生 GET,但 `Buy::Index()` 只在 POST 时调用 `BuyDataStorage()`。
+
+```php
+// Buy.php:58-61
+public function Index() {
+ if($this->data_post) {
+ BuyService::BuyDataStorage($user_id, $this->data_post); // ← POST 才执行
+ return MyRedirect(MyUrl('index/buy/index'));
+ } else {
+ $buy_data = BuyService::BuyDataRead($user_id); // GET → 读 session → 空
+ }
+}
+```
+
+→ `goods_params` URL 参数从未被读取 → `BuyDataStorage` 未被调用 → buy 确认页收不到数据 → "商品数据为空"。
+
+**第二层(严重)**:字段名 `goods_params` vs 期望的 `goods_data`。
+
+**第三层(中等)**:spec 格式不匹配:
+- 当前:`spec_base_id: int`(直接传 ID)
+- ShopXO:`spec: [{type, value}]` 字符串匹配 GoodsSpecValue 表
+
+### ShopXO Buy 链路完全支持多座位合并下单
+
+**ShopXO 原生能力验证**:
+- `BuyService::BuyGoods` 第86行:`foreach($params['goods_data'] as $v)` — 原生遍历多 SKU
+- `BuyService::OrderInsertHandle` 第773行:`'extension_data' => json_encode($v['order_base']['extension_data'])` — 原生写入 extension_data
+- `vr_tickets` install.sql 已有:`real_name`, `phone`, `id_card` 字段 ✅
+- `TicketService::issueTicket()` 第141行:从 `$order['extension_data']` 读取观演人 ✅
+
+### 正确修复方案(只需改 submit())
+
+```javascript
+// var self = this; — 原始代码第6行已有此声明,确保 submit() 上方作用域有 var self = this
+submit: function() {
+ var self = this; // 如作用域内已有则忽略此行
+
+ // 1. 收集观演人
+ var inputs = document.querySelectorAll('#attendeeList input');
+ var attendeeData = {};
+ inputs.forEach(function(input) {
+ var idx = input.dataset.index;
+ var field = input.dataset.field;
+ if (!attendeeData[idx]) attendeeData[idx] = {};
+ attendeeData[idx][field] = input.value;
+ });
+
+ // 2. 构建 ShopXO 原生 goods_data 格式
+ //
+ // ⚠️ 【必须】order_base.extension_data 是 BuyService 隐含契约(第86行 $v['order_base'])
+ // 必须嵌套在 order_base 内!不能平铺在 goods_data 第一层!
+ // ⚠️ 【必须】直接传 JSON 字符串,不需要 base64
+ // BuyService 第60行判断:!is_array($_POST['goods_data']) → json_decode()
+ // ShopXO 标准 buy/index.html 第871行也是直接输出 JSON 字符串,不 base64
+ var goodsDataList = this.selectedSeats.map(function(seat, i) {
+ var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
+ return {
+ goods_id: self.goodsId,
+ spec: [{type: '$vr-座位号', value: seat.seatKey}],
+ stock: 1,
+ order_base: { // ← 必须嵌套!不能平铺!
+ extension_data: {
+ attendee: {
+ real_name: attendeeData[i]?.real_name || '',
+ phone: attendeeData[i]?.phone || '',
+ id_card: attendeeData[i]?.id_card || ''
+ }
+ }
+ }
+ };
+ });
+
+ // 3. 隐藏表单 POST 到 Buy 链路
+ //
+ // ⚠️ requestUrl 来自 PHP 模板注入(ticket_detail.html 第6行):
+ // var requestUrl = '';
+ // 确认 Goods.php 在传给票务模板时已在 $assign 中注入 host_url
+ var form = document.createElement('form');
+ form.method = 'POST';
+ form.action = requestUrl + '?s=index/buy/index'; // 用模板注入的全局 requestUrl 变量
+ var input = document.createElement('input');
+ input.name = 'goods_data';
+ input.value = JSON.stringify(goodsDataList); // 直接 JSON,BuyService 自动处理
+ form.appendChild(input);
+ document.body.appendChild(form);
+ form.submit();
+}
+```
+
+**完整数据流**(ShopXO 原生,无需扩展):
+```
+submit() POST goods_data(含 order_base.extension_data)
+ → Buy::Index → BuyDataStorage(user_id, data_post) [存入 session]
+ → 跳转确认页(GET)→ form hidden field 携带 goods_data
+ → Buy::Add → BuyGoods → OrderInsertHandle
+ → order.extension_data 写入 Order 表
+ → 支付成功 → onOrderPaid → issueTicket()
+ → 从 $order['extension_data'] 读取观演人 → 写入 vr_tickets(real_name/phone/id_card) ✅
+```
+
+---
+
+## Issue 2(P1):缩放时舞台不跟随
+
+### 根因
+
+`.vr-stage` 和 `.vr-seat-rows` 是平级兄弟元素,transform 只作用于子树。
+
+### 修复方案
+
+```html
+
+```
+
+```css
+.vr-zoom-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ transform-origin: center top;
+ transition: transform 0.2s ease;
+}
+```
+
+缩放 JS 操作 `#zoomContainer` 的 `transform: scale()`,舞台和座位同步缩放。
+
+---
+
+## Issue 3(P1):spec 加载问题(已回滚)
+
+### 根因
+
+- `loadSoldSeats()` 是空 TODO stub,无任何 AJAX 调用
+- 后端无 `sold_seats` API 端点
+
+### 修复方案
+
+**后端**:新增 `plugins/vr_ticket/index/soldSeats` API 端点
+```
+GET /?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=index&pluginsaction=soldSeats
+Query: goods_id, spec_base_id
+Response: {code: 0, data: {sold_seats: ["A_1", "A_2", "B_5"]}}
+```
+
+**前端**:`loadSoldSeats()` 调用该接口,标记 `.sold` class。
+
+---
+
+## Issue 4(P2):商品详情/图片加载
+
+- `$goods['content']`:✅ 正常渲染
+- `$goods['images']`:⚠️ 数据存在但未使用
+- `.goods-detail-content` CSS:⚠️ 缺失
+
+如需展示商品图片,在模板中添加图片渲染逻辑。如票务详情页不需要 ShopXO 商品内容,可降级为「确认不需要」。
+
+---
+
+## Issue 5(P2 潜在):GetGoodsViewData 只返回第一个场次
+
+`SeatSkuService::GetGoodsViewData()` 第368行返回 `validConfigs[0]`,多场次商品只显示第一个场次。
+
+### 修复方向
+
+修改返回值格式为数组,前端根据选中场次索引读取对应数据。
+
+---
+
+## 第一性原则视角(修正后)
+
+1. **Issue 1 是「传输机制损坏」,不是「流程错误」**:Buy 链路完全正确,多 SKU 合并下单是 ShopXO 原生能力,不需要绕过。
+
+2. **extension_data 存储完全在 ShopXO 生态内**:`order.extension_data` → `onOrderPaid` → `vr_tickets` 全链路原生打通,不需要新建表或扩展字段。
+
+3. **`spec_base_id_map` 是性能缓存**:如果 `onOrderPaid` 能通过 seatKey(spec value 字符串)查询 spec_base_id,map 可以去掉。但保留是合理的优化。
+
+4. **`onOrderPaid` 是座位唯一性权威**(未审计):在 Issue 1 修复部署前,必须验证此 Hook 是否正确实现了座位锁定(幂等 + FOR UPDATE)。这是防双售的核心。
+
+5. **onOrderPaid spec 匹配存在潜在 bug(⚠️ 新增)**:`BatchGenerate` 写入 GoodsSpecValue.value 的格式是 `"{$venueName}-{$roomName}-{$char}-{$rowLabel}{$col}"`(如 "场馆A-放映室1-A-A3座"),而前端 seatKey 格式是 `"roomId_A_3"`,两者不匹配。`TicketService::issueTicket` 第57-77行通过 `type='$vr-座位号'` 匹配 GoodsSpecValue.value 的逻辑会失效。目前不影响功能是因为幂等靠 `seat_info` 字段(不需要 spec_base_id),但如果未来需要精确关联,此处需修复 value 写入格式或改为读 GoodsSpecBase.extends.seat_key。
+
+6. **最小修复范围**:只需修改 `submit()` 函数(POST + 正确 goods_data 格式 + extension_data)。不需要重构 spec 系统,不需要新建表,不需要绕过 Buy 链路。
+
+---
+
+## 修复优先级
+
+| 优先级 | Issue | 负责 | 修复说明 |
+|--------|------|------|---------|
+| P0 | Issue 1 submit() | FrontendDev | 改隐藏表单 POST,正确构造 goods_data + extension_data |
+| P1 | Issue 2 舞台缩放 | FrontendDev | 新增 zoom wrapper 容器 |
+| P1 | Issue 3 spec 加载 | BackendArchitect | 新增 sold_seats API + 前端调用 |
+| P2 | Issue 4 商品详情 | FrontendDev | 确认是否需要,补充 CSS |
+| P2 | Issue 5 多场次 | BackendArchitect | GetGoodsViewData 返回数组格式 |
+
+---
+
+## 附录:ShopXO Buy 链路关键代码索引
+
+| 文件 | 行号 | 说明 |
+|------|------|------|
+| `Buy.php` | 58-61 | Index() — POST/GET 分支,BuyDataStorage/BuyDataRead |
+| `BuyService.php` | 51-62 | BuyGoods — goods_data 参数校验 + base64/JSON 解码 |
+| `BuyService.php` | 86 | foreach($params['goods_data'] as $v) — 多 SKU 原生遍历 |
+| `BuyService.php` | 104-109 | GoodsSpecDetail 调用 — spec.value 字符串匹配 |
+| `BuyService.php` | 773 | OrderInsertHandle — extension_data 写入 order 表 |
+| `BuyService.php` | 1932 | BuyDataStorage — 21600s TTL session 缓存 |
+| `Buy/index.html` | 871 | 确认表单 hidden goods_data field(原生包含) |
+| `TicketService.php` | 141-143 | issueTicket — 从 $order['extension_data'] 读观演人 |
+| `SeatSkuService.php` | ~368 | GetGoodsViewData — validConfigs[0] 多场次 Bug |
+
+---
+
+*VR 演唱会票务小程序 Phase 2 技术评估 — Council 协作完成,2026-04-21 修正版*
diff --git a/docs/FULL_PLAN.md b/docs/FULL_PLAN.md
new file mode 100644
index 0000000..7fc5ada
--- /dev/null
+++ b/docs/FULL_PLAN.md
@@ -0,0 +1,680 @@
+# 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 可独立阅读并推进事务。*
diff --git a/docs/PLAN_PHASE3_EXECUTION.md b/docs/PLAN_PHASE3_EXECUTION.md
new file mode 100644
index 0000000..e83073c
--- /dev/null
+++ b/docs/PLAN_PHASE3_EXECUTION.md
@@ -0,0 +1,513 @@
+# Phase 3 前端执行计划
+
+> 日期:2026-04-21 | 状态:✅ 已完成
+> 关联:PLAN_PHASE3_FRONTEND.md + Issue #17
+> 策略:谨慎保守,稳扎稳打
+
+---
+
+## 一、目标
+
+**1 天内上线可演示的多座位下单 Demo**,验证购物车路线可行性。
+
+---
+
+## 二、现状盘点
+
+| 文件 | 当前状态 | 问题 |
+|------|---------|------|
+| `ticket_detail.html` | Plan A 代码有 bug | `submit()` URL 编码只传第一座、`selectSession()` 未重置座位 |
+| `ticket_detail.html` | 桩代码 | `loadSoldSeats()` 无实现 |
+| `ticket_detail.html` | 内联样式 | CSS 未分离,色值硬编码 |
+
+---
+
+## 三、执行步骤
+
+### Step 1:修复 `submit()` 函数(P0)
+
+**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`
+
+**改动**:替换 `submit()` 函数,改走购物车 API。
+
+```javascript
+submit: function() {
+ // 1. 前置检查
+ if (this.selectedSeats.length === 0) {
+ alert('请先选择座位');
+ return;
+ }
+ if (!this.userId) {
+ alert('请先登录');
+ location.href = this.requestUrl + '?s=index/user/logininfo';
+ return;
+ }
+
+ // 2. 收集观演人信息
+ var inputs = document.querySelectorAll('#attendeeList input');
+ var attendeeData = {};
+ inputs.forEach(function(input) {
+ var idx = input.dataset.index;
+ var field = input.dataset.field;
+ if (!attendeeData[idx]) attendeeData[idx] = {};
+ attendeeData[idx][field] = input.value;
+ });
+
+ // 3. 构建 goodsParamsList
+ var self = this;
+ var goodsParamsList = this.selectedSeats.map(function(seat, i) {
+ var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
+ return {
+ goods_id: self.goodsId,
+ spec_base_id: parseInt(specBaseId) || 0,
+ stock: 1
+ };
+ });
+
+ // 4. 逐座提交到购物车(避免并发竞态,逐座串行提交)
+ function submitNext(index) {
+ if (index >= goodsParamsList.length) {
+ // 全部成功 → 跳转购物车
+ location.href = self.requestUrl + '?s=index/cart/index';
+ return;
+ }
+
+ var params = goodsParamsList[index];
+ $.post(__goods_cart_save_url__, params, function(res) {
+ if (res.code === 0 && res.data && res.data.id) {
+ submitNext(index + 1);
+ } else {
+ alert('座位 [' + self.selectedSeats[index].label + '] 提交失败:' + (res.msg || '库存不足'));
+ }
+ }).fail(function() {
+ alert('网络错误,请重试');
+ });
+ }
+
+ submitNext(0);
+}
+```
+
+**保守策略**:
+- 使用**串行** `submitNext()` 递归,避免并发竞态
+- 每个座位单独请求,成功后提交下一个
+- 任意失败立即中断并弹窗提示
+
+**验收测试**:
+- [ ] 选择 3 个座位 → 点击提交 → 购物车页显示 3 条商品
+- [ ] 座位 2 库存不足 → 弹窗提示,座位 1 不在购物车
+
+---
+
+### Step 2:修复场次切换状态重置(P0)
+
+**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`
+
+**改动**:在 `selectSession()` 函数开头添加状态重置。
+
+```javascript
+selectSession: function(el) {
+ // 【新增】切换场次时重置已选座位
+ this.selectedSeats = [];
+
+ // 移除其他选中样式
+ document.querySelectorAll('.vr-session-item').forEach(function(item) {
+ item.classList.remove('selected');
+ });
+ el.classList.add('selected');
+ this.currentSession = el.dataset.specId;
+ this.sessionSpecId = el.dataset.specBaseId;
+
+ // 隐藏座位图和观演人区域(等待渲染)
+ document.getElementById('seatSection').style.display = 'none';
+ document.getElementById('selectedSection').style.display = 'none';
+ document.getElementById('attendeeSection').style.display = 'none';
+
+ this.renderSeatMap();
+ this.loadSoldSeats();
+}
+```
+
+**保守策略**:
+- 重置后隐藏座位图和观演人区域,避免旧数据残留
+- 渲染完成后由 `updateSelectedUI()` 显示
+
+**验收测试**:
+- [ ] 选择场次 A → 选 2 个座位 → 切换场次 B → 确认已选座位清零
+- [ ] 切换回场次 A → 确认已选座位仍然清零(严格隔离)
+
+---
+
+### Step 3:实现 `loadSoldSeats()`(P1)
+
+#### 3.1 后端接口
+
+**文件**:`shopxo/app/plugins/vr_ticket/controller/Index.php`
+
+**新增方法**:
+
+```php
+/**
+ * 获取场次已售座位列表
+ * @method POST
+ * @param goods_id 商品ID
+ * @param spec_base_id 规格ID(场次)
+ * @return json {code:0, data:{sold_seats:['A_1','A_2','B_5']}}
+ */
+public function SoldSeats()
+{
+ // 鉴权
+ if (!IsMobileLogin()) {
+ return json_encode(['code' => 401, 'msg' => '请先登录']);
+ }
+
+ // 获取参数
+ $goodsId = input('goods_id', 0, 'intval');
+ $specBaseId = input('spec_base_id', 0, 'intval');
+
+ if (empty($goodsId) || empty($specBaseId)) {
+ return json_encode(['code' => 400, 'msg' => '参数错误']);
+ }
+
+ // 查询已支付订单中的座位
+ // 简化版:直接从已支付订单 item 的 extension_data 解析
+ $orderService = new \app\service\OrderService();
+ // 注意:此处需根据实际的 QR 票订单表结构查询
+
+ $soldSeats = [];
+ return json_encode(['code' => 0, 'data' => ['sold_seats' => $soldSeats]]);
+}
+```
+
+**保守策略**:
+- 第一版只返回空数组(不查数据库)
+- 后续迭代再接入真实数据
+
+#### 3.2 前端调用
+
+**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`
+
+**改动 `loadSoldSeats()`**:
+
+```javascript
+loadSoldSeats: function() {
+ if (!this.currentSession || !this.goodsId) return;
+
+ var self = this;
+ $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
+ goods_id: this.goodsId,
+ spec_base_id: this.sessionSpecId
+ }, function(res) {
+ if (res.code === 0 && res.data && res.data.sold_seats) {
+ res.data.sold_seats.forEach(function(seatKey) {
+ self.soldSeats[seatKey] = true;
+ });
+ self.markSoldSeats();
+ }
+ });
+},
+
+markSoldSeats: function() {
+ var self = this;
+ document.querySelectorAll('.vr-seat').forEach(function(el) {
+ var seatKey = el.dataset.rowLabel + '_' + el.dataset.colNum;
+ if (self.soldSeats[seatKey]) {
+ el.classList.add('sold');
+ }
+ });
+}
+```
+
+**验收测试**:
+- [ ] 后端接口返回 `{"code":0,"data":{"sold_seats":["A_1","A_2"]}}` → A_1、A_2 标记为灰色已售
+
+---
+
+### Step 4:CSS 文件分离(P1)
+
+#### 4.1 新建 CSS 文件
+
+**文件**:`shopxo/app/plugins/vr_ticket/static/css/ticket.css`
+
+**内容**(从 `ticket_detail.html` 的 `
-' : ''; ?>
+' : ''; ?>
+
\ No newline at end of file
diff --git a/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html b/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html
index f4ea8e4..c93670d 100644
--- a/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html
+++ b/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html
@@ -1,128 +1,19 @@
-
+
+
@@ -136,7 +27,8 @@
-
选择座位 (点击空座选中,再点击取消)
+
选择座位 (点击空座选中,再点击取消)
舞 台
@@ -161,7 +53,9 @@
@@ -180,272 +74,283 @@
-
-
diff --git a/shopxo/public/plugins/vr_ticket/static/css/ticket.css b/shopxo/public/plugins/vr_ticket/static/css/ticket.css
new file mode 100644
index 0000000..eaa6c41
--- /dev/null
+++ b/shopxo/public/plugins/vr_ticket/static/css/ticket.css
@@ -0,0 +1,104 @@
+/* VR票务 - 票务商品详情页样式 */
+/* 从 ticket_detail.html 内联样式抽取,2026-04-21 */
+
+.vr-ticket-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
+.vr-ticket-header { margin-bottom: 20px; }
+.vr-event-title { font-size: 24px; font-weight: bold; color: #333; margin-bottom: 8px; }
+.vr-event-subtitle { color: #666; font-size: 14px; }
+
+.vr-seat-section { margin-bottom: 30px; }
+.vr-section-title { font-size: 18px; font-weight: bold; margin-bottom: 15px; color: #333; }
+
+.vr-seat-map-wrapper { background: #f8f9fa; border-radius: 8px; padding: 20px; margin-bottom: 20px; overflow-x: auto; }
+.vr-stage {
+ text-align: center;
+ background: linear-gradient(180deg, #e8e8e8, #d0d0d0);
+ border-radius: 50% 50% 0 0 / 20px 20px 0 0;
+ padding: 15px 40px;
+ margin: 0 auto 25px;
+ max-width: 600px;
+ color: #666;
+ font-size: 13px;
+ letter-spacing: 2px;
+}
+.vr-seat-rows { display: flex; flex-direction: column; align-items: center; gap: 4px; }
+.vr-seat-row { display: flex; align-items: center; gap: 2px; }
+.vr-row-label { width: 24px; text-align: center; color: #999; font-size: 12px; flex-shrink: 0; }
+
+.vr-seat {
+ width: 28px;
+ height: 28px;
+ border-radius: 4px;
+ margin: 1px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 9px;
+ color: #fff;
+ transition: all 0.15s;
+ flex-shrink: 0;
+ position: relative;
+}
+.vr-seat:hover { transform: scale(1.15); z-index: 1; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
+.vr-seat.selected { border: 2px solid #333; transform: scale(1.1); }
+.vr-seat.sold { background: #ccc !important; cursor: not-allowed; opacity: 0.5; }
+.vr-seat.sold:hover { transform: none; box-shadow: none; }
+.vr-seat.aisle { background: transparent !important; cursor: default; }
+.vr-seat.space { background: transparent !important; cursor: default; }
+
+.vr-legend { display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 15px; justify-content: center; }
+.vr-legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #666; }
+.vr-legend-seat { width: 20px; height: 20px; border-radius: 3px; }
+
+.vr-selected-seats { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
+.vr-selected-title { font-weight: bold; margin-bottom: 10px; color: #333; }
+.vr-selected-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; min-height: 30px; }
+.vr-selected-item {
+ display: inline-flex; align-items: center; gap: 6px;
+ background: #e8f4ff; border: 1px solid #b8d4f0;
+ border-radius: 4px; padding: 4px 10px; font-size: 13px;
+}
+.vr-selected-item .remove { color: #f56c6c; cursor: pointer; font-size: 16px; line-height: 1; }
+.vr-total { font-size: 16px; font-weight: bold; color: #333; margin-top: 10px; }
+
+.vr-sessions { margin-bottom: 20px; }
+.vr-session-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
+.vr-session-item {
+ border: 1px solid #ddd; border-radius: 6px; padding: 10px;
+ cursor: pointer; text-align: center;
+ transition: all 0.15s;
+}
+.vr-session-item:hover { border-color: #409eff; }
+.vr-session-item.selected { border-color: #409eff; background: #ecf5ff; }
+.vr-session-item .date { font-size: 14px; font-weight: bold; color: #333; }
+.vr-session-item .time { font-size: 13px; color: #666; margin-top: 4px; }
+.vr-session-item .price { font-size: 14px; color: #f56c6c; font-weight: bold; margin-top: 6px; }
+
+.vr-attendee-form { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
+.vr-form-title { font-weight: bold; margin-bottom: 15px; color: #333; }
+.vr-attendee-item { background: #f8f9fa; border-radius: 6px; padding: 15px; margin-bottom: 10px; }
+.vr-attendee-label { font-size: 13px; color: #666; margin-bottom: 5px; }
+.vr-attendee-input { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
+
+.vr-purchase-bar {
+ position: fixed; bottom: 0; left: 0; right: 0;
+ background: #fff; border-top: 1px solid #e8e8e8;
+ padding: 12px 20px; z-index: 100;
+ display: flex; align-items: center; justify-content: space-between;
+ box-shadow: 0 -2px 10px rgba(0,0,0,0.08);
+}
+.vr-purchase-info { font-size: 14px; color: #666; }
+.vr-purchase-info strong { font-size: 20px; color: #f56c6c; }
+.vr-purchase-btn {
+ background: linear-gradient(135deg, #409eff, #3b8ef8);
+ color: #fff; border: none; border-radius: 20px;
+ padding: 12px 36px; font-size: 16px; font-weight: bold;
+ cursor: pointer; transition: all 0.2s;
+}
+.vr-purchase-btn:hover { transform: scale(1.03); box-shadow: 0 4px 12px rgba(64,158,255,0.4); }
+.vr-purchase-btn:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; }
+
+.vr-goods-info { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
+.vr-goods-photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; margin-bottom: 15px; }
+.vr-goods-photos img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; }