# 幽灵 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` ```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, ...), ]); ``` **问题**: 1. 当 `template_id` 指向已硬删除的模板时,`find()` 返回 null 2. `continue` 只跳过 snapshot 重建,但 config 块仍保留在 `$configs` 数组 3. 第 148-150 行将含无效 `template_id` 的 config 块写回 DB ### 3.2 前端过滤是否有效? **Council 遗漏的关键点**:后台商品编辑页(AdminGoodsSave.php)本身的前端过滤。 查看 `AdminGoodsSave.php:196-202`: ```javascript 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 真实的问题场景 1. **商品创建时**:用户配置了场馆 A(template_id=5)和场馆 B(template_id=6) 2. **场馆 A 被硬删除**:vr_seat_templates 表中无记录 3. **用户编辑商品**: - 前端读取 DB 中的 vr_goods_config(仍含场馆 A 的配置) - 前端过滤后只提交场馆 B 的配置 4. **后端 save_handle**:接收前端提交的只含场馆 B 的配置 5. **后端 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 原生规格验证逻辑。 **可能场景**: 1. 商品曾以普通商品(有原生 spec)保存 2. 后转换为票务商品 3. 保存时 ShopXO 原生 GoodsSpecificationsInsert 先生成原生规格 4. AdminGoodsSaveHandle save_thing_end 执行清空规格表 5. 但如果时序有问题,原生规格可能残留 --- ## 五、根因总结 | 优先级 | 根因 | 位置 | 影响 | |--------|------|------|------| | **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 行 ```php if (empty($template)) { unset($configs[$i]); // 移除无效 config 块 continue; } ``` **修改 2**:第 145 行后(unset($config) 之后) ```php unset($config); $configs = array_values($configs); // 重排索引 ``` **修改 3**:第 148-150 行前加判空 ```php 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 行) ```php 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 需要遍历所有配置块,清理无效块后再处理: ```php // 过滤有效配置块 $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 | 测试验证 | — | — | --- ## 八、结论 1. **Council 的调研结果基本正确**,但遗漏了前端过滤本身是有效的这一点 2. **真正的根因**在于 `save_thing_end` 时机从 DB 读取的是 goods 表中的旧数据,而非前端提交的数据 3. **修复方案**是:在后端遍历时直接移除无效 config 块,确保脏数据不回写 DB 4. **GetGoodsViewData** 也需要同步修复,支持多模板模式