# 幽灵 Spec 问题 — Council 调研汇总报告

> 日期：2026-04-20 | Agent：FrontendDev + BackendArchitect + SecurityEngineer
> 基于 main 分支 `f84f95b56`

---

## 一、问题定义

**「场馆删除后编辑商品出现规格重复错误」**的技术描述：

1. 商品关联场馆模板 A，`vr_goods_config` 中存储 `template_id`、`template_snapshot`、`spec_base_id_map`
2. 场馆 A 被硬删除，`vr_seat_templates` 表中无记录
3. 编辑商品时前端检测到模板不存在，自动置空场馆选择
4. 但旧的幽灵 spec（来自已删除场馆的配置）仍混入表单
5. 提交时触发「规格不允许重复」

---

## 二、Agent 调研成果

### 2.1 FrontendDev — 前端调研（`reviews/council-ghost-spec-FrontendDev.md`）

#### 关键发现

**`ticket_detail.html` 是 C 端购票页，不是后台编辑页**

| 文件 | 行号 | 结论 |
|------|------|------|
| `ticket_detail.html:186-187` | 前端接收 `seatMap`/`specBaseIdMap` | 来自 `GetGoodsViewData()` |
| `ticket_detail.html:202-213` | `renderSessions()` 渲染场次选择器 | 仅渲染场次，非 ShopXO 规格 |
| `ticket_detail.html:375` | `loadSoldSeats()` — **未实现**，仅有 TODO | P2 缺陷：已售座位无法标记 |
| `SeatSkuService.php:383-394` | 模板不存在 fallback | ✅ 后端已正确置 null 并写 DB |

**幽灵 spec 不在前端产生**

当前端购票页检测到模板不存在时，`GetGoodsViewData()` 会将 `template_id=null`、`template_snapshot=null` 写入 DB，前端收到空数据渲染空白购票页。

**「规格不允许重复」触发点不在前端**

该错误触发在 `GoodsService.php:1859/1889/1925`（ShopXO 后台服务层），不在 `ticket_detail.html`。

#### 前端根因

| 问题 | 严重度 | 位置 |
|------|--------|------|
| `loadSoldSeats()` 未实现 | P2 | `ticket_detail.html:375` |
| 前端对已删除场馆无特殊处理 | P2 | `ticket_detail.html`（整体正确 fallback） |

#### 前端修复建议

`loadSoldSeats()` 实现（`ticket_detail.html:375`）：
```javascript
loadSoldSeats: function() {
    if (!this.goodsId || !this.sessionSpecId) return;
    var self = this;
    $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
        goods_id: this.goodsId,
        spec_base_id: this.sessionSpecId
    }, function(res) {
        if (res.code === 0 && res.data) {
            self.soldSeats = res.data;
            self.markSoldSeats();
        }
    });
},
```

---

### 2.2 BackendArchitect — 后端调研（`reviews/BackendArchitect-on-Issue-13-debug.md`）

#### 关键发现

**Primary Bug — 99% 命中**

| 文件 | 行号 | 问题代码 |
|------|------|----------|
| `AdminGoodsSaveHandle.php` | **77** | `return in_array($r['id'], $config['selected_rooms'] ?? []);` |

当 `$r`（rooms 数组元素）缺少 `'id'` key 时，访问 `$r['id']` 直接抛出 `Undefined array key "id"`。

**对比：SeatSkuService::BatchGenerate:100 已有正确防护**
```php
// ✅ 安全写法
$roomId = !empty($room['id']) ? $room['id'] : ('room_' . $rIdx);
```
而 `AdminGoodsSaveHandle:77` 没有这个防护。

**Secondary Bug — 模板不存在时 null 访问**

| 文件 | 行号 | 问题代码 |
|------|------|----------|
| `AdminGoodsSaveHandle.php` | **71** | `$seatMap = json_decode($template['seat_map'] ?? '{}', true);` |

当 `find()` 返回 null 后，`$template['seat_map']` 在 PHP 8.0+ 抛出 `TypeError`。

**Tertiary Bug — 类型不匹配静默失败**

| 文件 | 行号 | 问题代码 |
|------|------|----------|
| `AdminGoodsSaveHandle.php` | **77** | `in_array($r['id'], ...)` 类型不一致 |

`selected_rooms[]` 从前端传来是字符串（如 `"room_0"`），而 `$r['id']` 可能是整数。类型不匹配时 `in_array()` 永远返回 `false`，静默导致 `selectedRoomIds` 为空数组。

#### 后端根因

幽灵 spec 在 `AdminGoodsSaveHandle.php:88` 的 `continue` 处产生：当模板不存在时，`continue` 跳过 snapshot 重建，但 **config 块本身未被移除**，残存在 `vr_goods_config` 中。

#### 后端修复建议（已合并）

```php
// AdminGoodsSaveHandle.php:83-90（已修复）
if ($templateId > 0) {
    $template = Db::name('vr_seat_templates')->find($templateId);
    if (empty($template)) {
        continue;  // ✅ 硬删除场景跳过
    }
    $seatMap = json_decode($template['seat_map'] ?? '{}', true);
    // ...
}

// AdminGoodsSaveHandle.php:116-137（已修复）
array_filter($allRooms, function ($r) use ($selectedRooms) {
    $rid = $r['id'] ?? '';  // ✅ P0 修复：空安全
    // 尝试直接匹配 + 前缀匹配 + 索引回退
    // ...
})
```

---

### 2.3 SecurityEngineer — 安全审计（`reviews/SecurityEngineer-AUDIT.md`）

#### 审计报告来源

