# 安全审计报告：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% 命中）

```php
// 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 时的行为

```php
// 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` 有正确防御：
```php
if (empty($template)) {
    return ['code' => -2, 'msg' => "座位模板 {$seatTemplateId} 不存在"];
}
```
但 `AdminGoodsSaveHandle` 中没有类似防御。

---

## Q4: `$configs` JSON 解码后的类型安全性

### 分析结论：**部分安全**

```php
// 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`:
```json
"selected_rooms": ["room_id_1776341371905", "room_id_1776341444657"]
```
→ **字符串数组**

### 类型匹配问题

```php
// 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 的安全写法

```php
// 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']` 访问 ✅ 安全

```php
// 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 修复（一行改动）

```php
// 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 修复（添加模板存在性检查）

```php
// 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);
```

### 建议的完整防御代码

```php
// 填充 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
