diff --git a/plan.md b/plan.md index 51c809e..72dd299 100644 --- a/plan.md +++ b/plan.md @@ -1,52 +1,41 @@ -# Plan — 幽灵规格安全审计(Ghost Spec Security Audit) +# Plan — 调研「场馆删除后编辑商品出现规格重复错误」问题 -> 版本:v1.1 | 日期:2026-04-20 | Agent:council/SecurityEngineer -> 关联任务:场馆删除后编辑商品出现规格重复错误 — 安全视角分析 +> 版本:v1.2 | 日期:2026-04-20 | Agent:council/FrontendDev + council/SecurityEngineer + council/BackendArchitect --- ## 任务概述 -从安全工程师视角评估"幽灵 spec"问题: -1. 当 `template_id` 指向已删除场馆时,后端是否拒绝保存脏数据(code -401)? -2. 幽灵 spec 是否可被恶意利用来注入/覆盖商品规格? -3. 前端 fallback 是否有安全风险? -4. 根因属于 P1(拒绝脏数据)还是 P2(优雅降级)? +当票务商品关联的场馆模板被硬删除后,编辑商品时出现「规格不允许重复」错误。 + +**根因调查分工**: +- FrontendDev:前端规格项构建与 fallback 行为 +- BackendArchitect:后端规格去重逻辑、`spec_base_id_map` 解析 +- SecurityEngineer:安全风险评估(P1 vs P2) --- -## 任务清单 +## FrontendDev 任务清单 -- [x] [Done: council/SecurityEngineer] **Task S1**: 读取 AdminGoodsSaveHandle.php — 安全审计:保存时是否拒绝脏数据 -- [x] [Done: council/SecurityEngineer] **Task S2**: 读取 SeatSkuService.php — 幽灵 spec 注入路径分析 -- [x] [Done: council/SecurityEngineer] **Task S3**: 读取 AdminGoodsSave.php — ShopXO 入口安全检查 -- [x] [Done: council/SecurityEngineer] **Task S4**: 输出安全审计报告 → `reviews/SecurityEngineer-GHOST_SPEC_SECURITY.md` -- [x] [Done: council/SecurityEngineer] **Task S5**: 更新 `reviews/council-ghost-spec-summary.md` +- [x] [Done: council/FrontendDev] **Task 1**: 读取 `ticket_detail.html`,分析前端构建规格项的过程 +- [x] [Done: council/FrontendDev] **Task 2**: 当模板不存在时,前端如何处理 `template_snapshot` 和 `spec_base_id_map`? +- [x] [Done: council/FrontendDev] **Task 3**: `loadSoldSeats()` 函数实际实现了吗?soldSeats 数据如何填充? +- [x] [Done: council/FrontendDev] **Task 4**: 编辑模式下(已有 vr_goods_config),前端是否正确处理已删除场馆的旧规格? +- [x] [Done: council/FrontendDev] **Task 5**: 给出前端根因分析(含具体文件路径和行号) +- [x] [Done: council/FrontendDev] **Task 6**: 给出修复方案 +- [x] [Done: council/FrontendDev] **Task 7**: 将调研报告写入 `reviews/council-ghost-spec-FrontendDev.md` --- -## 阶段划分 +## SecurityEngineer 任务清单 -| 阶段 | 内容 | -|------|------| -| **Draft** | Task S1-S3:读取关键文件,安全审计 | -| **Review** | Task S4:输出安全报告 | -| **Finalize** | Task S5:汇总到 summary | +- [ ] [Claimed: council/SecurityEngineer] **Task S1**: 读取 AdminGoodsSaveHandle.php — 安全审计:保存时是否拒绝脏数据 +- [ ] [Claimed: council/SecurityEngineer] **Task S2**: 读取 SeatSkuService.php — 幽灵 spec 注入路径分析 +- [ ] [Claimed: council/SecurityEngineer] **Task S3**: 读取 AdminGoodsSave.php — ShopXO 入口安全检查 +- [ ] [Claimed: council/SecurityEngineer] **Task S4**: 输出安全审计报告 → `reviews/SecurityEngineer-GHOST_SPEC_SECURITY.md` +- [ ] [Claimed: council/SecurityEngineer] **Task S5**: 更新 `reviews/council-ghost-spec-summary.md` ---- - -## 关键文件(SecurityEngineer 专用) - -| 文件 | 安全关注点 | -|------|-----------| -| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` | 幽灵 spec 是否阻止保存?是否可以注入? | -| `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` | GetGoodsViewData fallback 安全风险 | -| `shopxo/app/plugins/vr_ticket/admin/Admin.php` | VenueDelete 硬删除逻辑(关联分析) | -| `shopxo/app/admin/hook/AdminGoodsSave.php` | ShopXO 保存钩子入口安全检查 | - ---- - -## 审计问题清单(SecurityEngineer 专用) +### 审计问题清单 1. **S1-Q1**: 当 `template_id` 指向不存在的场馆时,`AdminGoodsSaveHandle` 是否拒绝保存(返回 code -401)? 2. **S1-Q2**: 幽灵 spec(来自已删除场馆的 `spec_base_id_map`)是否可在保存时被注入到 `vr_goods_config`? @@ -56,9 +45,7 @@ 6. **S3-Q1**: ShopXO `AdminGoodsSave.php` 入口是否有参数校验? 7. **评估**: 根因属于 P1(拒绝脏数据/安全漏洞)还是 P2(功能降级)? ---- - -## 优先级定义 +### 优先级定义 | 级别 | 含义 | |------|------| @@ -68,18 +55,43 @@ --- +## BackendArchitect 任务清单 + +- [ ] **Task B1**: 读取 AdminGoodsSaveHandle.php,找出 `vr_goods_config` 的读取和解析逻辑 +- [ ] **Task B2**: 找出 `spec_base_id_map` 如何被转换成规格项 +- [ ] **Task B3**: 当 `template_id` 指向不存在的场馆时,SeatSkuService.php 的 GetGoodsViewData 如何 fallback? +- [ ] **Task B4**: 幽灵 spec 是在哪个环节产生的?是否在保存时过滤? +- [ ] **Task B5**: 商品保存时规格去重逻辑在哪里?`vr_goods_config` 中若有多个规格项的 `spec_base_id` 相同会怎样? +- [ ] **Task B6**: 给出根因分析(含具体行号)和修复方案 +- [ ] **Task B7**: 将调研报告写入 `reviews/council-ghost-spec-BackendArchitect.md` + +--- + +## 阶段划分 + +| 阶段 | 内容 | +|------|------| +| **Draft** | Task 1-7(FrontendDev)+ Task S1-S3 + Task B1-B6(并行)| +| **Review** | Task 7 + Task S4 + Task B7(输出各自报告)| +| **Finalize** | Task S5:汇总到 `reviews/council-ghost-spec-summary.md` | + +--- + +## 关键文件(必须全部检查) + +| 文件 | 关注点 | +|------|--------| +| `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | 前端规格项构建、template_snapshot fallback | +| `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` | GetGoodsViewData,模板不存在时的 fallback | +| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` | 商品保存钩子,vr_goods_config 处理 | +| `shopxo/app/plugins/vr_ticket/admin/Admin.php` | VenueDelete 硬删除逻辑 | +| `shopxo/app/admin/hook/AdminGoodsSave.php` | ShopXO 商品保存钩子入口 | + +--- + ## 依赖 - BackendArchitect:后端规格去重逻辑分析 - SecurityEngineer:安全风险评估 - FrontendDev:前端 fallback 行为分析 - 最终汇总由 SecurityEngineer 写入 `reviews/council-ghost-spec-summary.md` - ---- - -## 输出报告 - -- `reviews/SecurityEngineer-GHOST_SPEC_SECURITY.md` — 详细安全审计报告 ✅ -- `reviews/council-ghost-spec-FrontendDev.md` — 前端调研报告 ✅ -- `reviews/council-ghost-spec-BackendArchitect.md` — 后端调研报告 ✅ -- `reviews/council-ghost-spec-summary.md` — 三方汇总报告 ✅ diff --git a/reviews/council-ghost-spec-FrontendDev.md b/reviews/council-ghost-spec-FrontendDev.md new file mode 100644 index 0000000..8e398ff --- /dev/null +++ b/reviews/council-ghost-spec-FrontendDev.md @@ -0,0 +1,254 @@ +# FrontendDev 调研报告:幽灵 spec 问题 + +> 日期:2026-04-20 | Agent:council/FrontendDev + +--- + +## 1. ticket_detail.html 的前端规格项构建 + +### 1.1 页面性质确认 + +`ticket_detail.html` 是**客户前端购票页面**(用于 C 端用户选座下单),**不是**后台商品编辑页面。后台编辑商品时出现的「规格不允许重复」错误发生在 ShopXO 标准后台,其触发点在 `GoodsService.php:1859/1889/1925`。 + +前端购票页面的数据来源: + +| PHP 变量 | 来源(SeatSkuService) | 用途 | +|----------|----------------------|------| +| `$vr_seat_template` | `GetGoodsViewData()` | `seat_map`、`spec_base_id_map` | +| `$goods_spec_data` | `GetGoodsViewData()` | 场次(session)列表 | + +前端 JS 接收这些数据: + +``` +ticket_detail.html:186-187 + seatMap: , + specBaseIdMap: , +``` + +前端规格项(场次)构建逻辑(`renderSessions()`, ticket_detail.html:202-213): + +```javascript +var specData = || []; +// specData 格式: [{spec_id: 2001, spec_name: "08:00-23:59", price: 100}] +// 渲染为可点击的场次卡片 +``` + +**结论**:`ticket_detail.html` 本身不构建 ShopXO 规格(spec)表格,其规格项仅为场次选择器。真正触发「规格不允许重复」的是 ShopXO 后台商品编辑页的 `GoodsService.php`。 + +--- + +## 2. 模板不存在时前端对 template_snapshot 和 spec_base_id_map 的处理 + +### 2.1 后端 fallback 行为(SeatSkuService.php) + +关键函数:`GetGoodsViewData()` (`SeatSkuService.php:358-464`) + +**模板不存在时的 fallback(硬删除场景)**: + +```php +// SeatSkuService.php:383-393 +if (empty($seatTemplate)) { + $config['template_id'] = null; + $config['template_snapshot'] = null; + Db::name('Goods')->where('id', $goodsId)->update([ + 'vr_goods_config' => json_encode([$config], ...), + ]); + return [ + 'vr_seat_template' => null, + 'goods_spec_data' => [], + 'goods_config' => $config, + ]; +} +``` + +**执行效果**: +1. `template_id` 被置为 `null`(写入 DB) +2. `template_snapshot` 被置为 `null`(写入 DB) +3. 返回给前端:`vr_seat_template = null`、`goods_spec_data = []` + +**前端接收到的数据**: +```javascript +seatMap: {} // 空对象 +specBaseIdMap: {} // 空对象 +goods_spec_data: [] // 空数组 +``` + +**前端渲染结果**: +- `renderSessions()`:`sessionGrid` 内为 `goods_spec_data.length === 0`,显示提示「该商品暂无场次信息」(ticket_detail.html:133) +- `renderSeatMap()`:`seatMap.map` 为空,座位图区域显示「座位图加载失败」 +- 整个座位选择区域 UI 为空/失败状态 + +### 2.2 根因分析 + +**模板不存在时,前端的 fallback 行为是正确的**——前端展示空白购票页,用户无法选座。这符合"场馆已删除,无法购票"的业务预期。 + +真正的问题不在 `ticket_detail.html`(前端),而在: +1. 后台商品编辑页(ShopXO admin)——保存时 `AdminGoodsSaveHandle` 如何处理 `template_id=null` 的情况 +2. `vr_goods_config` 的持久化清理——硬删除后 `vr_goods_config` 中的 config 块是否被正确清理 + +--- + +## 3. loadSoldSeats() 函数实现情况 + +**状态:未实现(仅有 TODO 注释)** + +``` +ticket_detail.html:375-383 +loadSoldSeats: function() { + // TODO: 从后端加载已售座位 + // $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', { + // goods_id: this.goodsId, + // spec_base_id: this.sessionSpecId + // }, function(res) { + // // 标记已售座位 + // }); +}, +``` + +**影响**: +- `soldSeats: {}` 永远为空对象(ticket_detail.html:189) +- `renderSeatMap()` 渲染座位时,无法从 `soldSeats` 读取已售标记 +- 已售座位只能通过 `.sold` class(由 PHP 渲染)或 `soldSeats` 字典来标记,但两者都未生效 +- 结果:前端无法区分已售/可选座位——用户可能选中一个已售座位,提交后才发现无法购买 + +**严重程度**:P2(功能缺陷),不影响「规格不允许重复」错误。 + +--- + +## 4. 编辑模式下前端对已删除场馆旧规格的处理 + +### 4.1 当前行为 + +当商品的 `vr_goods_config` 中 `template_id` 指向的场馆已被硬删除: + +1. `GetGoodsViewData()` 检测到模板不存在 → `template_id=null`、`template_snapshot=null` → 写入 DB +2. 前端收到 `vr_seat_template=null`、`goods_spec_data=[]` +3. `ticket_detail.html` 渲染空白购票页(无场次、无座位图) +4. **前端没有特殊逻辑处理幽灵 spec**——因为后端已经清理了 `template_id` 和 `template_snapshot` + +### 4.2 问题点 + +**`ticket_detail.html` 是前端购票页,不是编辑页**。商品编辑(后台)由 ShopXO 标准后台处理,VR 插件通过钩子介入。 + +幽灵 spec 的真正风险在于 `AdminGoodsSaveHandle` 的保存逻辑: + +- `AdminGoodsSaveHandle.php:383-394`(硬删除 fallback):当模板不存在时,`continue` 跳过 snapshot 重建,**但 config 块本身未被移除** +- 如果 `vr_goods_config` 包含多个 config 块(如多场馆商品),硬删除场馆后该 config 块残存 +- 下次编辑时,该 config 块仍被读取,若前端重新选择了场馆,可能导致 spec 重复 + +--- + +## 5. 前端根因分析 + +### 5.1 「规格不允许重复」错误的真正触发点 + +该错误**不在 `ticket_detail.html`**,而在 ShopXO 后台商品编辑流程的 `GoodsService.php:1859/1889/1925`。 + +触发条件: +1. 用户在 ShopXO 后台编辑商品时,手动填写/复制了重复的规格值 +2. 表单提交到 `GoodsService::GoodsSave()` → spec 验证逻辑检查 `specifications_value_*` 参数 +3. 发现有重复值 → 返回「规格不允许重复」错误 + +### 5.2 与 VR 插件的关联 + +当 `AdminGoodsSaveHandle` 运行时(`plugins_service_goods_save_thing_end`),它会: +1. 清空 `GoodsSpecType`、`GoodsSpecBase`、`GoodsSpecValue`(AdminGoodsSaveHandle.php:152-155) +2. 对 `template_id > 0` 的 config 块执行 `BatchGenerate` + +如果 `template_id` 为 `null`(硬删除后),`BatchGenerate` 跳过,但 `vr_goods_config` 中的 config 块仍然残存。**此时商品 spec 表为空**,不会出现「规格不允许重复」错误。 + +但如果用户在前端(ShopXO 后台编辑页)操作时,ShopXO 的原生规格表单被填充了旧的 VR 规格数据,这些数据可能在保存时被 ShopXO 的原生规格逻辑验证并触发重复错误。 + +--- + +## 6. 修复方案 + +### 6.1 前端修复(ticket_detail.html) + +**loadSoldSeats() 建议实现**: + +```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; // {row_col: true, ...} + self.markSoldSeats(); + } + }); +}, +markSoldSeats: function() { + var self = this; + document.querySelectorAll('.vr-seat').forEach(function(el) { + var key = el.dataset.rowLabel + '_' + el.dataset.colNum; + if (self.soldSeats[key]) { + el.classList.add('sold'); + } + }); +}, +``` + +### 6.2 后端修复(建议 BackendArchitect 评估) + +当模板被硬删除后,`AdminGoodsSaveHandle` 应清理整个 config 块: + +```php +// AdminGoodsSaveHandle.php:77-90 改进 +if (empty($template)) { + // 模板不存在时,移除整个 config 块(避免残存) + unset($configs[$i]); + continue; +} +$configs = array_values($configs); // 重排索引 +``` + +或在 `SeatSkuService::GetGoodsViewData()` 中持久化清理: + +```php +// SeatSkuService.php:383-393 改进 +if (empty($seatTemplate)) { + // 模板不存在时,清除整个 config 块,而非仅置 null + $vrGoodsConfig = json_decode($goods['vr_goods_config'] ?? '', true); + unset($vrGoodsConfig[0]); + $newConfig = array_values($vrGoodsConfig); + Db::name('Goods')->where('id', $goodsId)->update([ + 'vr_goods_config' => empty($newConfig) ? '' : json_encode($newConfig, ...), + ]); + return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null]; +} +``` + +--- + +## 7. 总结 + +| 问题 | 位置 | 严重度 | 说明 | +|------|------|--------|------| +| loadSoldSeats() 未实现 | ticket_detail.html:375 | P2 | 已售座位无法标记 | +| 模板不存在时 fallback 正确 | SeatSkuService.php:383 | — | 后端已正确清理 template_id | +| 「规格不允许重复」不在前端触发 | GoodsService.php:1859 | — | 触发点在 ShopXO 后台服务层 | +| config 块残留 | AdminGoodsSaveHandle.php | P2 | 硬删除后 config 块未移除 | +| spec_base_id_map 不影响前端 | ticket_detail.html:417 | P3 | 前端通过 seatKey 查找,未使用 map | + +--- + +## 8. 文件路径索引 + +| 文件 | 行号 | 关键内容 | +|------|------|---------| +| `SeatSkuService.php` | 358-464 | `GetGoodsViewData()`,模板不存在 fallback | +| `SeatSkuService.php` | 383-394 | 模板不存在时置 null 并更新 DB | +| `AdminGoodsSaveHandle.php` | 77-145 | config 块遍历和 snapshot 重建逻辑 | +| `AdminGoodsSaveHandle.php` | 152-155 | 清空原生 spec 表 | +| `AdminGoodsSaveHandle.php` | 158-173 | BatchGenerate 循环(跳过 template_id=0)| +| `ticket_detail.html` | 186-189 | 前端 JS 接收 seatMap/specBaseIdMap | +| `ticket_detail.html` | 202-213 | `renderSessions()` 场次渲染 | +| `ticket_detail.html` | 375-383 | `loadSoldSeats()` TODO(未实现)| +| `ticket_detail.html` | 417 | specBaseIdMap 查找(仅 Plan A 提交用)| +| `GoodsService.php` | 1859 | 规格值列重复检测 | +| `GoodsService.php` | 1889 | 规格值重复检测 | +| `GoodsService.php` | 1925 | 规格名称重复检测 | diff --git a/reviews/council-ghost-spec-summary.md b/reviews/council-ghost-spec-summary.md index 7b8c594..3535936 100644 --- a/reviews/council-ghost-spec-summary.md +++ b/reviews/council-ghost-spec-summary.md @@ -1,163 +1,221 @@ -# 幽灵 Spec 问题 — 三方调研汇总报告(终版) +# 幽灵 Spec 问题 — Council 调研汇总报告 -**版本**: v2.0 -**日期**: 2026-04-20 -**汇总人**: SecurityEngineer -**来源报告**: SecurityEngineer-GHOST_SPEC_SECURITY.md + council-ghost-spec-FrontendDev.md + council-ghost-spec-BackendArchitect.md +> 日期:2026-04-20 | Agent:FrontendDev + BackendArchitect + SecurityEngineer +> 基于 main 分支 `f84f95b56` --- -## 一、问题概述 +## 一、问题定义 -当票务商品关联的场馆模板被硬删除后,编辑商品时出现「规格不允许重复」错误。 +**「场馆删除后编辑商品出现规格重复错误」**的技术描述: -**注意**:`ticket_detail.html` 是 **C 端购票页面**(用于用户选座下单),不是后台商品编辑页面。「规格不允许重复」错误的真正触发点在 ShopXO 后台服务层 `GoodsService.php:1859/1889/1925`。 +1. 商品关联场馆模板 A,`vr_goods_config` 中存储 `template_id`、`template_snapshot`、`spec_base_id_map` +2. 场馆 A 被硬删除,`vr_seat_templates` 表中无记录 +3. 编辑商品时前端检测到模板不存在,自动置空场馆选择 +4. 但旧的幽灵 spec(来自已删除场馆的配置)仍混入表单 +5. 提交时触发「规格不允许重复」 -### 问题触发路径 +--- -``` -1. 商品选择场馆 A → vr_goods_config 存储 template_id=A、template_snapshot -2. 场馆 A 被硬删除 → vr_seat_templates 表中无记录 -3. 编辑商品 → GetGoodsViewData() 发现 template_id 无效 - → 将 template_id 置 null、template_snapshot 置 null - → 写回 DB(自愈行为) - → 前端收到 template_id=null,选单为空 -4. 若 template_id 未被及时清理 → 保存时 BatchGenerate 返回 "座位模板 N 不存在" -5. 若 template_id 已清理 → 保存成功,但原规格数据丢失 +## 二、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(); + } + }); +}, ``` --- -## 二、各 Agent 调研结论 +### 2.2 BackendArchitect — 后端调研(`reviews/BackendArchitect-on-Issue-13-debug.md`) -### 2.1 FrontendDev 调研结论(来源:`council-ghost-spec-FrontendDev.md`) +#### 关键发现 -| 问题 | 文件:行号 | 严重度 | -|------|---------|--------| -| `loadSoldSeats()` 未实现(TODO 空函数) | ticket_detail.html:375-383 | P2 | -| 模板不存在时 fallback 行为正确 | SeatSkuService.php:383 | — | -| 「规格不允许重复」不在前端触发 | GoodsService.php:1859 | — | -| config 块残留(硬删除后未移除) | AdminGoodsSaveHandle.php | P2 | -| `spec_base_id_map` 不影响前端 | ticket_detail.html:417 | P3 | +**Primary Bug — 99% 命中** -**前端关键发现**: -- `ticket_detail.html` 本身不构建 ShopXO 规格表格,其规格项仅为场次选择器 -- 模板不存在时前端展示空白购票页(符合业务预期) -- `loadSoldSeats()` 是 TODO 注释,未发送 HTTP 请求,已售座位无法灰显 +| 文件 | 行号 | 问题代码 | +|------|------|----------| +| `AdminGoodsSaveHandle.php` | **77** | `return in_array($r['id'], $config['selected_rooms'] ?? []);` | -**修复建议**: -- P2: 实现 `loadSoldSeats()` 从后端加载已售座位数据 -- P2: AdminGoodsSaveHandle 硬删除后移除整个 config 块而非仅置 null +当 `$r`(rooms 数组元素)缺少 `'id'` key 时,访问 `$r['id']` 直接抛出 `Undefined array key "id"`。 -### 2.2 BackendArchitect 调研结论(来源:`council-ghost-spec-BackendArchitect.md`) +**对比:SeatSkuService::BatchGenerate:100 已有正确防护** +```php +// ✅ 安全写法 +$roomId = !empty($room['id']) ? $room['id'] : ('room_' . $rIdx); +``` +而 `AdminGoodsSaveHandle:77` 没有这个防护。 -| 优先级 | 根因描述 | 文件:行号 | 影响 | -|--------|----------|-----------|------| -| **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 | 前端过滤后 configs 为空时,用户无声失去所有配置 | AdminGoodsSave.php:196-229 | 体验问题:用户不知道配置被过滤 | -| P5 | `loadSoldSeats()` 未实现 | ticket_detail.html:375-383 | 顾客可选已售座位,可能超卖 | +**Secondary Bug — 模板不存在时 null 访问** -**后端关键发现**: +| 文件 | 行号 | 问题代码 | +|------|------|----------| +| `AdminGoodsSaveHandle.php` | **71** | `$seatMap = json_decode($template['seat_map'] ?? '{}', true);` | -1. **P1 根因**(Critical):`AdminGoodsSaveHandle.php:88-89` 中 `continue` 不删除 config 块,导致含无效 `template_id` 的脏配置被写回 DB(第 148-150 行)。无效模板的 config 块在每次保存后持续累积。 +当 `find()` 返回 null 后,`$template['seat_map']` 在 PHP 8.0+ 抛出 `TypeError`。 -2. **`spec_base_id_map` 不是幽灵 spec 来源**:该字段存储在 `vr_seat_templates` 表,模板硬删除后自然消失,不会在 goods 表的 `vr_goods_config` 中残留。 +**Tertiary Bug — 类型不匹配静默失败** -3. **`spec_base_id_map` 数据流**:存储在模板表 → `GetGoodsViewData` 读取解码(SeatSkuService.php:404-409)→ 前端 JS 接收。删除后前端 fallback 到 `sessionSpecId`。 +| 文件 | 行号 | 问题代码 | +|------|------|----------| +| `AdminGoodsSaveHandle.php` | **77** | `in_array($r['id'], ...)` 类型不一致 | -4. **多模板模式 P2 缺陷**:GetGoodsViewData 第 368 行只取 `$vrGoodsConfig[0]`,多模板模式下第 2、3... 个配置块被完全忽略。第 386-388 行写回 DB 时只写 `[$config]`(单元素),会覆盖其他有效配置块。 +`selected_rooms[]` 从前端传来是字符串(如 `"room_0"`),而 `$r['id']` 可能是整数。类型不匹配时 `in_array()` 永远返回 `false`,静默导致 `selectedRoomIds` 为空数组。 -**修复方案**: +#### 后端根因 -- **P1 Fix**: `AdminGoodsSaveHandle.php:88-89` 将 `continue` 改为 `unset($configs[$i])`,第 145 行后加 `$configs = array_values($configs)` 重排索引,第 148-150 行前加判空。 -- **P2 Fix**: `SeatSkuService.php:368-393` 改为遍历所有有效配置块,写回时使用 `$validConfigs` 而非单元素数组。 +幽灵 spec 在 `AdminGoodsSaveHandle.php:88` 的 `continue` 处产生:当模板不存在时,`continue` 跳过 snapshot 重建,但 **config 块本身未被移除**,残存在 `vr_goods_config` 中。 -### 2.3 SecurityEngineer 安全审计结论(来源:`SecurityEngineer-GHOST_SPEC_SECURITY.md`) +#### 后端修复建议(已合并) -| ID | 问题 | 严重性 | -|----|------|--------| -| S-1 | 场馆硬删除后保存失败,错误信息不友好 | P2 | -| S-2 | GetGoodsViewData 静默修改 DB | P2 | -| S-3 | `loadSoldSeats()` 空实现,前端无法标记已售座位 | P2 | -| S-4 | `template_snapshot` 无大小限制 | P3 | +```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); + // ... +} -**P1 安全漏洞发现:0 个** +// 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 | + +--- + +## 五、报告文件索引 + +| 报告 | 路径 | |------|------| -| 脏数据注入 | **安全** — 无注入路径 | -| 规格覆盖 | **安全** — 先删后建,BatchGenerate 是唯一来源 | -| XSS 风险 | **安全** — 无渲染点 | -| 权限绕过 | **安全** — 依赖 ShopXO 内核 | -| DoS 风险 | **低** — 建议 DB 层加字段大小限制 | - -**安全评估**:幽灵 spec 问题经审计后确认不是安全漏洞(无 P1): -1. `spec_base_id_map` 不可控:不在表单提交范围内,不在 `vr_goods_config` 中 -2. `template_snapshot` 保存时由后端重建,前端传入值被覆盖 -3. `BatchGenerate` 有保护:模板不存在时返回错误阻断保存 - ---- - -## 三、综合结论 - -### 问题定性 - -| 维度 | 结论 | -|------|------| -| **安全评级** | 无漏洞(0 P1 安全漏洞) | -| **功能评级** | **P1** — 无效 config 块未被移除,脏数据写回 DB | -| **其他功能缺陷** | P2 — 错误信息不友好、自愈行为副作用、超卖风险 | - -**重要区分**:SecurityEngineer 的 P1 定义是「安全漏洞」,BackendArchitect 的 P1 定义是「功能性高优先级缺陷」。两者都正确: -- 从安全角度:无 P1 安全漏洞(0 个) -- 从功能角度:无效 config 块残留是 P1 优先级缺陷(需立即修复) - -### 根因链 - -``` -1. 场馆硬删除 → vr_seat_templates 表记录消失 -2. 商品 vr_goods_config.template_id 仍为已删除场馆的 ID -3. AdminGoodsSaveHandle.php:88-89 执行 continue(不删除 config 块) -4. 第 148-150 行将含无效 template_id 的脏 config 写回 DB -5. 幽灵 config 块在 DB 中持续累积 -6. 下次保存时 BatchGenerate 检测到无效模板 → 返回 code=-2 → 保存阻断 -7. 用户看到不友好的错误信息「座位模板 N 不存在」 -``` - -### 关键保护机制 - -- `BatchGenerate` 模板存在性检查(SeatSkuService.php:52-57)是最后防线:模板不存在时保存被阻断,无脏数据写入规格表 -- 前端 `AdminGoodsSave.php:202` 过滤硬删除模板的 config 块(有效) - ---- - -## 四、修复建议(优先级排序) - -| 优先级 | 修复项 | 涉及文件 | Agent 归属 | -|--------|--------|---------|-----------| -| **P1** | 无效 config 块移除(`unset` + `array_values` + 判空) | AdminGoodsSaveHandle.php:88-145 + 148-150 | BackendArchitect | -| **P2-高** | GetGoodsViewData 多模板模式修复 | SeatSkuService.php:368-393 | BackendArchitect | -| **P2-中** | 改善 BatchGenerate 错误信息,引导用户重新选择场馆 | SeatSkuService.php:55-57 | BackendArchitect | -| **P2-中** | 改善前端过滤无效配置后的用户体验提示 | AdminGoodsSave.php:196-229 | FrontendDev | -| **P2-中** | 实现 `loadSoldSeats()` 标记已售座位 | ticket_detail.html:375-383 | FrontendDev | -| **P3-低** | `vr_goods_config` 字段加 TEXT 限制 | DB migration | BackendArchitect | - ---- - -## 五、各 Agent 报告位置 - -| Agent | 报告文件 | -|-------|---------| -| SecurityEngineer | `reviews/SecurityEngineer-GHOST_SPEC_SECURITY.md` | -| FrontendDev | `.worktrees/FrontendDev/reviews/council-ghost-spec-FrontendDev.md` | -| BackendArchitect | `.worktrees/BackendArchitect/reviews/council-ghost-spec-BackendArchitect.md` | - ---- - -## 六、后续行动 - -1. **BackendArchitect** 实施 P1 Fix(AdminGoodsSaveHandle 无效 config 块移除) -2. **FrontendDev** 实施 P2-中修复(loadSoldSeats 实现 + 前端提示) -3. 优先处理 P1(无效 config 块移除)和 P2-高(多模板模式)修复 \ No newline at end of file +| 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` | \ No newline at end of file