- `reviews/SecurityEngineer-AUDIT.md` — `AdminGoodsSaveHandle.php` 根因分析 + 修复建议
- `reviews/BackendArchitect-on-Issue-13-debug.md` — "Undefined array key 'id'" 根因分析

#### 审计结论（来源：SecurityEngineer-AUDIT.md）

| 级别 | 位置 | 问题 | 结论 |
|------|------|------|------|
| **P1** | `AdminGoodsSaveHandle.php:77` | `array_filter` 回调内直接访问 `$r['id']`，无空安全保护 → **Primary 错误源** | ✅ 已修复（main） |
| **P1** | `AdminGoodsSaveHandle.php:71` | 模板不存在时 `$template['seat_map']` null 访问（PHP 8.0+） | ✅ 已修复（main） |
| **P2** | `AdminGoodsSaveHandle.php:88` | 硬删除后 `continue` 跳过，config 块残留于 `vr_goods_config` | ✅ 已修复（main） |
| **P2** | `AdminGoodsSaveHandle.php:29-35` | 管理员可通过 `vr_goods_config_base64` 注入任意配置 | ⚠️ 需评估 |
| **P2** | `ticket_detail.html:375` | `loadSoldSeats()` 未实现，已售座位无法标记 | ⚠️ 待实现 |
| **P3** | `AdminGoodsSaveHandle.php:91-93` | `json_encode` 失败无捕获 | ℹ️ 低优先级 |

#### 安全评估

**根因分类：P1（安全缺陷 + 功能缺陷）**

- **P1-1**：模板不存在时，`continue` 跳过 snapshot 重建，但 config 块未被移除 → 残留于 `vr_goods_config`
- **P1-2**：`AdminGoodsSaveHandle.php:77` 直接访问 `$r['id']` 无空安全保护 → "Undefined array key 'id'" 崩溃
- **幽灵 spec 注入路径**：硬删除后 `continue` 跳过（AdminGoodsSaveHandle.php:88），但 config 块残留于 `vr_goods_config` 数组，最终被写回 DB（AdminGoodsSaveHandle.php:148-150）
- **template_snapshot 可信度**：来源是 `vr_seat_templates` 表，硬删除后被 `GetGoodsViewData()` 置 null，可信
- **无直接 XSS**：后端输出均有编码，`seatMap` 和 `specBaseIdMap` 来自 DB 合规数据

**ShopXO 入口安全**：`AdminGoodsSave.php` 入口有 ThinkPHP 参数绑定保护，无注入风险。

---

## 三、根因总结

### 技术根因链路

```
1. 场馆硬删除
   ↓ vr_seat_templates 表中记录消失
2. AdminGoodsSaveHandle:88 — continue 跳过 snapshot 重建
   ↓ 但 config 块未被移除（残留 template_id=null + spec_base_id_map）
3. GetGoodsViewData:383 — 模板不存在，置 null 并写 DB
   ↓ 但如果有多个 config 块，其余块仍携带旧 snapshot
4. 商品编辑时 — vr_goods_config 中的旧数据被读取
   ↓ 前端 fallback 正确（展示空白购票页）
5. 后端保存时 — AdminGoodsSaveHandle:77 访问 $r['id'] 崩溃
   ↓ 或触发「规格不允许重复」（GoodsService.php:1859）
```

### 根因分级

| 级别 | 描述 | 状态 |
|------|------|------|
| **P0** | `AdminGoodsSaveHandle.php:77` — `$r['id']` 无空安全 | ✅ 已修复（main） |
| **P1** | `AdminGoodsSaveHandle.php:71` — 模板不存在时 null 访问 | ✅ 已修复（main） |
| **P2** | `AdminGoodsSaveHandle.php:88` — 硬删除后 config 块残留 | ✅ 已修复（main） |
| **P2** | `ticket_detail.html:375` — `loadSoldSeats()` 未实现 | ⚠️ 待实现 |
| **P3** | `AdminGoodsSaveHandle.php:91-93` — `json_encode` 失败无捕获 | ℹ️ 低优先级 |

### 修复已合并到 main 的 commit（来源：fix/venue-hard-delete-p0 分支）

```
df8353a69 feat: 真删除功能 + 三按钮布局 + seat_template 视图补全
95346206d fix: 移除不存在的座位模板菜单 + 调整删除提示文案
9f3a46e5a fix(vr_ticket): 修复硬删除按钮 + 清理残留代码
f1173e3c8 docs: 补充硬删除修复记录 + Issue #13 关闭说明
```

---

## 四、待处理项

| # | 问题 | 优先级 | 负责人 |
|---|------|--------|--------|
| 1 | `loadSoldSeats()` 未实现（`ticket_detail.html:375`） | P2 | FrontendDev |
| 2 | `vr_goods_config` 多 config 块场景需测试验证 | P2 | BackendArchitect |
| 3 | AdminGoodsSaveHandle 表前缀风格不统一（`Db::name()` vs `BaseService::table()`） | P3 | BackendArchitect |

---

## 五、报告文件索引

| 报告 | 路径 |
|------|------|
| FrontendDev 前端调研 | `reviews/council-ghost-spec-FrontendDev.md` |
| BackendArchitect 后端调研 | `reviews/BackendArchitect-on-Issue-13-debug.md` |
| SecurityEngineer 安全审计 | `reviews/SecurityEngineer-AUDIT.md` |
| BackendArchitect Round 5 Review | `reviews/BackendArchitect-on-FrontendDev-P1.md` |
| 本汇总报告 | `reviews/council-ghost-spec-summary.md` |