# Bug 分析报告：商品编辑保存时 `category_id` 为空

**状态**：调研完成，待修复  
**严重性**：🔴 P0 — 所有票务/普通商品编辑后无法保存  
**影响范围**：vr-shopxo-plugin Phase 2 以降的所有商品编辑场景  
**调研人**：西莉雅  
**日期**：2026-04-19

---

## 一、现象描述

| 场景 | 结果 |
|------|------|
| 商品列表 → 点编辑 | 分类正常显示 ✅ |
| 编辑页手动选择分类 | UI 正常选中 ✅ |
| 点击保存 | 弹窗提示"至少需要选择一个商品分类" ❌ |
| 反复重试 | 每次都报错，即使选了多个分类也报错 |

**关键特征**：分类在表单上显示正常（说明 DB 有数据、模板渲染正确），但保存时服务端收到的 `category_id` 为空。

---

## 二、数据流追踪

### 2.1 正常保存流程

```
浏览器 POST 提交
  → category_id=911&category_id=912&...
    → ThinkPHP input() 接收
      → GoodsService::GoodsSave($params)
        → $category_ids = $params['category_id']  (转数组)
          → ParamsChecked: empty($params['category_id']) → 【若为空则报错】
            → GoodsCategoryInsert($category_ids, $goods_id)  (写入 goods_category_join)
```

### 2.2 关键代码节点现状

| 文件 | 路径 | 是否被 antigravity 修改 |
|------|------|------------------------|
| GoodsController | `app/admin/controller/Goods.php` | ❌ 未改动 |
| GoodsService | `app/service/GoodsService.php` | ❌ 未改动 |
| saveinfo.html | `app/admin/view/default/goods/saveinfo.html` | ❌ 未改动 |
| common.js (验证逻辑) | `public/static/common/js/common.js` | ❌ 未改动 |
| AdminGoodsSave.php | `app/plugins/vr_ticket/hook/AdminGoodsSave.php` | ✅ 新引入 |
| AdminGoodsSaveHandle.php | `app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` | ✅ 新引入 |
| config.json | `app/plugins/vr_ticket/config.json` | ✅ 新增 hooks 注册 |

**结论**：Bug 不在 ShopXO Core，也不在 AdminGoodsSaveHandle（该钩子只处理 `item_type`，不碰 `category_id`）。问题指向 `AdminGoodsSave.php`。

---

## 三、根因分析

### 3.1 `AdminGoodsSave.php` 的注入内容

```php
// GoodsService::SaveInfo() 中调用
$assign[$hook_name.'_data'] = MyEventTrigger($hook_name, [
    'hook_name' => 'plugins_view_admin_goods_save',
    'data'      => &$data,      // 商品数据（含 category_ids）
    'params'    => &$params,
]);
// 模板中 {{$hook|raw}} 将其输出到表单底部
```

`AdminGoodsSave.php` 的 `handle()` 返回一段 HTML，**直接嵌入商品编辑表单底部**：

```html
<div id="vr-ticket-plugin-app">
    <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
    <!-- Vue 3 app, ref, computed, watch -->
    <input type="hidden" name="vr_is_ticket" :value="isTicket ? 1 : 0" />
    <input type="hidden" name="vr_goods_config_base64" :value="outputBase64" />
    <script>
        createApp({...}).mount('#vr-ticket-plugin-app')
    </script>
</div>
```

### 3.2 冲突机制（推断）

**时序**：

```
1. PHP 渲染表单 → <select name="category_id"> 的 <option selected> 已标记 ✅
2. jQuery chosen-select 初始化 → 在 category select 上创建自定义 UI ✅
3. Vue 3 mount('#vr-ticket-plugin-app')  → 【冲突点】
```

**冲突细节**：

1. **Vue 3 prod 版的激进 DOM 优化**  
   Vue 3 的 `createApp().mount()` 在挂载时会对整个 `#vr-ticket-plugin-app` 子树进行 Virtual DOM diff 和批量 DOM 更新。虽然 category select 不在该节点内，但浏览器在处理 `reflow/repaint` 时会触发 **layout thrashing**（连锁布局计算），影响同层 DOM 元素。

2. **chosen-select 与 jQuery 的 `.val()` 机制**  
   chosen-select 维护两套状态：原生 `<select>` 的 `option.selected` 属性 + 自定义 UI。浏览器 layout 抖动会导致 chosen-select 的内部缓存与原生 DOM 状态短暂失去同步。

