# BackendArchitect 调研报告：场馆删除后规格重复根因分析（终版）

> Agent：council/BackendArchitect | 日期：2026-04-20 | 状态：基于源码逐行验证完成

---

## 一、vr_goods_config 全链路数据流

### 1.1 读取链路（商品编辑页加载）

```
ShopXO 商品编辑页
    ↓
AdminGoodsSave::handle() 返回 Vue 组件 HTML
    - 从 vr_seat_templates WHERE status=1 读取有效模板列表
    - 从 goods.vr_goods_config 读取原始配置
AdminGoodsSave.php:196-229  (前端 JS 过滤)
    .filter(c => validTemplateIds.has(c.template_id))  ← 过滤无效模板
    .filter(...validRoomIds...)  ← 过滤无效 room ID
    ↓
Vue 表单展示清洗后的配置
    ↓
用户修改配置，提交 vr_goods_config_base64 (JSON base64 编码)
```

### 1.2 保存链路（商品保存）

```
前端提交 vr_goods_config_base64
    ↓
AdminGoodsSaveHandle.php:29-35 (save_handle 时机)
    base64_decode → 写入 $data['vr_goods_config']
    ↓
ShopXO 原生 GoodsSpecificationsInsert (goods_save_thing_begin 之后)
    生成 GoodsSpecType / GoodsSpecBase / GoodsSpecValue（原生规格）
    ↓
AdminGoodsSaveHandle.php:55-179 (save_thing_end 时机)
    ├─ 从 DB 读 vr_goods_config（最新数据）
    ├─ 遍历 configs[]，重建 template_snapshot（template_id 无效则 continue）
    ├─ 写回 vr_goods_config 到 goods 表（第 148-150 行）
    ├─ 清空 GoodsSpecType/GoodsSpecBase/GoodsSpecValue（第 152-155 行）
    ├─ 逐模板 BatchGenerate（无效 template_id 静默跳过）
    └─ refreshGoodsBase
```

---

## 二、幽灵 spec 根因定位（含行号）

### 根因 1（Critical）：无效 config 块在保存时未被移除，导致脏数据写回 DB

**文件**：`shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php`
**行号**：83-90（snapshot 重建循环内） + 148-150（写回 DB）

```php
// 第 77 行：遍历 configs
foreach ($configs as $i => &$config) {
    $templateId = intval($config['template_id'] ?? 0);
    $selectedRooms = $config['selected_rooms'] ?? [];

    // 第 82 行：进入 snapshot 重建的条件
    if ($templateId > 0 && (!empty($selectedRooms) || empty($config['template_snapshot']) || empty($config['template_snapshot']['rooms']))) {
        $template = Db::name('vr_seat_templates')->find($templateId);  // 第 83 行

        // 第 88-89 行：BUG 在此
        if (empty($template)) {
            continue;  // ← 仅跳过本次循环，config 块仍留在 $configs 数组中！
        }
        // ... snapshot 重建逻辑（第 93-142 行）
    }
}
unset($config);  // 第 145 行

// 第 148-150 行：BUG 在此
Db::name('Goods')->where('id', $goodsId)->update([
    'vr_goods_config' => json_encode($configs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
```

**根因机制**：
- 当 `template_id` 指向已硬删除的模板时，`find()` 返回 null，`continue` 跳过 snapshot 重建
- **但 `continue` 不删除 config 块**，脏 config 块保留在 `$configs` 数组中
- 第 148-150 行将包含无效 `template_id` 的 config 块**无条件写回 goods 表**
- 下次编辑时，脏数据仍然存在

**触发路径**：
1. 场馆 A（template_id=5）被硬删除，`vr_seat_templates` 无记录
2. 商品的 `vr_goods_config[0].template_id = 5` 仍保留在 goods 表
3. 用户编辑商品 → `GetGoodsViewData` 检测到无效模板，清 `template_id` 并写回 DB（单模板模式可部分缓解）
4. 但若有多模板配置块，其中一个无效：前端过滤掉无效块 → 提交时只有有效块 → 后端继续处理有效块 → 无效块因 `continue` 保留在 DB
5. **真正危险场景**：若前端过滤失效（如 `validTemplateIds` 构建有误），无效 config 块会参与后续流程

### 根因 2（High）：GetGoodsViewData 仅处理单模板模式，多模板时无效块不清理

**文件**：`shopxo/app/plugins/vr_ticket/service/SeatSkuService.php`
**行号**：368-393

```php
// 第 368-373 行
$config = $vrGoodsConfig[0];  // ← 只取第一个配置块！
$templateId = intval($config['template_id'] ?? 0);
if ($templateId <= 0) {
    return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
}

// 第 383-393 行
if (empty($seatTemplate)) {
    $config['template_id'] = null;
    $config['template_snapshot'] = null;
    \think\facade\Db::name('Goods')->where('id', $goodsId)->update([
        'vr_goods_config' => json_encode([$config], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
    ]);
    return [...]
}
```

