28 KiB
VR 演唱会票务小程序 — 完整实现文档
最后更新:2026-04-21 用途:给任意 agent 独立阅读并推进事务 仓库:
http://xmhome.ow-my.com:3000/sileya-ai/vr-shopxo-plugin本地路径:/Users/bigemon/WorkSpace/vr-shopxo-pluginShopXO 容器: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.roomsrowLabel:座位行标签,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,而是三层叠加问题:
submit()用location.href(GET),ShopXOBuy::Index只在 POST 时调用BuyDataStorage- spec 格式错误:只传 1 维度而非 4 维度
- 最严重:前端根本没有 seatSpecMap,无法把座位 DOM 映射到正确的 GoodsSpecBase
三、商品118 vr_goods_config(原始数据库数据)
存储位置:goods 表 vr_goods_config JSON 字段(商品 ID = 118)
这是从数据库直接读取的原始数据,所有其他数据结构均派生于此。
[
{
"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() 向模板注入以下变量:
MyViewAssign([
'vr_seat_template' => $viewData['vr_seat_template'], // 座位图原始数据
'goods_spec_data' => $viewData['goods_spec_data'], // 场次列表
'seatSpecMap' => $viewData['seatSpecMap'] ?? [], // 【待新增】座位→4维spec映射
]);
模板中接收方式:
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)
{
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(场次列表)
// 来源: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
// 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 中实现)
// 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[]
// 过滤函数
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 渲染座位图
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()
// 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 |
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 |
九、第一性原则(设计决策记录)
-
座位唯一性靠 ShopXO 原生 inventory:每个 GoodsSpecBase 的
inventory=1,ShopXO 在OrderInsertHandle中扣库存,防超卖由 ShopXO 原生保证,不需要自己实现锁。 -
spec_base_id_map是性能缓存:理想情况下onOrderPaid通过seat_key查询 GoodsSpecBase 即可,不需要 map 字段。但保留是合理的优化。 -
extension_data存储完全在 ShopXO 生态内:不新建表,不扩展 ShopXO 字段,order.extension_data→onOrderPaid→vr_tickets全链路 ShopXO 原生。 -
onOrderPaidspec 匹配存在潜在 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 写入格式
-
最小修复原则:Issue 1 的修复只需改
submit()函数(POST + 正确 4维 spec 格式 + extension_data)。不需要重构 spec 系统,不需要绕过 Buy 链路。
本文档为 vr-shopxo-plugin Phase 2 完整实现文档,Agent 可独立阅读并推进事务。