vr-shopxo-plugin/reviews/council-ghost-spec-BackendA...

438 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# BackendArchitect 调研报告:场馆删除后规格重复根因分析(终版)
> Agentcouncil/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_snapshottemplate_id 无效则 continue
├─ 写回 vr_goods_config 到 goods 表(第 148-150 行)
├─ 清空 GoodsSpecType/GoodsSpecBase/GoodsSpecValue第 152-155 行)
├─ 逐模板 BatchGenerate无效 template_id 静默跳过)
└─ refreshGoodsBase
```
---
## 二、幽灵 spec 根因定位(含行号)
### 根因 1Critical无效 config 块在保存时未被移除,导致脏数据写回 DB
**文件**`shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php`
**行号**83-90snapshot 重建循环内) + 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. 场馆 Atemplate_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 块会参与后续流程
### 根因 2HighGetGoodsViewData 仅处理单模板模式,多模板时无效块不清理
**文件**`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]`(单元素),这在**单模板模式下会覆盖掉其他有效配置块**
### 根因 3MediumBatchGenerate 对无效 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} 不存在"`,但用户看到的可能是前端显示的通用错误
### 根因 4MediumAdminGoodsSave 前端过滤无法防御 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 为空数组**时,用户看不到任何配置,需要重新选择场馆和场次。无声的过滤体验不好但不造成错误。
### 根因 5LowGoodsService 规格列值去重检测
**文件**`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 规格 ← GOODVR 规格写入
若 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: <?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>,
specBaseIdMap: <?php echo json_encode($vr_seat_template['spec_base_id_map'] ?? []); ?>,
```
- `$vr_seat_template` 来自 `SeatSkuService::GetGoodsViewData()` 的返回值
- 模板不存在时,`GetGoodsViewData:383-393` 返回 `'vr_seat_template' => null`
- 此时 `seatMap``specBaseIdMap` 均为 `[]`
### 6.2 场次渲染(第 201-213 行)
```javascript
renderSessions: function() {
var specData = <?php echo json_encode($goods_spec_data ?? []); ?> || [];
// 动态渲染场次列表
}
```
- `$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 问题,但影响已售座位显示