9.8 KiB
FrontendDev 调研报告:幽灵 spec 问题
日期:2026-04-20 | Agent:council/FrontendDev
1. ticket_detail.html 的前端规格项构建
1.1 页面性质确认
ticket_detail.html 是客户前端购票页面(用于 C 端用户选座下单),不是后台商品编辑页面。后台编辑商品时出现的「规格不允许重复」错误发生在 ShopXO 标准后台,其触发点在 GoodsService.php:1859/1889/1925。
前端购票页面的数据来源:
| PHP 变量 | 来源(SeatSkuService) | 用途 |
|---|---|---|
$vr_seat_template |
GetGoodsViewData() |
seat_map、spec_base_id_map |
$goods_spec_data |
GetGoodsViewData() |
场次(session)列表 |
前端 JS 接收这些数据:
ticket_detail.html:186-187
seatMap: <?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>,
specBaseIdMap: <?php echo json_encode($vr_seat_template['spec_base_id_map'] ?? []); ?>,
前端规格项(场次)构建逻辑(renderSessions(), ticket_detail.html:202-213):
var specData = <?php echo json_encode($goods_spec_data ?? []); ?> || [];
// specData 格式: [{spec_id: 2001, spec_name: "08:00-23:59", price: 100}]
// 渲染为可点击的场次卡片
结论:ticket_detail.html 本身不构建 ShopXO 规格(spec)表格,其规格项仅为场次选择器。真正触发「规格不允许重复」的是 ShopXO 后台商品编辑页的 GoodsService.php。
2. 模板不存在时前端对 template_snapshot 和 spec_base_id_map 的处理
2.1 后端 fallback 行为(SeatSkuService.php)
关键函数:GetGoodsViewData() (SeatSkuService.php:358-464)
模板不存在时的 fallback(硬删除场景):
// SeatSkuService.php:383-393
if (empty($seatTemplate)) {
$config['template_id'] = null;
$config['template_snapshot'] = null;
Db::name('Goods')->where('id', $goodsId)->update([
'vr_goods_config' => json_encode([$config], ...),
]);
return [
'vr_seat_template' => null,
'goods_spec_data' => [],
'goods_config' => $config,
];
}
执行效果:
template_id被置为null(写入 DB)template_snapshot被置为null(写入 DB)- 返回给前端:
vr_seat_template = null、goods_spec_data = []
前端接收到的数据:
seatMap: {} // 空对象
specBaseIdMap: {} // 空对象
goods_spec_data: [] // 空数组
前端渲染结果:
renderSessions():sessionGrid内为goods_spec_data.length === 0,显示提示「该商品暂无场次信息」(ticket_detail.html:133)renderSeatMap():seatMap.map为空,座位图区域显示「座位图加载失败」- 整个座位选择区域 UI 为空/失败状态
2.2 根因分析
模板不存在时,前端的 fallback 行为是正确的——前端展示空白购票页,用户无法选座。这符合"场馆已删除,无法购票"的业务预期。
真正的问题不在 ticket_detail.html(前端),而在:
- 后台商品编辑页(ShopXO admin)——保存时
AdminGoodsSaveHandle如何处理template_id=null的情况 vr_goods_config的持久化清理——硬删除后vr_goods_config中的 config 块是否被正确清理
3. loadSoldSeats() 函数实现情况
状态:未实现(仅有 TODO 注释)
ticket_detail.html:375-383
loadSoldSeats: function() {
// TODO: 从后端加载已售座位
// $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
// goods_id: this.goodsId,
// spec_base_id: this.sessionSpecId
// }, function(res) {
// // 标记已售座位
// });
},
影响:
soldSeats: {}永远为空对象(ticket_detail.html:189)renderSeatMap()渲染座位时,无法从soldSeats读取已售标记- 已售座位只能通过
.soldclass(由 PHP 渲染)或soldSeats字典来标记,但两者都未生效 - 结果:前端无法区分已售/可选座位——用户可能选中一个已售座位,提交后才发现无法购买
严重程度:P2(功能缺陷),不影响「规格不允许重复」错误。
4. 编辑模式下前端对已删除场馆旧规格的处理
4.1 当前行为
当商品的 vr_goods_config 中 template_id 指向的场馆已被硬删除:
GetGoodsViewData()检测到模板不存在 →template_id=null、template_snapshot=null→ 写入 DB- 前端收到
vr_seat_template=null、goods_spec_data=[] ticket_detail.html渲染空白购票页(无场次、无座位图)- 前端没有特殊逻辑处理幽灵 spec——因为后端已经清理了
template_id和template_snapshot
4.2 问题点
ticket_detail.html 是前端购票页,不是编辑页。商品编辑(后台)由 ShopXO 标准后台处理,VR 插件通过钩子介入。
幽灵 spec 的真正风险在于 AdminGoodsSaveHandle 的保存逻辑:
AdminGoodsSaveHandle.php:383-394(硬删除 fallback):当模板不存在时,continue跳过 snapshot 重建,但 config 块本身未被移除- 如果
vr_goods_config包含多个 config 块(如多场馆商品),硬删除场馆后该 config 块残存 - 下次编辑时,该 config 块仍被读取,若前端重新选择了场馆,可能导致 spec 重复
5. 前端根因分析
5.1 「规格不允许重复」错误的真正触发点
该错误不在 ticket_detail.html,而在 ShopXO 后台商品编辑流程的 GoodsService.php:1859/1889/1925。
触发条件:
- 用户在 ShopXO 后台编辑商品时,手动填写/复制了重复的规格值
- 表单提交到
GoodsService::GoodsSave()→ spec 验证逻辑检查specifications_value_*参数 - 发现有重复值 → 返回「规格不允许重复」错误
5.2 与 VR 插件的关联
当 AdminGoodsSaveHandle 运行时(plugins_service_goods_save_thing_end),它会:
- 清空
GoodsSpecType、GoodsSpecBase、GoodsSpecValue(AdminGoodsSaveHandle.php:152-155) - 对
template_id > 0的 config 块执行BatchGenerate
如果 template_id 为 null(硬删除后),BatchGenerate 跳过,但 vr_goods_config 中的 config 块仍然残存。此时商品 spec 表为空,不会出现「规格不允许重复」错误。
但如果用户在前端(ShopXO 后台编辑页)操作时,ShopXO 的原生规格表单被填充了旧的 VR 规格数据,这些数据可能在保存时被 ShopXO 的原生规格逻辑验证并触发重复错误。
6. 修复方案
6.1 前端修复(ticket_detail.html)
loadSoldSeats() 建议实现:
loadSoldSeats: function() {
if (!this.goodsId || !this.sessionSpecId) return;
var self = this;
$.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
goods_id: this.goodsId,
spec_base_id: this.sessionSpecId
}, function(res) {
if (res.code === 0 && res.data) {
self.soldSeats = res.data; // {row_col: true, ...}
self.markSoldSeats();
}
});
},
markSoldSeats: function() {
var self = this;
document.querySelectorAll('.vr-seat').forEach(function(el) {
var key = el.dataset.rowLabel + '_' + el.dataset.colNum;
if (self.soldSeats[key]) {
el.classList.add('sold');
}
});
},
6.2 后端修复(建议 BackendArchitect 评估)
当模板被硬删除后,AdminGoodsSaveHandle 应清理整个 config 块:
// AdminGoodsSaveHandle.php:77-90 改进
if (empty($template)) {
// 模板不存在时,移除整个 config 块(避免残存)
unset($configs[$i]);
continue;
}
$configs = array_values($configs); // 重排索引
或在 SeatSkuService::GetGoodsViewData() 中持久化清理:
// SeatSkuService.php:383-393 改进
if (empty($seatTemplate)) {
// 模板不存在时,清除整个 config 块,而非仅置 null
$vrGoodsConfig = json_decode($goods['vr_goods_config'] ?? '', true);
unset($vrGoodsConfig[0]);
$newConfig = array_values($vrGoodsConfig);
Db::name('Goods')->where('id', $goodsId)->update([
'vr_goods_config' => empty($newConfig) ? '' : json_encode($newConfig, ...),
]);
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
}
7. 总结
| 问题 | 位置 | 严重度 | 说明 |
|---|---|---|---|
| loadSoldSeats() 未实现 | ticket_detail.html:375 | P2 | 已售座位无法标记 |
| 模板不存在时 fallback 正确 | SeatSkuService.php:383 | — | 后端已正确清理 template_id |
| 「规格不允许重复」不在前端触发 | GoodsService.php:1859 | — | 触发点在 ShopXO 后台服务层 |
| config 块残留 | AdminGoodsSaveHandle.php | P2 | 硬删除后 config 块未移除 |
| spec_base_id_map 不影响前端 | ticket_detail.html:417 | P3 | 前端通过 seatKey 查找,未使用 map |
8. 文件路径索引
| 文件 | 行号 | 关键内容 |
|---|---|---|
SeatSkuService.php |
358-464 | GetGoodsViewData(),模板不存在 fallback |
SeatSkuService.php |
383-394 | 模板不存在时置 null 并更新 DB |
AdminGoodsSaveHandle.php |
77-145 | config 块遍历和 snapshot 重建逻辑 |
AdminGoodsSaveHandle.php |
152-155 | 清空原生 spec 表 |
AdminGoodsSaveHandle.php |
158-173 | BatchGenerate 循环(跳过 template_id=0) |
ticket_detail.html |
186-189 | 前端 JS 接收 seatMap/specBaseIdMap |
ticket_detail.html |
202-213 | renderSessions() 场次渲染 |
ticket_detail.html |
375-383 | loadSoldSeats() TODO(未实现) |
ticket_detail.html |
417 | specBaseIdMap 查找(仅 Plan A 提交用) |
GoodsService.php |
1859 | 规格值列重复检测 |
GoodsService.php |
1889 | 规格值重复检测 |
GoodsService.php |
1925 | 规格名称重复检测 |