12 KiB
场馆/座位模板硬删除问题评估报告
项目: 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)
// 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)
// 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 前端票务详情页显示
// 返回结构
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)
// 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)
// 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: 逻辑外键约束(推荐)
-- 创建 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: 硬删除前检查
// 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 中,作为备份数据源。
// 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
// 约 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
// 约 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"的不一致状态。
最终方案逻辑:
GetGoodsViewData()检测到模板不存在 → 将template_id和template_snapshot同时置 null → 写回 DB- 前端打开编辑 → 选单为空(因为 template_id=null 对应不上任何模板)
- 用户保存(无 template_id)→
AdminGoodsSaveHandle的 snapshot 重建条件$templateId > 0不满足 → 跳过重建 → config 块无 snapshot - 商品彻底脱钩,不存在任何指向已删模板的数据
警告文案(删除确认弹窗):
删除记录不会导致已上架商品内容变动。若需要同步场馆信息到已发布商品,请编辑对应商品并保存。
最终实现
文件 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() |