18 KiB
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)
// 第 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 表 - 下次编辑时,脏数据仍然存在
触发路径:
- 场馆 A(template_id=5)被硬删除,
vr_seat_templates无记录 - 商品的
vr_goods_config[0].template_id = 5仍保留在 goods 表 - 用户编辑商品 →
GetGoodsViewData检测到无效模板,清template_id并写回 DB(单模板模式可部分缓解) - 但若有多模板配置块,其中一个无效:前端过滤掉无效块 → 提交时只有有效块 → 后端继续处理有效块 → 无效块因
continue保留在 DB - 真正危险场景:若前端过滤失效(如
validTemplateIds构建有误),无效 config 块会参与后续流程
根因 2(High):GetGoodsViewData 仅处理单模板模式,多模板时无效块不清理
文件:shopxo/app/plugins/vr_ticket/service/SeatSkuService.php
行号:368-393
// 第 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
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:
$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
// 第 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
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):
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
// 第 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 模板数据加载
// 第 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 行)
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 行)
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
// 第 88-89 行修改前
if (empty($template)) {
continue;
}
// 第 88-89 行修改后
if (empty($template)) {
unset($configs[$i]); // 移除无效 config 块
continue;
}
修改点 2:第 145 行 unset($config) 之后添加
$configs = array_values($configs); // 重排数组索引,避免 JSON 序列化出现非连续数字索引
修改点 3:第 148-150 行写回 DB 前添加判空
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 循环中,在调用前增加模板存在性显式校验
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],需扩展为遍历所有有效配置块:
// 在 $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 写回逻辑:
// 当前:只写回 [$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 为空,给用户提示:
// 在第 228 行后添加
if (configs.value.length === 0 && (AppData.vrGoodsConfig || []).length > 0) {
alert('检测到部分场馆配置已失效(对应场馆已被删除),已自动清除。请重新选择场馆。');
}
九、调研结论
-
幽灵 spec 的来源:
AdminGoodsSaveHandle.php:88-89的continue不删除无效 config 块,导致含无效template_id的脏配置被写回 DB(第 148-150 行) -
幽灵 spec 的清理时机:目前没有主动清理,只能依赖前端过滤(AdminGoodsSave.php:202)或下次
GetGoodsViewData调用时的单模板覆盖(P2 场景不适用) -
规格重复错误:最可能是 GoodsSpecificationsInsert 与 VR 插件清空规格的时序问题,或用户从普通商品转票务商品时原生规格未清干净
-
spec_base_id_map不是幽灵 spec 的来源:它存储在模板表,模板删除后自然消失,与 goods 表的 vr_goods_config 无关 -
loadSoldSeats()未实现:是 TODO 注释,不影响幽灵 spec 问题,但影响已售座位显示