refactor: admin 列表筛选纪律全面治理 (PR #1908)
- 后端 changelog: 8 处筛选范式治理, API 行为变化说明 - 前端 BUG 通知: DevTools 实测纠正 07 文件根因, 真实根因为 3 个并发请求互相 cancel, 需 mmg 收敛 fetch 入口 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
这个提交包含在:
父节点
4b8735e3cf
当前提交
c935ef7905
@ -0,0 +1,89 @@
|
||||
# 前端 BUG 通知 — 主题列表筛选触发并发 3 请求互相 cancel (DevTools 实测纠正 07 文件根因)
|
||||
|
||||
**日期**: 2026-05-09
|
||||
**类型**: 前端 BUG (admin 后台 hl-ui)
|
||||
**模块**: 主题管理 (产品线 / Product Line)
|
||||
**通知**: @mmg
|
||||
**后端是否需改动**: 否(本日已 PR #1908 做防御性加固)
|
||||
**关联**: `07_frontend_notice_topic-filter-empty.md` (07 文件的根因推断 A/B 已被本次 DevTools 证据**否定**)
|
||||
|
||||
---
|
||||
|
||||
## 现象
|
||||
|
||||
用户 2026-05-09 复现, DevTools Network 截图证实:
|
||||
|
||||
打开「主题列表」页 → 选「主题类型 = 小蒙马」点搜索 → 列表显示「暂无主题数据」, 但顶部仍显示「共 15 条」
|
||||
|
||||
**Network 关键发现**:
|
||||
- 同一时刻并发发出 **3 个请求**, productType 分别是 `CORE` / `CUSTOM` / `GROUP`
|
||||
- 所有 3 个请求 status 全部 `(canceled)` (size 0.0kB / time 8-27ms)
|
||||
- 因为请求都被 cancel, 列表 store 收不到任何 data → 列表空
|
||||
- 「共 15 条」是上一次请求成功的缓存值, 没刷
|
||||
|
||||
```
|
||||
list?keyword=&productType=CORE&page=1&pageSize... (canceled) 27 ms
|
||||
list?keyword=&productType=CUSTOM&page=1&pageSize... (canceled) 10 ms
|
||||
list?keyword=&productType=GROUP&page=1&pageSize... (canceled) 8 ms
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 根因 (推翻 07 文件的 A/B 推断)
|
||||
|
||||
07 文件原推断:
|
||||
- (A) 字典 store localStorage 老数据 → ❌ 不成立, 请求里 productType 字段是正确的字典 value
|
||||
- (B) 前端把 label 当 value 传给后端 → ❌ 不成立, 请求里清楚是 `GROUP` 而非 `小蒙马`
|
||||
|
||||
**真实根因 (推断)**:
|
||||
|
||||
页面进入时 URL 带 `?productType=GROUP`, 触发了**多个 watch/computed 同时联动**, 各自独立调 `getProductLinePage()` 拼接不同的 productType:
|
||||
- 一个 watch 监听 URL query → 发 `productType=GROUP`
|
||||
- 另一个 watch 监听筛选下拉 model → 看到 dict store 拉好后默认 option 又触发一次, 可能是 CORE
|
||||
- 又一个 watch 监听某个状态切换 → 发 `productType=CUSTOM`
|
||||
|
||||
axios 默认会把同一接口的并发请求按 `cancelToken` 互相 cancel (项目 axios 拦截器很可能有 dedupe / cancel-on-new 逻辑), 结果**3 个全被 cancel** (互相 cancel 死锁).
|
||||
|
||||
---
|
||||
|
||||
## 复现取证
|
||||
|
||||
请 mmg 在 DevTools 里:
|
||||
1. Network 抓页面进入时刻所有 `GET /admin/product/line/list` 请求, 看是否真的有 3 个并发
|
||||
2. Sources 里搜 `getProductLinePage` 找所有调用点, 看是否多个 watch/onMounted/init 各自触发了一次
|
||||
3. axios 拦截器 (`hl-ui/src/utils/request.js` 或 `hl-ui/src/api/index.js`) 是否有 cancel-on-duplicate 逻辑
|
||||
|
||||
---
|
||||
|
||||
## 建议修复方向
|
||||
|
||||
**优先级 1**: 把 `getProductLinePage` 调用收敛到**单一入口**:
|
||||
- 页面 onMounted 触发一次 fetch
|
||||
- 用户点搜索 / reset / 改筛选 → 只有点搜索按钮才 fetch (不要 watch 字段直接 fetch)
|
||||
- URL query 同步只更新 store / 表单 model, 不直接 fetch
|
||||
|
||||
**优先级 2**: 检查 axios 拦截器 cancel 逻辑:
|
||||
- 如果有 dedupe by url, `productType=GROUP` 与 `productType=CORE` URL 不同应当不算 dup, 但仍被 cancel → 拦截器逻辑 bug
|
||||
- 临时缓解: 关掉这个 dedupe / cancel-on-new
|
||||
|
||||
**优先级 3**: 后端兼容性 (本次 PR #1908 已加固)
|
||||
- 即使前端真传了 `productType=GROUP,CORE,CUSTOM` 拼接形式, 后端也能 setter 兼容
|
||||
- 即使前端某次传了字典里没有的 status, 后端不再 500
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件 (前端排查参考)
|
||||
|
||||
- 页面: `hl-ui/src/views/product/line/index.vue`
|
||||
- API: `hl-ui/src/api/product/core.js:49-51` `getProductLinePage`
|
||||
- axios 配置: `hl-ui/src/utils/request.js` 或 `hl-ui/src/api/index.js`
|
||||
|
||||
---
|
||||
|
||||
## 后端关联
|
||||
|
||||
后端今日 PR #1908 已落地防御性加固 (详见 `09_refactor_admin_list_filter_governance.md`):
|
||||
- `ProductLinePageReqVO.productType` 改 `List<String>` + 自定义 setter, 兼容 4 种入参
|
||||
- 即使前端按重复 key / 逗号分隔 / 单值任一方式发送, 后端能正确接住
|
||||
|
||||
后端**不能**修复"3 个请求互相 cancel"问题——这是前端调用频次问题, 必须前端修.
|
||||
@ -0,0 +1,108 @@
|
||||
# admin 列表筛选纪律全面治理 (8 处)
|
||||
|
||||
> **服务**: hl-product-service-v2 (8093) + hl-order-service-v2 (8094)
|
||||
> **PR**: #1908 (Closes #1907)
|
||||
> **日期**: 2026-05-09
|
||||
> **影响范围**: 管理端列表分页接口
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 关键变化
|
||||
|
||||
admin 端 8 个列表分页接口的「下拉筛选」行为统一治理。**API 路径/请求参数/响应字段不变**,仅治理两类范式不健壮:
|
||||
|
||||
| 类别 | Before | After |
|
||||
|------|--------|-------|
|
||||
| A. ProductLine 筛选字段类型 | `productType` 单值 String 派生 `ProductTypeList` 不通 → 筛选不生效 | `List<String>` + 自定义 setter, 兼容 4 种入参 |
|
||||
| B. status/type/productType/platform 筛选 | Service 调 `XxxEnum.of(req.getStatus())`, 字典+1 enum 不+1 时抛 IAE → 500 | String 直接透传 + `eqIfPresent + StrUtil.isNotBlank`, 字典已有合法值 → 正常筛选; 非法/未知值 → 视同未筛选返回全量 |
|
||||
|
||||
---
|
||||
|
||||
## 一、影响接口清单
|
||||
|
||||
### A. ProductLine 筛选字段不通 (1 处)
|
||||
|
||||
| 接口 | 方法 | 路径 | 变更 |
|
||||
|------|------|------|------|
|
||||
| 主题列表 | GET | `/admin/product/line/list` | `productType` 后端接收**兼容性扩大**到 4 种入参形式 |
|
||||
|
||||
**4 种兼容入参** (前端任一方式都能正确接住):
|
||||
- `?productType=GROUP`(单值)
|
||||
- `?productType=CORE,GROUP`(逗号分隔)
|
||||
- `?productType=CORE&productType=GROUP`(重复 key)
|
||||
- POST body `{"productType":["CORE","GROUP"]}`(JSON 数组)
|
||||
|
||||
### B. enum.of() IAE 隐患 → String 透传 (7 处)
|
||||
|
||||
| # | 接口 | 方法 | 路径 | 治理字段 |
|
||||
|---|------|------|------|----------|
|
||||
| 1 | admin 订单列表 | GET | `/admin/order/list` | status / processStatus / productType |
|
||||
| 2 | designer 订单列表 | GET | `/admin/order/designer-orders` | status |
|
||||
| 3 | 工单列表 | GET | `/admin/workorder/page` | status / type |
|
||||
| 4 | 保险单列表 | GET | `/admin/insurance-order/page` | status |
|
||||
| 5 | 定制需求列表 | GET | `/admin/customize-request/page` | status |
|
||||
| 6 | 合同列表 | GET | `/admin/contract/page` | status / platform |
|
||||
| 7 | 发票列表 | GET | `/admin/invoice/page` | status |
|
||||
|
||||
---
|
||||
|
||||
## 二、行为变化(前端关注)
|
||||
|
||||
| 场景 | Before | After |
|
||||
|------|--------|-------|
|
||||
| 字典已有的合法值, 如 `status=PAID` | 正常筛选 | 正常筛选(行为不变) |
|
||||
| 字典新增了值 enum 还没同步, 如未来加 `status=AFTER_SALES` | **HTTP 500 IllegalArgumentException** | 正常筛选(直接 SQL eq) |
|
||||
| 传字典里完全没有的值, 如 `status=XXX_NOT_EXIST` | HTTP 500 | HTTP 200 但返回空列表 |
|
||||
| 不传 / 空字符串 | 不筛选 | 不筛选(行为不变) |
|
||||
|
||||
**前端不需要任何改动**, 但建议:
|
||||
- 已有字典 fallback 逻辑可保留也可移除(后端不再 500)
|
||||
- 主题列表前端的「重复请求互相 cancel」BUG 详见同日 `09_frontend_notice_admin_topic-filter-dup-request.md`
|
||||
|
||||
---
|
||||
|
||||
## 三、内部范式
|
||||
|
||||
参照 `RefundApplicationMapper.selectPage` 既有正确范式:
|
||||
|
||||
```java
|
||||
// Before (Mapper)
|
||||
default IPage<XxxDO> selectPage(... String status, ...) {
|
||||
return selectPage(page, new LambdaQueryWrapperX<XxxDO>()
|
||||
.eq(XxxStatus.of(status)) // 字典+1 enum 不+1 → IAE
|
||||
...);
|
||||
}
|
||||
|
||||
// After (Mapper)
|
||||
default IPage<XxxDO> selectPage(... String status, ...) {
|
||||
return selectPage(page, new LambdaQueryWrapperX<XxxDO>()
|
||||
.eqIfPresent(XxxDO::getStatus, StrUtil.isNotBlank(status) ? status : null)
|
||||
...);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、不变化
|
||||
|
||||
- DB schema / 字典表 / Flyway: **不动**
|
||||
- enum 类型本身: **不删**, 仍可作字面量使用 (如 `if (PAID.name().equals(status))`)
|
||||
- 前端 VO 字段名/类型契约: **不变**
|
||||
- 排序/分页逻辑: **不变**
|
||||
- 数据权限/角色控制: **不变**
|
||||
|
||||
---
|
||||
|
||||
## 五、测试
|
||||
|
||||
- hl-product-service-v2: `ProductLineMapperTest 13 / ProductLinePageReqVOTest 9 / ProductPricingServiceTest 64 / ProductPricingServiceMpQuoteTest 5 / MpProductServiceTest 69 = 160 全绿`
|
||||
- hl-order-service-v2: `ContractQueryServiceTest 27 / OrderInvoiceServiceTest 31 / OrderListQueryServiceTest 19 / OrderInfoMapperTest 13 / GroupOrderStrategyTest 10 = 100 全绿`
|
||||
- 新增治理范式 case (字典+enum 不一致 / null 透传 / 4 种入参兼容性) 共 13 个
|
||||
|
||||
---
|
||||
|
||||
## 六、关联
|
||||
|
||||
- 前端 BUG 通知: `09_frontend_notice_admin_topic-filter-dup-request.md` (同日新建)
|
||||
- 历史前端 BUG 通知: `07_frontend_notice_topic-filter-empty.md` (本日 DevTools 新证据已纠正其根因推断)
|
||||
- 单房差功能: 完全解耦, PR #1900 / #1901 / #1904 不受影响 (治理过程中误删已回滚)
|
||||
正在加载...
x
在新工单中引用
屏蔽一个用户