# 场馆/座位模板硬删除问题评估报告

**项目**: VR票务插件 (vr-shopxo-plugin)  
**评估人**: Architect + PM  
**日期**: 2026-04-20  
**状态**: P0 需立即处理

---

## 摘要

当前系统对 `vr_seat_templates` 实施软删除 (`status=0`)，若引入硬删除会导致以下问题：
- 商品编辑时模板读取失败 → `seatTemplate = null`
- 商品保存时 `json_decode(null)` 报错 → 500错误
- 前端票务详情页无法显示座位图

**核心问题**: `AdminGoodsSaveHandle` 第60-110行在重建 `template_snapshot` 时缺少空值检测，硬删除后访问已删模板会触发 Fatal Error。

---

## Q1 影响评估

### 场景还原

当模板 ID=5 被硬删除，商品 A 仍关联 `template_id=5`：

#### 1.1 读取时 (GetGoodsViewData，约 line 350)

```php
// SeatSkuService.php:358-365
$seatTemplate = \think\facade\Db::name(self::table('seat_templates'))
    ->where('id', $templateId)
    ->find();

// 如果模板不存在（硬删除）→ $seatTemplate = null
// 后续对 $seatTemplate['seat_map'] 的直接访问会报 Notice 或 Warning
if (!empty($seatTemplate['seat_map'])) {
    $decoded = json_decode($seatTemplate['seat_map'], true); // Warning: null passed
}
```

**影响**: 
- `$seatTemplate` 为 `null`，前端 `ticket_detail.html` 无法渲染座位图
- 页面仍能显示（PHP Warning 不中断），但座位图区域空白

#### 1.2 保存时 (AdminGoodsSaveHandle，约 line 70-90)

```php
// AdminGoodsSaveHandle.php:70-85
$template = Db::name('vr_seat_templates')->find($templateId); // null
$seatMap  = json_decode($template['seat_map'] ?? '{}', true); // FATAL: Cannot access null
$allRooms = $seatMap['rooms'] ?? []; // Warning: null
```

**影响**:
- ✅ **P0** - 触发 PHP Fatal Error 导致保存失败
- 错误信息: `Error: Call to a member function on null`
- 商品无法保存/更新

#### 1.3 前端票务详情页显示

```php
// 返回结构
return [
    'vr_seat_template' => $seatTemplate ?: null,  // null → 页面无座位图
    'goods_spec_data'  => $goodsSpecData,
    'goods_config'    => $config,
];
```

**影响**:
- 前端票务详情页座位图区域空白
- 用户无法选座（但不影响已购票的 `goods_snapshot`）

---

## Q2 修复方案

### 方案对比

| 方案 | 优点 | 缺点 | 推荐度 |
|------|------|------|--------|
| **A**: GetGoodsViewData 加 fallback | 改动小，不影响保存流程 | 治标不治本 | ⭐⭐⭐ |
| **B**: AdminGoodsSaveHandle 加检测+提示 | 可阻止脏数据写入 | 需要改两个地方 | ⭐⭐⭐⭐ |
| **C**: 删除模板时级联处理 | 彻底解决孤立引用 | 改动大，破坏软删除语义 | ⭐⭐ |

### 推荐: 方案 B + 方案 A 组合

**Step 1**: GetGoodsViewData 加 fallback (方案 A)

```php
// SeatSkuService.php:365，新增
$seatTemplate = \think\facade\Db::name(self::table('seat_templates'))
    ->where('id', $templateId)
    ->find();

// ▼ 新增: 模板不存在时，检查 template_snapshot
if (empty($seatTemplate) && !empty($config['template_snapshot'])) {
    // 使用 snapshot 恢复模板数据
    $seatTemplate = [
        'seat_map' => json_encode($config['template_snapshot'], JSON_UNESCAPED_UNICODE),
    ];
}
```

**Step 2**: AdminGoodsSaveHandle 加检测 (方案 B)

