# 幽灵 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** 也需要同步修复，支持多模板模式