**根因机制**：
- 第 368 行只取 `$vrGoodsConfig[0]`，多模板模式下第 2、3... 个配置块完全被忽略
- 若第一个模板有效、第二个无效：GetGoodsViewData 不会清理第二个无效块
- 若第一个模板无效、第二个有效：GetGoodsViewData 会返回 null（第一个无效导致整体返回）
- 第 386-388 行写回 DB 时只写 `[$config]`（单元素），这在**单模板模式下会覆盖掉其他有效配置块**！

### 根因 3（Medium）：BatchGenerate 对无效 template_id 静默跳过，但不报错

**文件**：`shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php`
**行号**：158-173

```php
foreach ($configs as $config) {
    $templateId = intval($config['template_id'] ?? 0);  // 第 159 行
    // ...
    if ($templateId > 0) {  // 第 164 行
        $res = SeatSkuService::BatchGenerate(...);  // 第 165 行
        if ($res['code'] !== 0) {
            return $res;  // 第 169-170 行
        }
    }
}
```

**根因机制**：
- 第 164 行 `if ($templateId > 0)` 静默跳过 `templateId = 0` 或 `null` 的块
- 由于根因 1，无效 config 块的 `templateId` 仍为原值（硬编码 ID），但模板不存在
- BatchGenerate 内部（`SeatSkuService.php:52-57`）会再次查 DB：
```php
$template = Db::name(self::table('seat_templates'))->where('id', $seatTemplateId)->find();
if (empty($template)) {
    return ['code' => -2, 'msg' => "座位模板 {$seatTemplateId} 不存在"];
}
```
- 返回 `code = -2`，触发第 169-170 行的 `return $res`，**阻断整个保存流程并返回错误**
- 错误信息：`"座位模板 {id} 不存在"`，但用户看到的可能是前端显示的通用错误

### 根因 4（Medium）：AdminGoodsSave 前端过滤无法防御 DB 层污染

**文件**：`shopxo/app/plugins/vr_ticket/hook/AdminGoodsSave.php`
**行号**：196-229

```php
// 第 196-202 行
if (AppData.vrGoodsConfig && Array.isArray(AppData.vrGoodsConfig)) {
    const validTemplateIds = new Set((AppData.templates || []).map(t => t.id));  // 第 198 行

    configs.value = AppData.vrGoodsConfig
        // 过滤掉软删除模板的配置（幽灵配置）
        .filter(c => validTemplateIds.has(c.template_id))  // 第 202 行
```

**分析**：
- 第 198 行从 `AppData.templates` 构建 Set，`AppData.templates` 来自 `vr_seat_templates WHERE status=1`（第 29-32 行）
- 硬删除的模板不在表中，不在 `validTemplateIds` 中，所以第 202 行过滤**有效**
- 前端能正确过滤硬删除模板的 config 块
- **但**：若 `vr_goods_config` 中有 config 块的 `template_id` 指向有效模板，但 `selected_rooms` 包含已被删除的 room ID，前端在第 211-215 行会过滤这些 room ID

**实际风险**：前端过滤本身是正确的。真正的问题在于：当**前端过滤导致 configs.value 为空数组**时，用户看不到任何配置，需要重新选择场馆和场次。无声的过滤体验不好但不造成错误。

### 根因 5（Low）：GoodsService 规格列值去重检测

**文件**：`shopxo/app/service/GoodsService.php`
**行号**：1859

```php
if (!empty($temp_column)) {
    return DataReturn(MyLang('common_service.goods.save_spec_column_repeat_tips').'['.implode(',', array_unique($temp_column)).']', -1);
}
```

**分析**：此检测在 GoodsSpecificationsInsert 中执行，检查 GoodsSpecValue.value 是否跨列重复。VR 插件在 `save_thing_end` 时机（第 152-155 行）先清空了原生规格表，所以此检测理论上不应影响 VR 商品。

**「规格不允许重复」真实来源**：如果商品曾以普通商品（有原生 spec）保存，然后转换为票务商品，ShopXO 原生 spec 字段可能仍随表单提交，导致此错误。但这是 ShopXO 原生逻辑，非 VR 插件问题。

---

## 三、「规格不允许重复」错误的真实触发路径

经追踪，错误信息 `save_spec_column_repeat_tips`（中文：规格值列之间不能重复）来自 `GoodsService.php:1859`。

**最可能的真实场景**：

