vr-shopxo-plugin/reports/GHOST_SPEC_INVESTIGATION_RE...

7.8 KiB
Raw Permalink Blame History

幽灵 Spec 问题调研报告

日期2026-04-20 | 来源:独立验证(验证 Council 调研结果)


一、问题概述

症状:删除场馆后,编辑商品时即便场馆已置空,提交保存时仍不自动清除对应的 spec。

Council 结论:根因在 AdminGoodsSaveHandle.php:88-89continue 语句,导致无效 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, ...),
]);

问题

  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

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. 商品创建时:用户配置了场馆 Atemplate_id=5和场馆 Btemplate_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 行

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),
    ]);
}

修改 4BatchGenerate 循环中增加防御性校验(第 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 修复 P2GetGoodsViewData 多模板清理 SeatSkuService.php P1
3 测试验证

八、结论

  1. Council 的调研结果基本正确,但遗漏了前端过滤本身是有效的这一点
  2. 真正的根因在于 save_thing_end 时机从 DB 读取的是 goods 表中的旧数据,而非前端提交的数据
  3. 修复方案是:在后端遍历时直接移除无效 config 块,确保脏数据不回写 DB
  4. GetGoodsViewData 也需要同步修复,支持多模板模式