```php
// AdminGoodsSaveHandle.php:68-72，新增
$templateId    = intval($config['template_id'] ?? 0);
$selectedRooms = $config['selected_rooms'] ?? [];

// ▼ 新增: 检测模板是否存在
$template = Db::name('vr_seat_templates')->find($templateId);
$templateExists = !empty($template);

// 条件: snapshot 为空，或者前端有 selected_rooms
if ($templateId > 0 && (!empty($selectedRooms) || empty($config['template_snapshot']) || empty($config['template_snapshot']['rooms']))) {
    // 如果模板已删除且没有 snapshot，拒绝保存
    if (!$templateExists && empty($config['template_snapshot'])) {
        return ['code' => -401, 'msg' => '座位模板已被删除，请重新选择模板'];
    }
    
    // ▼ 新增: 模板不存在但有 snapshot 时，从 snapshot 恢复
    if (!$templateExists && !empty($config['template_snapshot'])) {
        $seatMap = ['venue' => $config['template_snapshot']['venue'] ?? [], 'rooms' => $config['template_snapshot']['rooms'] ?? []];
    } else {
        $seatMap = json_decode($template['seat_map'] ?? '{}', true);
    }
    // ...
}
```

---

## Q3 真删除功能设计

### UI/UX 建议

| 按钮 | 当前文字 | 建议文字 | 备注 |
|-----|---------|---------|------|
| 软删除 | 删除 | 禁用 | 现有行为: `status→0` |
| 硬删除 | - | 删除 | 需二次确认 |

**警告弹窗设计**:
```
┌─────────────────────────────────┐
│  确定要删除此模板吗？           │
├─────────────────────────────────┤
│  ⚠️  此操作不可恢复            │
│                                 │
│  □  同时解除商品关联（推荐）   │
│  □  强制删除（忽略关联）        │
│                                 │
│    [取消]        [确定删除]    │
└─────────────────────────────────┘
```

### 数据库操作

**方案 1**: 逻辑外键约束（推荐）

```sql
-- 创建 FK，但不启用 ON DELETE CASCADE
ALTER TABLE vr_goods_config 
ADD CONSTRAINT fk_template_soft 
FOREIGN KEY (template_id) 
REFERENCES vr_seat_templates(id) 
ON DELETE NO ACTION;

-- 软删除时不清除外键，只是查不到
-- 需要显示检查关联商品，在应用层处理
```

**方案 2**: 硬删除前检查

```php
// Admin.php: SeatTemplateDelete 新增参数
public function SeatTemplateDelete()
{
    $id = input('id', 0, 'intval');
    $force = input('force', 0, 'intval'); // 强制删除 flag
    
    if (!$force) {
        // 检查是否有商品关联
        $goods = Db::name('goods')
            ->where('vr_goods_config', 'like', '%"template_id":' . $id . '%')
            ->find();
        
        if (!empty($goods)) {
            return DataReturn('该模板有关联商品，无法删除', -402);
        }
    }
    
    // 硬删除
    Db::name('vr_seat_templates')->where('id', $id)->delete(true);
}
```

### template_snapshot 处理

**原则**: 删除模板时，`template_snapshot` 保留在 `vr_goods_config` 中，作为备份数据源。

```php
// AdminGoodsSaveHandle.php snapshot 恢复逻辑已覆盖此场景
// 删除模板不影响已有商品的 snapshot
```

---

## Q4 优先级定义

### P0（必须修复，立即）

| 问题 | 描述 | 修复位置 |
|------|------|----------|
| AdminGoodsSaveHandle 空指针 | 硬删除后保存商品 Fatal Error | AdminGoodsSaveHandle.php:68-90 |
| GetGoodsViewData 空值 | 编辑时模板不存在导致 Warning | SeatSkuService.php:358-365 |

### P1（下一迭代）