```
场景：商品曾以普通商品（有 native spec）保存，后转换为票务商品
1. ShopXO 原生 GoodsSpecificationsInsert 执行，在 goods_spec_value 中写入原生规格数据
2. AdminGoodsSaveHandle save_thing_end 执行
   a. 第 61 行从 DB 读 vr_goods_config（此时为空或旧值）
   b. 第 148-150 行写回 goods 表（此时 vr_goods_config 可能仍为空或旧值）
   c. 第 152-155 行清空原生规格表 ← GOOD：原生规格被清空
   d. 第 165-168 行 BatchGenerate 生成 VR 规格 ← GOOD：VR 规格写入

若 save_thing_end 在 GoodsSpecificationsInsert 之前执行（或执行失败），
原生规格数据残留在 GoodsSpecValue 表中，与 VR 规格数据共存 → 触发列值重复错误
```

---

## 四、spec_base_id_map 数据流追踪

**存储位置**：`vr_seat_templates.spec_base_id_map`（模板表，非 goods 表）
**格式**：`{"A_1": 2001, "A_2": 2002, ...}`（room_row_col → GoodsSpecBase ID）

**读取路径**（`SeatSkuService.php:404-409`）：
```php
if (!empty($seatTemplate['spec_base_id_map'])) {
    $decoded = json_decode($seatTemplate['spec_base_id_map'], true);
    if (json_last_error() === JSON_ERROR_NONE) {
        $seatTemplate['spec_base_id_map'] = $decoded;
    }
}
```

**关键发现**：
- `spec_base_id_map` 存储在**模板表**（vr_seat_templates），不在 goods 表
- 模板硬删除后，`spec_base_id_map` 随之消失
- goods 的 `vr_goods_config` 中只有 `template_id`、`template_snapshot`、`selected_rooms`，**没有 spec_base_id_map**
- 前端 `ticket_detail.html` 第 187 行读取 `$vr_seat_template['spec_base_id_map']`，为空时返回 `[]`（第 417 行 fallback：`self.specBaseIdMap[seat.seatKey] || self.sessionSpecId`）

**结论**：`spec_base_id_map` 与幽灵 spec 问题无关。它是模板的辅助数据，模板删除后自然消失，不会在 goods 中残留。

---

## 五、VenueDelete 硬删除逻辑

**文件**：`shopxo/app/plugins/vr_ticket/admin/Admin.php`
**行号**：858-896

```php
// 第 882-896 行
if ($hardDelete) {
    // 检查是否有关联商品
    $goods = \think\facade\Db::name('Goods')
        ->where('vr_goods_config', 'like', '%"template_id":' . $id . '%')
        ->where('is_delete_time', 0)
        ->find();
    \think\facade\Db::name('vr_seat_templates')->where('id', $id)->delete();  // 第 888 行：真正删除！
    \app\plugins\vr_ticket\service\AuditService::log(...);
    return DataReturn('删除成功', 0, ['has_goods' => !empty($goods)]);
}
```

**分析**：
- 第 888 行使用 ThinkPHP 的 `delete()` 直接从 `vr_seat_templates` 表删除记录（不经过软删除）
- ThinkPHP 默认的软删除是 `is_delete_time` 字段，但 `delete()` 在没有配置软删除时会真正删除
- `Admin.php:66` 中 `checkAndInstallTables` 未为 `vr_seat_templates` 设置软删除字段，所以硬删除是**真正删除**
- 硬删除后，`vr_seat_templates` 中无记录，`AdminGoodsSaveHandle:83` 的 `find()` 返回 null

---

## 六、ticket_detail.html 分析

**文件**：`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`

### 6.1 模板数据加载

```php
// 第 186-187 行（PHP 模板）
seatMap: <?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>,
specBaseIdMap: <?php echo json_encode($vr_seat_template['spec_base_id_map'] ?? []); ?>,
```

- `$vr_seat_template` 来自 `SeatSkuService::GetGoodsViewData()` 的返回值
- 模板不存在时，`GetGoodsViewData:383-393` 返回 `'vr_seat_template' => null`
- 此时 `seatMap` 和 `specBaseIdMap` 均为 `[]`

### 6.2 场次渲染（第 201-213 行）

```javascript
renderSessions: function() {
    var specData = <?php echo json_encode($goods_spec_data ?? []); ?> || [];
    // 动态渲染场次列表
}
```

- `$goods_spec_data` 来自 `GetGoodsViewData()` 的 `goods_spec_data` 字段
- 模板删除后，`goods_spec_data` 为空数组，`renderSessions` 显示"该商品暂无场次信息"

### 6.3 座位图渲染（第 232-283 行）

- 第 234 行：检查 `map.map` 是否存在，不存在则显示"座位图加载失败"
- 模板删除后，`seatMap` 为空，座位图区域不显示
- `loadSoldSeats()` 函数（第 375-383 行）为 **TODO 空实现**（见下节）

### 6.4 loadSoldSeats 函数（第 375-383 行）

```javascript
loadSoldSeats: function() {
    // TODO: 从后端加载已售座位
    // $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
    //     goods_id: this.goodsId,
    //     spec_base_id: this.sessionSpecId
    // }, function(res) {
    //     // 标记已售座位
    // });
},
```

