10 KiB
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()。
// 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)— 原生遍历多 SKUBuyService::OrderInsertHandle第773行:'extension_data' => json_encode($v['order_base']['extension_data'])— 原生写入 extension_datavr_ticketsinstall.sql 已有:real_name,phone,id_card字段 ✅TicketService::issueTicket()第141行:从$order['extension_data']读取观演人 ✅
正确修复方案(只需改 submit())
// 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 只作用于子树。
修复方案
<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>
.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_seatsAPI 端点
修复方案
后端:新增 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-contentCSS:⚠️ 缺失
如需展示商品图片,在模板中添加图片渲染逻辑。如票务详情页不需要 ShopXO 商品内容,可降级为「确认不需要」。
Issue 5(P2 潜在):GetGoodsViewData 只返回第一个场次
SeatSkuService::GetGoodsViewData() 第368行返回 validConfigs[0],多场次商品只显示第一个场次。
修复方向
修改返回值格式为数组,前端根据选中场次索引读取对应数据。
第一性原则视角(修正后)
-
Issue 1 是「传输机制损坏」,不是「流程错误」:Buy 链路完全正确,多 SKU 合并下单是 ShopXO 原生能力,不需要绕过。
-
extension_data 存储完全在 ShopXO 生态内:
order.extension_data→onOrderPaid→vr_tickets全链路原生打通,不需要新建表或扩展字段。 -
spec_base_id_map是性能缓存:如果onOrderPaid能通过 seatKey(spec value 字符串)查询 spec_base_id,map 可以去掉。但保留是合理的优化。 -
onOrderPaid是座位唯一性权威(未审计):在 Issue 1 修复部署前,必须验证此 Hook 是否正确实现了座位锁定(幂等 + FOR UPDATE)。这是防双售的核心。 -
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。 -
最小修复范围:只需修改
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 修正版