| 问题 | 描述 | 修复位置 |
|------|------|----------|
| 模板删除检查 | 删除模板前检查商品关联 | Admin.php: SeatTemplateDelete |
| UI 改名为"禁用" | 软删除按钮文案改为"禁用" | admin/view/seat_template/*.html |

### P2（后续优化）

| 问题 | 描述 | 修复位置 |
|------|------|----------|
| 真删除功能 | 硬删除 API + 二次确认弹窗 | Admin.php: SeatTemplateDelete + View |
| FK 约束增强 | 考虑添加数据库外键约束 | SQL migration |

---

## 修复步骤

### Step 1: 紧急修复 (P0)

**文件**: `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php`

```php
// 约 line 68-72 修改
$templateId    = intval($config['template_id'] ?? 0);
$selectedRooms = $config['selected_rooms'] ?? [];

// ▼ 新增: 检测模板是否存在
$template = Db::name('vr_seat_templates')->find($templateId);
$templateExists = !empty($template);

// 条件: snapshot 为空，或者前端有 selected_rooms
if ($templateId > 0 && (!empty($selectedRooms) || empty($config['template_snapshot']) || empty($config['template_snapshot']['rooms']))) {
    // ▼ 新增: 模板不存在且没有 snapshot，拒绝保存
    if (!$templateExists && empty($config['template_snapshot'])) {
        return ['code' => -401, 'msg' => '座位模板已被删除，请重新选择模板'];
    }
    
    // ▼ 新增: 模板不存在但有 snapshot 时，从 snapshot 恢复
    $seatMap = !$templateExists && !empty($config['template_snapshot']) 
        ? ['venue' => $config['template_snapshot']['venue'] ?? [], 'rooms' => $config['template_snapshot']['rooms'] ?? []]
        : json_decode($template['seat_map'] ?? '{}', true);
    // ... 后续逻辑不变
}
```

**文件**: `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php`

```php
// 约 line 358-365 修改
$seatTemplate = \think\facade\Db::name(self::table('seat_templates'))
    ->where('id', $templateId)
    ->find();

// ▼ 新增: 模板不存在时，检查 template_snapshot
if (empty($seatTemplate) && !empty($config['template_snapshot'])) {
    $seatTemplate = [
        'seat_map' => json_encode($config['template_snapshot'], JSON_UNESCAPED_UNICODE),
    ];
}
```

### Step 2: UX 优化 (P1)

- 修改按钮文案: "删除" → "禁用"
- 新增硬删除确认弹窗

### Step 3: 完整功能 (P2)

- 实现硬删除 API
- 添加商品关联检查

---

## 决策记录

### 方案选定：方案 C（用户提出）— 置空 + 自清理

**决策日期**：2026-04-20

**核心思路**（用户提出）：
> 模板如果删除说明用户不要了，否则他就应该设置禁用。既然删除，等商品卖完继续上架，不存在的配置本来就应该同步不要了。

**用户意图**：删除模板 = 用户主动放弃该模板 → 商品的 template_snapshot 也应一并清空，让商品下次保存时整块 config 干净地失效，而不是保留旧 snapshot 导致"有 snapshot 但无 template"的不一致状态。

**最终方案逻辑**：
1. `GetGoodsViewData()` 检测到模板不存在 → 将 `template_id` 和 `template_snapshot` 同时置 null → 写回 DB
2. 前端打开编辑 → 选单为空（因为 template_id=null 对应不上任何模板）
3. 用户保存（无 template_id）→ `AdminGoodsSaveHandle` 的 snapshot 重建条件 `$templateId > 0` 不满足 → 跳过重建 → config 块无 snapshot
4. 商品彻底脱钩，不存在任何指向已删模板的数据

**警告文案**（删除确认弹窗）：
> 删除记录不会导致已上架商品内容变动。若需要同步场馆信息到已发布商品，请编辑对应商品并保存。

### 最终实现

**文件 1**：`service/SeatSkuService.php` - `GetGoodsViewData()`
- 模板不存在时，`template_id = null` + `template_snapshot = null`
- 同步写回 `vr_goods_config` 到 DB
- 返回 `null` 模板，前端座位图区域空白

**文件 2**：`hook/AdminGoodsSaveHandle.php` - 重建 snapshot 逻辑
- `Db::find($templateId)` 返回 null 时 → `continue`
- 不执行后续 `json_decode($template['seat_map'])`（避免 Fatal Error）
- BatchGenerate 条件 `$templateId > 0` 不满足 → 跳过 SKU 生成

### 与方案 A+B 的对比

| | 方案 A+B | 方案 C（最终） |
|---|---|---|
| 模板不存在时 | fallback 到 snapshot | 置空 template_id + snapshot |
| 用户感知 | 旧数据仍可见 | 选单为空，需重新选择 |
| 数据一致性 | 混合状态（无 template_id 但有 snapshot）| 干净清空 |
| 复杂度 | 两处改动 | 一处读+一处写 |
| 符合用户意图 | 中等 | ✅ 完全一致 |

### 风险说明

- 删除模板前已售出的票不受影响（`goods_snapshot` 是购买时快照）
- 正在编辑中的商品，下次保存会自动清空配置（符合"不要了"的语义）
- 已上架商品（未重新编辑）保持原状态 → 下次编辑时才发现模板缺失 → 也按上述逻辑处理

---

## 附录: 代码路径汇总

| 文件 | 行号 | 函数 |
|------|------|------|
| `service/SeatSkuService.php` | 350-420 | `GetGoodsViewData()` |
| `hook/AdminGoodsSaveHandle.php` | 60-110 | 重建 template_snapshot |
| `admin/Admin.php` | 227-255 | `SeatTemplateDelete()` |
| `admin/Admin.php` | 803-830 | `VenueDelete()` |