From aa6651e963467bbe4885d482f83a98c7dc2419b9 Mon Sep 17 00:00:00 2001 From: Council Date: Mon, 20 Apr 2026 18:47:38 +0800 Subject: [PATCH 1/3] council(draft): BackendArchitect - create plan for ghost spec investigation Co-Authored-By: Claude Opus 4.6 --- plan.md | 89 +++++++++++++++------------------------------------------ 1 file changed, 23 insertions(+), 66 deletions(-) diff --git a/plan.md b/plan.md index 7b99191..ea9288e 100644 --- a/plan.md +++ b/plan.md @@ -1,31 +1,25 @@ -# Plan — 调试 "Undefined array key 'id'" PHP 错误 +# Plan — 调研「场馆删除后编辑商品出现规格重复错误」问题 > 版本:v1.0 | 日期:2026-04-20 | Agent:council/BackendArchitect -> 关联提交:bbea35d83(feat: 保存时自动填充 template_snapshot) +> 关联:vr_goods_config 全链路数据流追踪 --- ## 任务概述 -调试 ShopXO 后台编辑票务商品(goods_id=118)保存时报错: -``` -Undefined array key "id" -``` - -根因代码位于 `bbea35d83` 新增的 `AdminGoodsSaveHandle.php` save_thing_end 时机。 +当票务商品的场馆模板被硬删除后,编辑商品时出现「规格不允许重复」错误。 +根因:旧的幽灵 spec(来自已删除场馆的 `spec_base_id_map`)仍混入表单,提交时触发去重逻辑。 --- -## 任务清单 +## BackendArchitect 任务清单 -- [x] [Done: council/BackendArchitect] **Task 1**: 根因定位 — 逐行分析所有 "id" 访问位置 -- [x] [Done: council/BackendArchitect] **Task 2**: Db::name() 表前缀问题 — ShopXO 插件表前缀行为确认 -- [x] [Done: council/BackendArchitect] **Task 3**: 根因 1 — `$r['id']` 空安全(AdminGoodsSaveHandle 第 79 行) -- [x] [Done: council/BackendArchitect] **Task 4**: 根因 2 — `find()` 返回 null 的空安全(AdminGoodsSaveHandle 第 72 行) -- [x] [Done: council/BackendArchitect] **Task 5**: 根因 3 — `$config['template_id']` / `selected_rooms` 数据类型问题 -- [x] [Done: council/BackendArchitect] **Task 6**: SeatSkuService::BatchGenerate 类似问题审计 -- [x] [Done: council/BackendArchitect] **Task 7**: 修复方案汇总 + 建议修复优先级 -- [x] [Done: council/BackendArchitect] **Task 8**: 将修复方案写入 `reviews/BackendArchitect-on-Issue-13-debug.md` +- [ ] [Claimed: council/BackendArchitect] **Task 1**: 读取 AdminGoodsSaveHandle.php,分析 vr_goods_config 的读取和解析逻辑 +- [ ] [ ] **Task 2**: 找出 spec_base_id_map 如何被转换成规格项(spec items) +- [ ] [ ] **Task 3**: 分析 SeatSkuService.php GetGoodsViewData,模板不存在时的 fallback 行为 +- [ ] [ ] **Task 4**: 找出幽灵 spec 的产生环节,确认保存时是否有过滤 +- [ ] [ ] **Task 5**: 商品保存时规格去重逻辑位置;vr_goods_config 中多个规格项 spec_base_id 相同时的行为 +- [ ] [ ] **Task 6**: 根因分析报告(含具体文件路径和行号)+ 修复方案 --- @@ -33,61 +27,24 @@ Undefined array key "id" | 阶段 | 内容 | |------|------| -| **Draft** | ✅ Task 1-6:根因定位(只读,不修改代码) | -| **Review** | ✅ Task 7:汇总所有根因,给出修复建议 | -| **Finalize** | ✅ Task 8:输出评审报告到 reviews/ | - ---- - -## 依赖关系 - -- Task 2、3、4、5 互不依赖,可并行分析 -- Task 6 依赖 Task 1 的结论(确认 BatchGenerate 中类似位置) -- Task 7 依赖 Task 1-6 的结论 -- Task 8 依赖 Task 7 - ---- - -## 执行顺序 - -``` -并行: - Task 1: 读 AdminGoodsSaveHandle.php → 分析所有 $r['id'] / $template['seat_map'] / $r['id'] 访问 - Task 2: 读 BaseService::table() + ShopXO Db::name() 前缀逻辑 - Task 3: Task 1 完成后,确认 $r['id'] 空安全 - Task 4: Task 2 完成后,确认 find() null 处理 - Task 5: Task 1 完成后,确认 selected_rooms 类型匹配 -串行: - Task 6: 读 SeatSkuService.php → 检查 ensureAndFillVrSpecTypes / BatchGenerate 类似问题 - Task 7: 汇总根因,写修复建议 - Task 8: 输出 reviews/BackendArchitect-on-Issue-13-debug.md -``` +| **Draft** | Task 1-5:全链路数据流追踪 | +| **Review** | Task 6:输出调研报告至 reviews/council-ghost-spec-BackendArchitect.md | +| **Finalize** | 汇总至 reviews/council-ghost-spec-summary.md | --- ## 关键文件(只读) -| 文件 | 关注点 | -|------|--------| -| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` | save_thing_end 逻辑,template_snapshot 填充代码 | -| `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` | BatchGenerate、ensureAndFillVrSpecTypes | -| `shopxo/app/plugins/vr_ticket/service/BaseService.php` | table() 前缀方法 | -| `docs/VR_GOODS_CONFIG_SPEC.md` | vr_goods_config v3.0 JSON 格式 | -| `docs/PHASE2_PLAN.md` | 项目背景 | +- `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` +- `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` +- `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` +- `shopxo/app/plugins/vr_ticket/admin/Admin.php` +- `shopxo/app/admin/hook/AdminGoodsSave.php` --- -## 根因结论(已验证) +## 执行顺序 -1. **Primary(99%)**: `AdminGoodsSaveHandle.php:77` — `$r['id']` 无空安全,rooms 中缺少 id key 时崩溃 -2. **Secondary(5%)**: `AdminGoodsSaveHandle.php:71` — `find()` 返回 null 后直接访问 `$template['seat_map']` -3. **Tertiary(静默)**: `AdminGoodsSaveHandle.php:77` — `selected_rooms` 类型不匹配,`in_array` 永远 false -4. **已排除**: 表前缀问题 — `Db::name()` 和 `BaseService::table()` 均查询 `vrt_vr_seat_templates`,等价 -5. **已排除**: SeatSkuService::BatchGenerate — 第 100 行已有 `!empty()` 空安全 fallback - -## 修复记录 - -- [x] **Fix Commit**: `804d465d0` — P1+P2 空安全修复已合并到 main - - `AdminGoodsSaveHandle.php:71-73`:新增 `if (empty($template)) { continue; }` - - `AdminGoodsSaveHandle.php:80`:`$r['id']` 前加 `isset()` 空安全 - - 合并到 main:`49930844f` +1. Task 1 → 2 → 3:并行读取关键文件 +2. Task 4 → 5:基于读取结果追踪 spec 去重逻辑 +3. Task 6:输出带行号的根因分析报告 From f493d06d41aa7a82233a86f0aec995b94ebf6c23 Mon Sep 17 00:00:00 2001 From: Council Date: Mon, 20 Apr 2026 19:14:37 +0800 Subject: [PATCH 2/3] council(draft): BackendArchitect - mark all BackendArchitect tasks as done Co-Authored-By: Claude Opus 4.6 --- plan.md | 115 +++++++++++++++++++------------------------------------- 1 file changed, 38 insertions(+), 77 deletions(-) diff --git a/plan.md b/plan.md index f571157..0230e54 100644 --- a/plan.md +++ b/plan.md @@ -1,98 +1,59 @@ -# Plan — 调试 "Undefined array key 'id'" PHP 错误 +# Plan — 场馆删除后规格重复(幽灵 spec)根因调研 -> 版本:v1.2 | 日期:2026-04-20 | Agent:council/BackendArchitect + council/DebugAgent(并行协作) -> 关联提交:bbea35d83(feat: 保存时自动填充 template_snapshot) - ---- - -## 任务概述 - -调试 ShopXO 后台编辑票务商品(goods_id=118)保存时报错: -``` -Undefined array key "id" -``` - -根因代码位于 `bbea35d83` 新增的 `AdminGoodsSaveHandle.php` save_thing_end 时机。 +> 版本:v1.0 | 日期:2026-04-20 | Agent:council/BackendArchitect +> 任务:调研「场馆删除后编辑商品出现规格重复错误」问题 --- ## 任务清单 -- [x] [Done: council/BackendArchitect] **Task 1**: 根因定位 — 逐行分析所有 "id" 访问位置 -- [x] [Done: council/BackendArchitect] **Task 2**: Db::name() 表前缀问题 — ShopXO 插件表前缀行为确认 -- [x] [Done: council/BackendArchitect] **Task 3**: 根因 1 — `$r['id']` 空安全(AdminGoodsSaveHandle 第 77 行) -- [x] [Done: council/BackendArchitect] **Task 4**: 根因 2 — `find()` 返回 null 的空安全(AdminGoodsSaveHandle 第 71 行) -- [x] [Done: council/BackendArchitect] **Task 5**: 根因 3 — `$config['template_id']` / `selected_rooms` 数据类型问题 -- [x] [Done: council/BackendArchitect] **Task 6**: SeatSkuService::BatchGenerate 类似问题审计 -- [x] [Done: council/BackendArchitect] **Task 7**: 修复方案汇总 + 建议修复优先级 -- [x] [Done: council/BackendArchitect] **Task 8**: 将修复方案写入 `reviews/BackendArchitect-on-Issue-13-debug.md` +### BackendArchitect(Task B1-B6) -- [x] [Done: council/DebugAgent] **Task 9**: Round 1 静态分析 → `reviews/DebugAgent-PRELIMINARY.md` -- [x] [Done: council/DebugAgent] **Task 10**: Round 2 — 验证 database.php 前缀配置 + 读取 Admin.php 第 66 行 -- [x] [Done: council/DebugAgent] **Task 11**: Round 2 — 编写 DebugAgent 最终根因报告 → `reviews/DebugAgent-ROOT_CAUSE.md` -- [x] [Done: council/BackendArchitect] **Task 12**: Round 2 — 评审 DebugAgent ROOT_CAUSE 报告 → `reviews/BackendArchitect-on-DebugAgent-ROOT_CAUSE.md` - -- [x] [Done: council/SecurityEngineer] **Task 13**: Round 2 — 独立安全审计(6项子任务)→ `reviews/SecurityEngineer-AUDIT.md` - - Q1: "Undefined array key 'id'" 最可能出现的行 → Primary: Line 77 - - Q2: Db::name() 表前缀行为 → 等价,排除 - - Q3: find() 返回 null 处理 → Secondary: Line 71 - - Q4: $configs JSON 解码类型安全 → 部分安全 - - Q5: selected_rooms 数据结构 → 类型正确但无空安全 - - Q6: BatchGenerate + item_type → 安全 +- [x] [Done: council/BackendArchitect] **Task B1**: AdminGoodsSaveHandle.php 全链路追踪 — vr_goods_config 读取/解析/snapshot 重建 +- [x] [Done: council/BackendArchitect] **Task B2**: spec_base_id_map 如何被转换成规格项(已验证:存储在模板表,与幽灵 spec 无关) +- [x] [Done: council/BackendArchitect] **Task B3**: SeatSkuService GetGoodsViewData 模板不存在时的 fallback(单模板处理,多模板有缺陷) +- [x] [Done: council/BackendArchitect] **Task B4**: 幽灵 spec 产生环节 + 清理时机(保存时未清理,写回 DB) +- [x] [Done: council/BackendArchitect] **Task B5**: 商品保存规格去重逻辑(GoodsService.php:1859) +- [x] [Done: council/BackendArchitect] **Task B6**: 根因分析报告(含行号)→ `reviews/council-ghost-spec-BackendArchitect.md` --- -## 阶段划分 +## 根因结论 -| 阶段 | 内容 | -|------|------| -| **Draft** | ✅ Task 1-6(BackendArchitect)+ Task 9(DebugAgent)+ Task 13(SecurityEngineer)| -| **Review** | ✅ Task 7(BackendArchitect)+ Task 11(DebugAgent)+ Task 12(BackendArchitect)| -| **Finalize** | ✅ Task 8 + Task 12 + Task 13:所有评审报告输出完毕 | +| 优先级 | 根因 | 文件:行号 | +|--------|------|-----------| +| **P1** | 无效 config 块未从数组移除,`continue` 后脏数据写回 DB | AdminGoodsSaveHandle.php:88-89 + 148-150 | +| **P2** | GetGoodsViewData 单模板模式,多模板时覆盖有效块 | SeatSkuService.php:368 + 386-388 | +| **P3** | BatchGenerate 对无效 template_id 返回 code=-2,阻断保存 | AdminGoodsSaveHandle.php:164-170 | +| **P4** | 前端过滤后 configs 为空时用户无声失去配置 | AdminGoodsSave.php:196-229 | +| **P5** | loadSoldSeats 未实现(TODO 注释) | ticket_detail.html:375-383 | --- -## 根因结论(已验证) - -1. **Primary(99%)**: `AdminGoodsSaveHandle.php:77` — `$r['id']` 无空安全,rooms 中缺少 id key 时崩溃 -2. **Secondary(5%)**: `AdminGoodsSaveHandle.php:71` — `find()` 返回 null 后直接访问 `$template['seat_map']` -3. **Tertiary(静默)**: `AdminGoodsSaveHandle.php:77` — `selected_rooms` 类型不匹配,`in_array` 永远 false -4. **已排除**: 表前缀问题 — `Db::name()` 和 `BaseService::table()` 均查询 `vrt_vr_seat_templates`,等价 -5. **已排除**: SeatSkuService::BatchGenerate — 第 100 行已有 `!empty()` 空安全 fallback -6. **SecurityEngineer 补充**: PHP 8+ 中 `null['key']` 抛出 `TypeError`(非 Warning);`$configs` JSON 解码有 `is_array` 防御;`item_type` 有 `?? ''` 兜底;修复建议已在 `reviews/SecurityEngineer-AUDIT.md` - -## DebugAgent 补充结论(Round 1) - -6. **PHP 8+ `??` 行为**:`$template['seat_map'] ?? '{}'` 对空数组 `[]` 的键访问**无效**,需用 `isset()` -7. **vr_goods_config JSON 解码**:有 `is_array()` 防御,访问 `$config['template_id']` 安全 - ---- - -## 执行顺序(DebugAgent Round 2) - -``` -Task 10: 读 shopxo/config/database.php → 确认 prefix 值;读 Admin.php 第 66 行 -Task 11: 综合输出 reports/DebugAgent-ROOT_CAUSE.md -``` - ---- - -## 关键文件(只读) +## 关键文件 | 文件 | 关注点 | |------|--------| -| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` | save_thing_end 逻辑,template_snapshot 填充代码 | -| `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` | BatchGenerate、ensureAndFillVrSpecTypes | -| `shopxo/app/plugins/vr_ticket/service/BaseService.php` | table() 前缀方法 | -| `shopxo/config/database.php` | ShopXO 数据库表前缀配置(Task 10 需读) | -| `docs/VR_GOODS_CONFIG_SPEC.md` | vr_goods_config v3.0 JSON 格式 | -| `docs/PHASE2_PLAN.md` | 项目背景 | +| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` | P1 根因:continue 不删除脏 config | +| `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` | GetGoodsViewData:P2 根因,多模板处理缺陷 | +| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSave.php` | 前端过滤逻辑:P4 体验问题 | +| `shopxo/app/plugins/vr_ticket/admin/Admin.php` | VenueDelete:硬删除逻辑(第 888 行) | +| `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | loadSoldSeats 未实现(P5) | +| `shopxo/app/service/GoodsService.php` | 规格列值去重检测(第 1859 行) | --- -## 修复记录 +## 修复方案 -- [x] **Fix Commit**: `804d465d0` — P1+P2 空安全修复已合并到 main - - `AdminGoodsSaveHandle.php:71-73`:新增 `if (empty($template)) { continue; }` - - `AdminGoodsSaveHandle.php:80`:`$r['id']` 前加 `isset()` 空安全 - - 合并到 main:`49930844f` +### P1 Fix(立即实施) +1. AdminGoodsSaveHandle.php:88 — `continue` 改为 `unset($configs[$i])` +2. AdminGoodsSaveHandle.php:145 后 — 添加 `$configs = array_values($configs);` +3. AdminGoodsSaveHandle.php:148 — 写回前加 `if (!empty($configs))` +4. AdminGoodsSaveHandle.php:158-173 — BatchGenerate 前增加模板存在性显式校验 + +### P2 Fix(高优先级) +1. SeatSkuService.php GetGoodsViewData — 遍历所有有效配置块,不只处理 `$vrGoodsConfig[0]` +2. 修改 DB 写回逻辑为写回 `validConfigs` 而非 `[$config]` + +### P3 Fix(中优先级) +1. AdminGoodsSave.php — configs 为空时提示用户重新选择场馆 From ccf0fbb30922456d091f2d891e4256bbe780f6a7 Mon Sep 17 00:00:00 2001 From: Council Date: Mon, 20 Apr 2026 19:18:08 +0800 Subject: [PATCH 3/3] council(review): BackendArchitect - ghost spec root cause analysis report --- .../council-ghost-spec-BackendArchitect.md | 437 ++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 reviews/council-ghost-spec-BackendArchitect.md diff --git a/reviews/council-ghost-spec-BackendArchitect.md b/reviews/council-ghost-spec-BackendArchitect.md new file mode 100644 index 0000000..5d7fdf4 --- /dev/null +++ b/reviews/council-ghost-spec-BackendArchitect.md @@ -0,0 +1,437 @@ +# 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: , +specBaseIdMap: , +``` + +- `$vr_seat_template` 来自 `SeatSkuService::GetGoodsViewData()` 的返回值 +- 模板不存在时,`GetGoodsViewData:383-393` 返回 `'vr_seat_template' => null` +- 此时 `seatMap` 和 `specBaseIdMap` 均为 `[]` + +### 6.2 场次渲染(第 201-213 行) + +```javascript +renderSessions: function() { + var specData = || []; + // 动态渲染场次列表 +} +``` + +- `$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 问题,但影响已售座位显示