7.8 KiB
7.8 KiB
幽灵 Spec 问题调研报告
日期:2026-04-20 | 来源:独立验证(验证 Council 调研结果)
一、问题概述
症状:删除场馆后,编辑商品时即便场馆已置空,提交保存时仍不自动清除对应的 spec。
Council 结论:根因在 AdminGoodsSaveHandle.php:88-89 的 continue 语句,导致无效 config 块残留并写回 DB。
二、数据流分析
2.1 读取链路(商品编辑页加载)
ShopXO 商品编辑页
↓
AdminGoodsSave::handle() 返回 Vue 组件 HTML
- 从 vr_seat_templates WHERE status=1 读取有效模板列表
- 从 goods.vr_goods_config 读取原始配置
AdminGoodsSave.php:196-202 (前端 JS 过滤)
.filter(c => validTemplateIds.has(c.template_id)) ← 关键过滤
.filter(...validRoomIds...) ← 过滤无效 room ID
↓
Vue 表单展示清洗后的配置
↓
用户修改配置,提交 vr_goods_config_base64
2.2 保存链路(商品保存)
前端提交 vr_goods_config_base64
↓
AdminGoodsSaveHandle.php:29-35 (save_handle 时机)
base64_decode → 写入 $data['vr_goods_config']
↓
ShopXO 原生 GoodsSpecificationsInsert (事务内)
生成 GoodsSpecType / GoodsSpecBase / GoodsSpecValue(原生规格)
↓
AdminGoodsSaveHandle.php:55-179 (save_thing_end 时机)
├─ 从 DB 读 vr_goods_config(最新数据)
├─ 遍历 configs[],重建 template_snapshot(无效 template_id 则 continue)
├─ 写回 vr_goods_config 到 goods 表 ← 脏数据写回!
├─ 清空 GoodsSpecType/GoodsSpecBase/GoodsSpecValue
└─ 逐模板 BatchGenerate(无效 template_id 静默跳过)
三、Council 调研结果的验证
3.1 Council 发现的核心问题(正确)
文件:shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php
// 第 77-90 行
foreach ($configs as $i => &$config) {
$templateId = intval($config['template_id'] ?? 0);
$selectedRooms = $config['selected_rooms'] ?? [];
if ($templateId > 0 && (!empty($selectedRooms) || empty($config['template_snapshot']) || empty($config['template_snapshot']['rooms']))) {
$template = Db::name('vr_seat_templates')->find($templateId); // 第 83 行
if (empty($template)) {
continue; // ← BUG:只跳过本次循环,config 块仍留在 $configs 数组中
}
// ... snapshot 重建逻辑
}
}
unset($config);
// 第 148-150 行:无条件写回 DB
Db::name('Goods')->where('id', $goodsId)->update([
'vr_goods_config' => json_encode($configs, ...),
]);
问题:
- 当
template_id指向已硬删除的模板时,find()返回 null continue只跳过 snapshot 重建,但 config 块仍保留在$configs数组- 第 148-150 行将含无效
template_id的 config 块写回 DB
3.2 前端过滤是否有效?
Council 遗漏的关键点:后台商品编辑页(AdminGoodsSave.php)本身的前端过滤。
查看 AdminGoodsSave.php:196-202:
if (AppData.vrGoodsConfig && Array.isArray(AppData.vrGoodsConfig)) {
// 从 vr_seat_templates WHERE status=1 获取有效模板 ID
const validTemplateIds = new Set((AppData.templates || []).map(t => t.id));
configs.value = AppData.vrGoodsConfig
// 过滤掉模板已删除的配置
.filter(c => validTemplateIds.has(c.template_id))
分析:
validTemplateIds只包含status=1的有效模板- 硬删除的模板不在
vr_seat_templates表中 - 所以
.filter(c => validTemplateIds.has(c.template_id))会正确移除无效模板的配置
结论:前端过滤是有效的,但问题出在后端的 save_thing_end 时机从数据库重新读取数据。
3.3 真实的问题场景
- 商品创建时:用户配置了场馆 A(template_id=5)和场馆 B(template_id=6)
- 场馆 A 被硬删除:vr_seat_templates 表中无记录
- 用户编辑商品:
- 前端读取 DB 中的 vr_goods_config(仍含场馆 A 的配置)
- 前端过滤后只提交场馆 B 的配置
- 后端 save_handle:接收前端提交的只含场馆 B 的配置
- 后端 save_thing_end:
- 从 DB 读取 vr_goods_config → 此时读到的是旧数据(含场馆 A)
- 遍历时场馆 A 的 template_id=5 查不到模板,continue 跳过
- 场馆 A 的 config 块残留在数组中
- 写回 DB → 场馆 A 的脏配置被写回!
关键发现:save_thing_end 从 DB 读取的是 goods 表中的数据,而非 save_handle 时提交的 $data['vr_goods_config']。如果 goods 表中原本就有脏数据,问题就会累积。
四、"规格不允许重复" 的来源
该错误信息来自 GoodsService.php:1859,是 ShopXO 原生规格验证逻辑。
可能场景:
- 商品曾以普通商品(有原生 spec)保存
- 后转换为票务商品
- 保存时 ShopXO 原生 GoodsSpecificationsInsert 先生成原生规格
- AdminGoodsSaveHandle save_thing_end 执行清空规格表
- 但如果时序有问题,原生规格可能残留
五、根因总结
| 优先级 | 根因 | 位置 | 影响 |
|---|---|---|---|
| P1 | save_thing_end 从 DB 读取时,无效 config 块未被移除 | AdminGoodsSaveHandle.php:88-89 + 148-150 | 脏数据写回 DB,幽灵 spec 累积 |
| P2 | GetGoodsViewData 只处理第一个配置块 | SeatSkuService.php:368 | 多模板时无效块不清理 |
六、修复方案
P1 Fix(立即实施)
文件:AdminGoodsSaveHandle.php
修改 1:第 88-89 行
if (empty($template)) {
unset($configs[$i]); // 移除无效 config 块
continue;
}
修改 2:第 145 行后(unset($config) 之后)
unset($config);
$configs = array_values($configs); // 重排索引
修改 3:第 148-150 行前加判空
if (!empty($configs)) {
Db::name('Goods')->where('id', $goodsId)->update([
'vr_goods_config' => json_encode($configs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
}
修改 4:BatchGenerate 循环中增加防御性校验(第 158-173 行)
foreach ($configs as $config) {
$templateId = intval($config['template_id'] ?? 0);
if ($templateId <= 0) {
continue;
}
$template = Db::name('vr_seat_templates')->find($templateId);
if (empty($template)) {
continue; // 无效块跳过
}
$res = SeatSkuService::BatchGenerate(...);
// ...
}
P2 Fix(高优先级)
文件:SeatSkuService.php 第 368-393 行
GetGoodsViewData 需要遍历所有配置块,清理无效块后再处理:
// 过滤有效配置块
$validConfigs = [];
foreach ($vrGoodsConfig as $cfg) {
$tid = intval($cfg['template_id'] ?? 0);
if ($tid <= 0) continue;
$tpl = Db::name(self::table('seat_templates'))->where('id', $tid)->find();
if (!empty($tpl)) {
$validConfigs[] = $cfg;
}
}
if (empty($validConfigs)) {
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
}
$config = $validConfigs[0]; // 取第一个有效配置块用于前端展示
七、实施计划
| 步骤 | 任务 | 文件 | 优先级 |
|---|---|---|---|
| 1 | 修复 P1:无效 config 块移除 | AdminGoodsSaveHandle.php | P1 |
| 2 | 修复 P2:GetGoodsViewData 多模板清理 | SeatSkuService.php | P1 |
| 3 | 测试验证 | — | — |
八、结论
- Council 的调研结果基本正确,但遗漏了前端过滤本身是有效的这一点
- 真正的根因在于
save_thing_end时机从 DB 读取的是 goods 表中的旧数据,而非前端提交的数据 - 修复方案是:在后端遍历时直接移除无效 config 块,确保脏数据不回写 DB
- GetGoodsViewData 也需要同步修复,支持多模板模式