# 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 = '<?php echo Config("shopxo.host_url"); ?>';
    //   确认 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
<div class="vr-seat-map-wrapper">
    <div class="vr-zoom-container" id="zoomContainer">
        <div class="vr-stage">舞 台</div>
        <div class="vr-seat-rows" id="seatRows"></div>
    </div>
</div>
```

```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 修正版*
