vr-shopxo-plugin/reports/GHOST_SPEC_INVESTIGATION_RE...

237 lines
7.8 KiB
Markdown
Raw Permalink Normal View History

# 幽灵 Spec 问题调研报告
> 日期2026-04-20 | 来源:独立验证(验证 Council 调研结果)
---
## 一、问题概述
**症状**:删除场馆后,编辑商品时即便场馆已置空,提交保存时仍不自动清除对应的 spec。
**Council 结论**:根因在 `AdminGoodsSaveHandle.php:88-89``continue` 语句,导致无效 config 块残留并写回 DB。
---
## 二、数据流分析
### 2.1 读取链路(商品编辑页加载)
```
ShopXO 商品编辑页
AdminGoodsSave::handle() 返回 Vue 组件 HTML
- 从 vr_seat_templates WHERE status=1 读取有效模板列表
- 从 goods.vr_goods_config 读取原始配置
AdminGoodsSave.php:196-202 (前端 JS 过滤)
.filter(c => validTemplateIds.has(c.template_id)) ← 关键过滤
.filter(...validRoomIds...) ← 过滤无效 room ID
Vue 表单展示清洗后的配置
用户修改配置,提交 vr_goods_config_base64
```
### 2.2 保存链路(商品保存)
```
前端提交 vr_goods_config_base64
AdminGoodsSaveHandle.php:29-35 (save_handle 时机)
base64_decode → 写入 $data['vr_goods_config']
ShopXO 原生 GoodsSpecificationsInsert (事务内)
生成 GoodsSpecType / GoodsSpecBase / GoodsSpecValue原生规格
AdminGoodsSaveHandle.php:55-179 (save_thing_end 时机)
├─ 从 DB 读 vr_goods_config最新数据
├─ 遍历 configs[],重建 template_snapshot无效 template_id 则 continue
├─ 写回 vr_goods_config 到 goods 表 ← 脏数据写回!
├─ 清空 GoodsSpecType/GoodsSpecBase/GoodsSpecValue
└─ 逐模板 BatchGenerate无效 template_id 静默跳过)
```
---
## 三、Council 调研结果的验证
### 3.1 Council 发现的核心问题(正确)
**文件**`shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php`
```php
// 第 77-90 行
foreach ($configs as $i => &$config) {
$templateId = intval($config['template_id'] ?? 0);
$selectedRooms = $config['selected_rooms'] ?? [];
if ($templateId > 0 && (!empty($selectedRooms) || empty($config['template_snapshot']) || empty($config['template_snapshot']['rooms']))) {
$template = Db::name('vr_seat_templates')->find($templateId); // 第 83 行
if (empty($template)) {
continue; // ← BUG只跳过本次循环config 块仍留在 $configs 数组中
}
// ... snapshot 重建逻辑
}
}
unset($config);
// 第 148-150 行:无条件写回 DB
Db::name('Goods')->where('id', $goodsId)->update([
'vr_goods_config' => json_encode($configs, ...),
]);
```
**问题**
1.`template_id` 指向已硬删除的模板时,`find()` 返回 null
2. `continue` 只跳过 snapshot 重建,但 config 块仍保留在 `$configs` 数组
3. 第 148-150 行将含无效 `template_id` 的 config 块写回 DB
### 3.2 前端过滤是否有效?
**Council 遗漏的关键点**后台商品编辑页AdminGoodsSave.php本身的前端过滤。
查看 `AdminGoodsSave.php:196-202`
```javascript
if (AppData.vrGoodsConfig && Array.isArray(AppData.vrGoodsConfig)) {
// 从 vr_seat_templates WHERE status=1 获取有效模板 ID
const validTemplateIds = new Set((AppData.templates || []).map(t => t.id));
configs.value = AppData.vrGoodsConfig
// 过滤掉模板已删除的配置
.filter(c => validTemplateIds.has(c.template_id))
```
**分析**
- `validTemplateIds` 只包含 `status=1` 的有效模板
- 硬删除的模板不在 `vr_seat_templates` 表中
- 所以 `.filter(c => validTemplateIds.has(c.template_id))` **会正确移除无效模板的配置**
**结论**:前端过滤是有效的,但问题出在后端的 `save_thing_end` 时机从数据库重新读取数据。
### 3.3 真实的问题场景
1. **商品创建时**:用户配置了场馆 Atemplate_id=5和场馆 Btemplate_id=6
2. **场馆 A 被硬删除**vr_seat_templates 表中无记录
3. **用户编辑商品**
- 前端读取 DB 中的 vr_goods_config仍含场馆 A 的配置)
- 前端过滤后只提交场馆 B 的配置
4. **后端 save_handle**:接收前端提交的只含场馆 B 的配置
5. **后端 save_thing_end**
- 从 DB 读取 vr_goods_config → **此时读到的是旧数据(含场馆 A**
- 遍历时场馆 A 的 template_id=5 查不到模板continue 跳过
- **场馆 A 的 config 块残留在数组中**
- 写回 DB → **场馆 A 的脏配置被写回!**
**关键发现**save_thing_end 从 DB 读取的是 goods 表中的数据,而非 save_handle 时提交的 `$data['vr_goods_config']`。如果 goods 表中原本就有脏数据,问题就会累积。
---
## 四、"规格不允许重复" 的来源
该错误信息来自 `GoodsService.php:1859`,是 ShopXO 原生规格验证逻辑。
**可能场景**
1. 商品曾以普通商品(有原生 spec保存
2. 后转换为票务商品
3. 保存时 ShopXO 原生 GoodsSpecificationsInsert 先生成原生规格
4. AdminGoodsSaveHandle save_thing_end 执行清空规格表
5. 但如果时序有问题,原生规格可能残留
---
## 五、根因总结
| 优先级 | 根因 | 位置 | 影响 |
|--------|------|------|------|
| **P1** | save_thing_end 从 DB 读取时,无效 config 块未被移除 | AdminGoodsSaveHandle.php:88-89 + 148-150 | 脏数据写回 DB幽灵 spec 累积 |
| P2 | GetGoodsViewData 只处理第一个配置块 | SeatSkuService.php:368 | 多模板时无效块不清理 |
---
## 六、修复方案
### P1 Fix立即实施
**文件**`AdminGoodsSaveHandle.php`
**修改 1**:第 88-89 行
```php
if (empty($template)) {
unset($configs[$i]); // 移除无效 config 块
continue;
}
```
**修改 2**:第 145 行后unset($config) 之后)
```php
unset($config);
$configs = array_values($configs); // 重排索引
```
**修改 3**:第 148-150 行前加判空
```php
if (!empty($configs)) {
Db::name('Goods')->where('id', $goodsId)->update([
'vr_goods_config' => json_encode($configs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
}
```
**修改 4**BatchGenerate 循环中增加防御性校验(第 158-173 行)
```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; // 无效块跳过
}
$res = SeatSkuService::BatchGenerate(...);
// ...
}
```
### P2 Fix高优先级
**文件**`SeatSkuService.php` 第 368-393 行
GetGoodsViewData 需要遍历所有配置块,清理无效块后再处理:
```php
// 过滤有效配置块
$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]; // 取第一个有效配置块用于前端展示
```
---
## 七、实施计划
| 步骤 | 任务 | 文件 | 优先级 |
|------|------|------|--------|
| 1 | 修复 P1无效 config 块移除 | AdminGoodsSaveHandle.php | P1 |
| 2 | 修复 P2GetGoodsViewData 多模板清理 | SeatSkuService.php | P1 |
| 3 | 测试验证 | — | — |
---
## 八、结论
1. **Council 的调研结果基本正确**,但遗漏了前端过滤本身是有效的这一点
2. **真正的根因**在于 `save_thing_end` 时机从 DB 读取的是 goods 表中的旧数据,而非前端提交的数据
3. **修复方案**是:在后端遍历时直接移除无效 config 块,确保脏数据不回写 DB
4. **GetGoodsViewData** 也需要同步修复,支持多模板模式