From fa35d785a92cfa3bec8ce34aff60d39dee65bc13 Mon Sep 17 00:00:00 2001 From: Council Date: Mon, 20 Apr 2026 13:53:35 +0800 Subject: [PATCH] docs: add venue hard-delete evaluation report --- reports/venue-hard-delete-evaluation.md | 309 ++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 reports/venue-hard-delete-evaluation.md diff --git a/reports/venue-hard-delete-evaluation.md b/reports/venue-hard-delete-evaluation.md new file mode 100644 index 0000000..3e07543 --- /dev/null +++ b/reports/venue-hard-delete-evaluation.md @@ -0,0 +1,309 @@ +# 场馆/座位模板硬删除问题评估报告 + +**项目**: 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 +- 添加商品关联检查 + +--- + +## 风险说明 + +当前系统**不存在真正的硬删除**,所有删除都是软删除。评估基于计划引入硬删除功能的假设。 + +如不实施硬删除,则 Q1 不会触发,仅需 Q2 方案 A 作为防御性编程。 + +--- + +## 附录: 代码路径汇总 + +| 文件 | 行号 | 函数 | +|------|------|------| +| `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()` | \ No newline at end of file