vr-shopxo-plugin/docs/FULL_PLAN.md

681 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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
---
## 一、项目概览
### 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 可独立阅读并推进事务。*