docs: add category_id empty bug analysis report (P0)

council/SecurityEngineer
Council 2026-04-19 07:38:27 +08:00
parent 9603ab42f6
commit b9da3e6fb7
1 changed files with 203 additions and 0 deletions

View File

@ -0,0 +1,203 @@
# 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 处理问题
---
## 五、修复方案
### 方案 AVue 容器与表单物理隔离(推荐)
`#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>
```
**优点**:不改架构,最快上线
**缺点**:定位覆盖样式需要调试
### 方案 Biframe 隔离
```html
<iframe name="vr_ticket_iframe" style="display:none"></iframe>
<!-- VR 表单 targeting="vr_ticket_iframe" -->
```
**优点**Vue/jQuery 完全隔离,永不冲突
**缺点**:需要改造 VR 配置的 POST 目标
### 方案 CShopXO 标准钩子化(最干净)
将 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 无效:考虑方案 Biframe 隔离)
- [ ] 修复后回归:新增商品 + 编辑已有商品 + 票务商品 + 普通商品,各验证一轮