11 KiB
安全审计报告:AdminGoodsSaveHandle 数据验证逻辑
审计员: council/SecurityEngineer
日期: 2026-04-20
目标: AdminGoodsSaveHandle.php save_thing_end 时机(bbea35d83 改动)
报告类型: 根因分析 + 修复建议
执行摘要
商品保存时报错 Undefined array key "id",根因定位在 AdminGoodsSaveHandle.php:77 的 array_filter 回调中直接访问 $r['id'],当 seat_map.rooms[] 中存在缺失 id 字段的房间对象时触发。此外还发现 3 个次要风险点。
Q1: "Undefined array key 'id'" 最可能出现在哪一行?
所有涉及 id 访问的位置
| 行号 | 代码 | 安全性 | 说明 |
|---|---|---|---|
| 77 | $r['id'] |
⚠️ 不安全 | array_filter 回调内,无空安全保护 → Primary 错误源 |
| 68 | $config['template_id'] |
✅ 安全 | 有 ?? 0 兜底 |
| 71 | $template['seat_map'] |
⚠️ 见 Q3 | find() 可能返回 null |
| 103 | $config['template_id'] |
✅ 安全 | 同 68 |
| 76 | $config['selected_rooms'] |
⚠️ 见 Q5 | 可能不存在或类型不匹配 |
| 101 | $config['template_id'] |
✅ 安全 | 同 68 |
| 103 | $config['selected_rooms'] |
⚠️ 见 Q5 | 同 76 |
| 104 | $config['selected_sections'] |
✅ 安全 | 有 ?? [] 兜底 |
| 105 | $config['sessions'] |
✅ 安全 | 有 ?? [] 兜底 |
Primary 根因(99% 命中)
// AdminGoodsSaveHandle.php:75-79
$selectedRoomIds = array_column(
array_filter($allRooms, function ($r) use ($config) {
return in_array($r['id'], $config['selected_rooms'] ?? []); // ← 第 77 行崩溃
}), null
);
触发条件:vr_seat_templates.seat_map.rooms[] 中任一房间对象缺少 id 键。
ShopXO 存储座位图时,如果前端 JSON 序列化或数据库写入过程中出现以下情况:
- 某个房间在模板编辑时被删除了
id字段 - 历史数据从旧版模板迁移时
id字段丢失 - 前端构造房间对象时使用了非标准字段名(如
roomId而非id)
则 $r['id'] 直接触发 Undefined array key "id"。
Q2: 表前缀问题 — Db::name() vs BaseService::table()
分析结论:等价,不存在问题
| 调用方式 | 等价 SQL 表名 | 说明 |
|---|---|---|
Db::name('vr_seat_templates') |
{prefix}vr_seat_templates |
ShopXO 自动加全局前缀 |
BaseService::table('seat_templates') 返回 'vr_seat_templates' |
{prefix}vr_seat_templates |
插件前缀层叠加 |
Db::name(BaseService::table('seat_templates')) |
{prefix}vrt_vr_seat_templates |
双重前缀(错误) |
实际使用的两种写法
| 位置 | 写法 | 实际查询表 | 正确? |
|---|---|---|---|
AdminGoodsSaveHandle:70 |
Db::name('vr_seat_templates') |
{prefix}vr_seat_templates |
✅ 正确 |
SeatSkuService:52 |
Db::name(self::table('seat_templates')) |
{prefix}vrt_vr_seat_templates |
⚠️ 需确认前缀配置 |
ShopXO 前缀配置分析
ShopXO 的 Db::name() 根据插件名自动加上插件专属前缀。BaseService::table() 手动加 vr_,两者组合会产生 双重前缀。但如果 ShopXO 的全局前缀为空(prefix = ''),两种写法等价。
结论:BackendArchitect 和 DebugAgent 已确认 Db::name('vr_seat_templates') 等价于 Db::name(self::table('seat_templates'))。表前缀不是本次错误的原因。
Q3: find($templateId) 返回 null 时的行为
// AdminGoodsSaveHandle.php:70-71
$template = Db::name('vr_seat_templates')->find($templateId);
$seatMap = json_decode($template['seat_map'] ?? '{}', true); // ← 若 $template 为 null
风险评估:Secondary 根因
当 $templateId > 0 但模板记录不存在时:
$template→null$template['seat_map']→ "Undefined array key 'seat_map'"(PHP 8.x 报 Warning/Error)- PHP 8.0+ 中
null['key']直接抛出Error,而非返回 null
现有代码已有部分防御
SeatSkuService::BatchGenerate:55 有正确防御:
if (empty($template)) {
return ['code' => -2, 'msg' => "座位模板 {$seatTemplateId} 不存在"];
}
但 AdminGoodsSaveHandle 中没有类似防御。
Q4: $configs JSON 解码后的类型安全性
分析结论:部分安全
// AdminGoodsSaveHandle.php:61-64
$rawConfig = $data['vr_goods_config'] ?? '';
if (!empty($rawConfig)) {
$configs = json_decode($rawConfig, true);
if (is_array($configs) && !empty($configs)) { // ← ✅ 有类型检查
安全点:
- ✅
is_array($configs)确保不是null或标量 - ✅
!empty($configs)排除空数组
潜在盲点:
json_decode失败时返回null,被is_array挡掉 ✅- 但
$configs是数组的数组:[[...]]vs[...]?代码使用foreach ($configs as $i => &$config)兼容两者(每层都是关联数组或索引数组) ✅ $config['template_id']访问有?? 0兜底 ✅
Q5: selected_rooms 数据结构与类型匹配
分析结论:静默逻辑错误风险
根据 VR_GOODS_CONFIG_SPEC.md:
"selected_rooms": ["room_id_1776341371905", "room_id_1776341444657"]
→ 字符串数组
类型匹配问题
// AdminGoodsSaveHandle.php:77
return in_array($r['id'], $config['selected_rooms'] ?? []);
// ↑ 房间 'id'(字符串)
// ↑ selected_rooms 元素(也是字符串) ✅ 类型一致
实际类型匹配是正确的(两者都是字符串)。
但存在以下静默错误风险:
| 风险场景 | 原因 | 后果 |
|---|---|---|
$r['id'] 缺失(Primary) |
房间对象无 id 键 |
直接崩溃 |
selected_rooms 为空数组 |
用户未选房间 | array_filter 返回空,rooms 写入空数组 |
selected_rooms 包含无效 ID |
前端传了不存在的 room_id | 所有房间被过滤掉,静默空结果 |
对比:SeatSkuService 的安全写法
// SeatSkuService.php:99-100(正确的防御性写法)
$roomId = !empty($room['id']) ? $room['id'] : ('room_' . $rIdx);
AdminGoodsSaveHandle 缺少这个 fallback。
Q6: SeatSkuService::BatchGenerate 和 $data['item_type'] 访问安全性
SeatSkuService::BatchGenerate ✅ 安全
- 参数都有类型声明(
int,array) - 对
$rooms遍历时有空安全:$room['id']有 fallback (room_$rIdx) $selectedSections访问有?? []兜底empty($template)检查存在
$data['item_type'] 访问 ✅ 安全
// AdminGoodsSaveHandle.php:59
if ($goodsId > 0 && ($data['item_type'] ?? '') === 'ticket') {
- 有
?? ''兜底,空值时条件为false,不会进入票务处理分支 item_type是save_handle时机中自己写入的(Line 26:$params['data']['item_type'] = 'ticket'),逻辑自洽
综合根因总结
根因分级
| 级别 | 位置 | 问题 | 影响 |
|---|---|---|---|
| P0 — Primary | AdminGoodsSaveHandle.php:77 |
$r['id'] 无空安全,房间缺字段时直接崩溃 |
保存商品立即 500 |
| P1 — Secondary | AdminGoodsSaveHandle.php:71 |
find() 返回 null 后直接访问 $template['seat_map'] |
模板不存在时崩溃 |
| P2 — Tertiary | AdminGoodsSaveHandle.php:75-79 |
selected_rooms 类型/存在性验证不足 |
静默空结果 |
| P3 — Info | AdminGoodsSaveHandle.php:91-93 |
JSON 编码异常(json_encode 失败)无捕获 |
数据回写失败 |
与 BackendArchitect 评审的一致性
本报告与 BackendArchitect 的 reviews/BackendArchitect-on-Issue-13-debug.md 结论一致:
- Primary 根因:Line 77
$r['id']无空安全 ✅ - Secondary:
find()返回 null ✅ - Tertiary:
selected_rooms类型不匹配 ✅(本报告进一步确认为静默风险,非直接崩溃)
修复建议
P0 修复(一行改动)
// AdminGoodsSaveHandle.php:74-79(修复后)
$selectedRoomIds = array_column(
array_filter($allRooms, function ($r) use ($config) {
return !empty($r['id']) && in_array($r['id'], $config['selected_rooms'] ?? []);
}), null
);
添加 !empty($r['id']) 前置检查,与 SeatSkuService:100 的防御策略一致。
P1 修复(添加模板存在性检查)
// AdminGoodsSaveHandle.php:69-71(修复后)
if ($templateId > 0) {
$template = Db::name('vr_seat_templates')->find($templateId);
if (empty($template)) {
continue; // 跳过无效模板,不阻塞整个保存流程
}
$seatMap = json_decode($template['seat_map'] ?? '{}', true);
建议的完整防御代码
// 填充 template_snapshot(前端没传时兜底从 vr_seat_templates 读)
foreach ($configs as $i => &$config) {
if (empty($config['template_snapshot'])) {
$templateId = intval($config['template_id'] ?? 0);
if ($templateId > 0) {
$template = Db::name('vr_seat_templates')->find($templateId);
if (empty($template)) {
continue; // P1: 跳过不存在的模板
}
$seatMap = json_decode($template['seat_map'] ?? '{}', true);
$allRooms = $seatMap['rooms'] ?? [];
// P0: 先过滤掉无 id 的脏数据,再按 selected_rooms 过滤
$validRooms = array_filter($allRooms, function ($r) {
return !empty($r['id']); // P0 修复
});
$selectedRoomIds = array_column(
array_filter($validRooms, function ($r) use ($config) {
return in_array($r['id'], $config['selected_rooms'] ?? []);
}), null
);
$config['template_snapshot'] = [
'venue' => $seatMap['venue'] ?? [],
'rooms' => $selectedRoomIds,
];
}
}
}
unset($config);
附:PHP 版本兼容性
| PHP 版本 | null['key'] 行为 |
find() 返回 null 时 |
|---|---|---|
| PHP 7.x | 返回 null(Undefined index Warning) |
访问 $template['seat_map'] → Warning |
| PHP 8.0+ | 抛出 TypeError |
同上 |
本项目应确认生产环境 PHP 版本,以评估错误级别。
结论
"Undefined array key 'id'" 的根因是 AdminGoodsSaveHandle.php:77 直接访问 $r['id'] 而未检查键是否存在。当 seat_map.rooms[] 中存在脏数据(缺失 id 字段的房间对象)时,PHP 直接崩溃。
最简修复:在 array_filter 回调中添加 !empty($r['id']) 前置条件,与同项目中 SeatSkuService::BatchGenerate:100 的已有防御模式保持一致。
报告生成时间: 2026-04-20 审计员: council/SecurityEngineer