vr-shopxo-plugin/docs/FULL_PLAN.md

683 lines
28 KiB
Markdown
Raw Permalink Normal View History

# VR 演唱会票务小程序 — 完整实现文档
> 最后更新2026-04-21
> 用途:给任意 agent 独立阅读并推进事务
> 仓库:`http://xmhome.ow-my.com:3000/sileya-ai/vr-shopxo-plugin`
> 本地路径:`/Users/bigemon/WorkSpace/vr-shopxo-plugin`
> ShopXO 容器localhost:10000Web/ localhost:10001MySQL/ localhost:9000PHP-FPM
> 📋 **AntiGravity 已进行会话进度**: `SESSION_REPORT_20260421_PHASE2_FIX.md` - 记录AntiGravity 推进的所有工作,包含经验教训与改动。
---
## 一、项目概览
### 1.1 目标产品
VR 演唱会票务微信小程序。用户选座 → 填观演人 → 微信支付 → 获取电子票二维码 → 现场扫码核销。
### 1.2 技术栈
- **前端**:原生 HTML + CSS + JS无框架商品详情页使用 `ticket_detail.html`
- **后端**ShopXOThinkPHP 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 每个 GoodsSpecBaseSKU由 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`GETShopXO `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. 查询对应的 GoodsSpecValue4个维度的 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 界面结构
```
┌─────────────────────────────────────────────────────┐
│ 顶部 Bannervenue.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`GETShopXO `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); // 直接 JSONBuyService 自动处理
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 fieldJSON 字符串,非 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 可独立阅读并推进事务。*