# 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）

> 📋 **AntiGravity 已进行会话进度**: `SESSION_REPORT_20260421_PHASE2_FIX.md` - 记录AntiGravity 推进的所有工作，包含经验教训与改动。

---

## 一、项目概览

### 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）~~ ✅ | **已完成** | seatSpecMap.inventory 反推已售座位，ShopXO原生防超卖 |
| 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 = <?php echo json_encode($vr_seat_template ?? [], JSON_UNESCAPED_UNICODE); ?>;
var goodsSpecData  = <?php echo json_encode($goods_spec_data ?? [], JSON_UNESCAPED_UNICODE); ?>;
var seatSpecMap    = <?php echo json_encode($seatSpecMap ?? [], JSON_UNESCAPED_UNICODE); ?>;
```

### 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 = '<?php echo Config("shopxo.host_url"); ?>';
    //    确认 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`~~ ✅ | 已完成 | seatSpecMap.inventory 前端推导，无需额外API | 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 可独立阅读并推进事务。*
