docs: add category_id empty bug analysis report (P0)
parent
9603ab42f6
commit
b9da3e6fb7
|
|
@ -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 处理问题
|
||||
|
||||
---
|
||||
|
||||
## 五、修复方案
|
||||
|
||||
### 方案 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 隔离)
|
||||
- [ ] 修复后回归:新增商品 + 编辑已有商品 + 票务商品 + 普通商品,各验证一轮
|
||||
Loading…
Reference in New Issue