**分析**：`loadSoldSeats()` 是 **TODO 注释，不是已实现的函数**。函数体存在但不发送任何 HTTP 请求，已售座位标记逻辑未实现。这意味着所有座位在顾客视角始终显示为可选，无已售座位灰显功能。

---

## 七、根因汇总表

| 优先级 | 根因描述 | 文件:行号 | 影响 |
|--------|----------|-----------|------|
| **P1** | 无效 config 块未从数组移除，`continue` 后脏数据写回 DB | AdminGoodsSaveHandle.php:88-89 + 148-150 | 幽灵 config 累积，每次保存后无效 template_id 仍存在 |
| **P2** | GetGoodsViewData 单模板模式处理，多模板场景会覆盖有效配置块 | SeatSkuService.php:368 + 386-388 | 多模板商品中一个模板删除后整体数据损坏 |
| **P3** | BatchGenerate 对无效 template_id 返回 code=-2，阻断整个保存 | AdminGoodsSaveHandle.php:164-170 | 用户看到"座位模板不存在"错误，无法保存 |
| **P4** | AdminGoodsSave 前端过滤后 configs 为空时，用户无声失去所有配置 | AdminGoodsSave.php:196-229 | 体验问题：用户不知道配置被过滤，需重新配置 |
| **P5** | loadSoldSeats 未实现，已售座位无灰显 | ticket_detail.html:375-383 | 顾客可选已售座位，可能导致超卖 |

---

## 八、修复方案

### P1 Fix（立即实施）：AdminGoodsSaveHandle 无效 config 块过滤

**文件**：`AdminGoodsSaveHandle.php`

**修改点 1**：第 77-90 行，将 `continue` 改为 `unset`
```php
// 第 88-89 行修改前
if (empty($template)) {
    continue;
}

// 第 88-89 行修改后
if (empty($template)) {
    unset($configs[$i]);  // 移除无效 config 块
    continue;
}
```

**修改点 2**：第 145 行 `unset($config)` 之后添加
```php
$configs = array_values($configs);  // 重排数组索引，避免 JSON 序列化出现非连续数字索引
```

**修改点 3**：第 148-150 行写回 DB 前添加判空
```php
if (!empty($configs)) {
    Db::name('Goods')->where('id', $goodsId)->update([
        'vr_goods_config' => json_encode($configs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
    ]);
}
```

**修改点 4**：第 158-173 行 BatchGenerate 循环中，在调用前增加模板存在性显式校验
```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;  // 无效块跳过（已被 P1 修复提前移除，此处为防御性编程）
    }
    $res = SeatSkuService::BatchGenerate(...);
    // ...
}
```

### P2 Fix：高优先级 — GetGoodsViewData 多模板模式修复

**文件**：`SeatSkuService.php` 第 368-393 行

当前只处理 `$vrGoodsConfig[0]`，需扩展为遍历所有有效配置块：
```php
// 在 $config = $vrGoodsConfig[0]; 之前添加
$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];
// 后续逻辑不变（处理第一个有效配置块用于前端展示）
```

并修改第 386-388 行的 DB 写回逻辑：
```php
// 当前：只写回 [$config]
// 修改后：写回所有有效配置块
Db::name('Goods')->where('id', $goodsId)->update([
    'vr_goods_config' => json_encode($validConfigs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
```

### P3 Fix（中优先级）：前端体验优化

**文件**：`AdminGoodsSave.php` 第 196-229 行

在过滤无效配置后，若 `configs.value` 为空，给用户提示：
```javascript
// 在第 228 行后添加
if (configs.value.length === 0 && (AppData.vrGoodsConfig || []).length > 0) {
    alert('检测到部分场馆配置已失效（对应场馆已被删除），已自动清除。请重新选择场馆。');
}
```

---

## 九、调研结论

1. **幽灵 spec 的来源**：`AdminGoodsSaveHandle.php:88-89` 的 `continue` 不删除无效 config 块，导致含无效 `template_id` 的脏配置被写回 DB（第 148-150 行）

2. **幽灵 spec 的清理时机**：目前**没有主动清理**，只能依赖前端过滤（AdminGoodsSave.php:202）或下次 `GetGoodsViewData` 调用时的单模板覆盖（P2 场景不适用）

3. **规格重复错误**：最可能是 GoodsSpecificationsInsert 与 VR 插件清空规格的时序问题，或用户从普通商品转票务商品时原生规格未清干净

4. **`spec_base_id_map` 不是幽灵 spec 的来源**：它存储在模板表，模板删除后自然消失，与 goods 表的 vr_goods_config 无关

5. **`loadSoldSeats()` 未实现**：是 TODO 注释，不影响幽灵 spec 问题，但影响已售座位显示
