237 lines
7.8 KiB
Markdown
237 lines
7.8 KiB
Markdown
|
|
# 幽灵 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. **商品创建时**:用户配置了场馆 A(template_id=5)和场馆 B(template_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 | 修复 P2:GetGoodsViewData 多模板清理 | SeatSkuService.php | P1 |
|
|||
|
|
| 3 | 测试验证 | — | — |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 八、结论
|
|||
|
|
|
|||
|
|
1. **Council 的调研结果基本正确**,但遗漏了前端过滤本身是有效的这一点
|
|||
|
|
2. **真正的根因**在于 `save_thing_end` 时机从 DB 读取的是 goods 表中的旧数据,而非前端提交的数据
|
|||
|
|
3. **修复方案**是:在后端遍历时直接移除无效 config 块,确保脏数据不回写 DB
|
|||
|
|
4. **GetGoodsViewData** 也需要同步修复,支持多模板模式
|