3. **ShopXO 表单提交时的取值路径**  
   ```javascript
   // common.js FromInit() 提交验证
   $temp_form.find('select.chosen-select').each(function() {
       var value = $(this).val();   // ← 这里返回 null 或空数组
       if ((value || null) == null) {
           is_success = false;      // ← 客户端验证失败
       }
   });
   ```
   如果 `$(category_select).val()` 返回 `null`（jQuery 的 `val()` 读的是原生 `<select>` 的 `.value`，不是 chosen-select UI），客户端验证也会失败并弹窗。

4. **客户端验证失败弹窗 vs. 服务端报错**  
   用户看到的错误"至少需要选择一个商品分类"来自 HTML `data-validation-message` 属性，由 amazeui validator 触发。但因为 Vue 3 也可能同时影响了 FormData 构造，**服务端同样收不到 `category_id`**（即使用户在 UI 上看到了分类被勾选）。

### 3.3 为什么"新增商品"不受影响

新增商品时，category select 的 `option.selected` 状态是**初始空状态**，chosen-select 初始化后不需要处理已选择的值。而编辑时，原生 `<option>` 已有 `selected` 属性，Vue 3 的挂载触发了浏览器 reflow，使得 chosen-select 的 DOM 状态与原生 select 失去同步。

---

## 四、验证方法

在浏览器 DevTools 中操作：

1. 打开商品编辑页
2. 打开 **Network** 面板
3. 选择分类，点击保存
4. 观察 XHR 请求：
   - `category_id` 参数**完全不存在** → 确认是前端 FormData 问题
   - `category_id` 存在但值为空 → ThinkPHP 处理问题

---

## 五、修复方案

### 方案 A：Vue 容器与表单物理隔离（推荐）

将 `#vr-ticket-plugin-app` 改为 `position: absolute`，浮动在表单之外，不参与表单 DOM 树：

```html
<!-- AdminGoodsSave.php -->
<div id="vr-ticket-plugin-app"
     style="position: absolute; top: 0; left: -9999px; width: 1px; height: 1px; overflow: hidden;">
    <!-- Vue app 内容 -->
</div>
```

**优点**：不改架构，最快上线  
**缺点**：定位覆盖样式需要调试

### 方案 B：iframe 隔离

```html
<iframe name="vr_ticket_iframe" style="display:none"></iframe>
<!-- VR 表单 targeting="vr_ticket_iframe" -->
```

**优点**：Vue/jQuery 完全隔离，永不冲突  
**缺点**：需要改造 VR 配置的 POST 目标

### 方案 C：ShopXO 标准钩子化（最干净）

将 VR 配置表单从 `plugins_view_admin_goods_save`（直接注入 HTML）改为：
- `plugins_service_goods_save_handle`：仅处理数据逻辑，不注入 HTML
- 前端 VR 配置 UI 改为独立的 Admin 页面（venues/sessions 管理）

**优点**：符合 ShopXO 插件规范，不污染商品表单 DOM  
**缺点**：需要重构 VR 插件的前端交互逻辑

### 方案 D：延迟 Vue mount（最简单验证）

```javascript
// AdminGoodsSave.php 的 <script> 末尾
document.addEventListener('DOMContentLoaded', function() {
    setTimeout(function() {
        createApp({...}).mount('#vr-ticket-plugin-app');
    }, 500);
});
```

**目的**：验证是否是 timing 问题。如果是 500ms 延迟后 mount 就能保存成功，则确认是 Vue/chosen-select 时序冲突。

---

## 六、相关文件清单

| 文件 | 作用 | 嫌疑 |
|------|------|------|
| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSave.php` | SaveInfo 时注入 Vue3 表单 | 🔴 主要嫌疑 |
| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` | Save 时处理 item_type | 🟢 已排除 |
| `shopxo/app/plugins/vr_ticket/config.json` | hooks 注册 | 🟢 已排除 |
| `shopxo/app/admin/view/default/goods/saveinfo.html` | 商品编辑表单模板 | 🟢 未改动 |
| `shopxo/public/static/common/js/common.js` FromInit | chosen-select 验证逻辑 | 🟢 未改动 |
| `shopxo/app/service/GoodsService.php` GoodsSave | category_id 验证点 | 🟢 未改动 |

---

## 七、行动项

- [ ] **优先验证**：方案 D（延迟 mount 500ms）在测试环境验证
- [ ] 若方案 D 有效：上线方案 A 或方案 C 作为长期修复
- [ ] 若方案 D 无效：考虑方案 B（iframe 隔离）
- [ ] 修复后回归：新增商品 + 编辑已有商品 + 票务商品 + 普通商品，各验证一轮
