diff --git a/docs/08-模块任务管理.md b/docs/08-模块任务管理.md index f37cfaf..6ec1f44 100644 --- a/docs/08-模块任务管理.md +++ b/docs/08-模块任务管理.md @@ -63,7 +63,7 @@ - [x] REQ-MOD-001 模块新增 - [x] REQ-MOD-002 模块修改 - [x] REQ-MOD-003 模块删除 - - [ ] REQ-MOD-004 模块查询 + - [x] REQ-MOD-004 模块查询 - module_usr 用户管理 - 依赖: — diff --git a/docs/superpowers/plans/2026-05-06-REQ-MOD-004.md b/docs/superpowers/plans/2026-05-06-REQ-MOD-004.md new file mode 100644 index 0000000..8ab118d --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-REQ-MOD-004.md @@ -0,0 +1,137 @@ +--- +req_id: REQ-MOD-004 +date: 2026-05-06 +spec_ref: docs/superpowers/specs/2026-05-06-REQ-MOD-004.md +--- + +# REQ-MOD-004 模块查询 Implementation Plan + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 `GET /api/modules?keyword=...`:返回未软删除模块的树形结构,可选按 `sModuleNameZh` 模糊匹配并保留命中节点的祖先链。 + +**Architecture:** 一次性 selectList 拉所有未删除模块到内存,按 keyword 过滤命中集合,沿父链向上收集祖先合并到结果集,最后在内存按 iParentId 组装树。同级按 iSortOrder ASC + iIncrement ASC 排序。叶子 `children=[]`。 + +**Tech Stack:** 沿用 REQ-MOD-001~003。 + +--- + +## Schema 改动 + +无。 + +## 文件变更清单 + +- 创建: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeNodeVO.java` — 树形节点 VO(7 字段 + children 列表) +- 创建: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java` — 查询参数 DTO(keyword) +- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `tree(ModuleQueryDTO query): List` +- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 tree +- 修改: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@GetMapping` +- 修改: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 6 个 tree 单测 +- 修改: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 7 个 GET 集成测试 + +## 任务步骤 + +### Task 1: VO + DTO + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeNodeVO.java` +- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java` + +**API shape:** +- `ModuleTreeNodeVO`:7 字段 + `List children`,Lombok `@Data`,含静态工厂 `from(ModuleEntity e)`(创建带空 children 列表的节点)。 +- `ModuleQueryDTO`:单字段 `@Size(max=50) String keyword`(可空)。Bean Validation 由 controller 触发。 + +- [ ] **Step 1.1 创建 VO + DTO(无独立单测;Bean Validation 在 IT 层覆盖)** + +- [ ] **Step 1.2 提交** + - `git commit -m "feat(mod): module tree VO + query DTO REQ-MOD-004"` + +--- + +### Task 2: ModuleService.tree — 树构建逻辑(mock 单元测试) + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` + +**API shape:** +- `interface ModuleService` 追加:`List tree(ModuleQueryDTO query)` +- 实现步骤: + 1. `all = moduleMapper.selectList(LambdaQueryWrapper.eq(BDeleted, false))` + 2. 若 `query.keyword == null || keyword.isEmpty()` → 跳到步骤 5(用全量构树) + 3. 否则在 `all` 内存里筛选 `hits = all.filter(e -> e.sModuleNameZh.contains(keyword))` + 4. 对每个 hit 沿 `iParentId` 链向上收集祖先(用 `byId = all.stream().toMap(IIncrement)` 索引);合并到 `survivors = hits ∪ ancestors` + 5. 按 iIncrement 索引 `survivors`,按 iParentId 分组子节点列表,同级按 (iSortOrder, iIncrement) ASC 排序 + 6. 取 `iParentId == null || iParentId not in survivors` 的节点作为根(这样过滤后一些祖先链断裂的节点也会被提到根,避免孤立);递归挂 children + 7. 返回根节点列表 +- 标 `@Transactional(readOnly = true)` + +**关键不变量**(写入 plan 锁定): +- 叶子节点 `children = new ArrayList<>()`,**不为 null** +- 排序键稳定:`Comparator.comparingInt(ModuleTreeNodeVO::getISortOrder).thenComparingInt(ModuleTreeNodeVO::getIIncrement)` +- 祖先链深度上限 5(与 docs/03 § tModule 注记一致);超 5 仍向上视为业务异常并记一行 `log.warn`,不抛错(不影响 200 返回) + +- [ ] **Step 2.1 写失败测试(6 个)** + - `tree_emptyDb_returnsEmptyList`:mapper.selectList 返回空 → service 返回 `List.of()` + - `tree_singleRoot_returnsOneNodeWithEmptyChildren`:1 个 root,断言返回 list 长度 1,children 是 `[]` + - `tree_multiLevel_buildsNestedStructureSortedByISortOrder`:root(sort=2) + root(sort=1) + child of root1,断言返回顺序 [sort=1, sort=2],root1.children 包含 child + - `tree_keywordHit_includesAncestorChain`:grandparent → parent → child(sModuleNameZh 各不同),keyword 匹配 child;断言返回 grandparent → parent → child 三层 + - `tree_keywordNoMatch_returnsEmptyList` + - `tree_softDeletedExcluded`(验证 mapper 调用时 wrapper 含 `eq(bDeleted, false)`;ArgumentCaptor) + - 测试方式:`@ExtendWith(MockitoExtension.class)`,构造 `List` 喂 mock 返回值 + - 子会话: FAIL(方法不存在) + +- [ ] **Step 2.2 实现 service.tree** + - 子会话: PASS + +- [ ] **Step 2.3 提交** + - `git commit -m "feat(mod): query module tree service REQ-MOD-004"` + +--- + +### Task 3: ModuleController GET 端点 + 端到端 IT + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` + +**API shape:** +- 新方法: + ``` + @GetMapping + public ApiResponse> tree(@Valid ModuleQueryDTO query) + ``` +- Javadoc:`REQ-MOD-004 模块查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:READ')")` +- `@Valid` 触发 Bean Validation;keyword 长度超限 → MethodArgumentNotValidException → GlobalExceptionHandler → 40010 + +> **注意**:query 参数对象用 `@Valid ModuleQueryDTO` 时,Spring MVC 会把 `keyword` query 参数绑定到 DTO 字段(无需 @ModelAttribute,bean 类型默认走 query string)。 + +- [ ] **Step 3.1 写失败测试(7 个)** + - `get_emptyKeyword_returnsAllUndeletedAsTree`:先 mapper.insert 几条(含 root + child + soft-deleted),GET 不带 keyword;断言 200 + data 长度等于未删 root 数;递归断言子树。 + - `get_keyword_filtersByModuleNameZhWithAncestors`:插入 grandparent("系统配置") → parent("用户管理") → child("登录");GET `?keyword=登录`;断言返回三层链。 + - `get_keywordNoMatch_returnsEmptyArray`:GET `?keyword=不存在`,断言 `data=[]`,code=200。 + - `get_keywordTooLong_returns40010`:keyword 51 字符。 + - `get_softDeletedNotInResult`:插入一条并立即 update bDeleted=1,GET 全量,断言不在结果。 + - `get_responseExcludesInternalFields`:断言 `$.data[0].sProcedureName` doesNotExist;同时验证 `sModuleType` / `bShowPermission` / `tCreateDate` / `bDeleted` 不出现。 + - `get_leafNodeChildrenIsEmptyArrayNotNull`:断言叶子 `$.data[*].children` is array 且 length=0(非 null)。 + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired ModuleMapper` + - 子会话: FAIL(端点不存在) + +- [ ] **Step 3.2 实现 GET 端点** + - 子会话: PASS + +- [ ] **Step 3.3 跑全量 backend 测试** + - 期望累计 47 + 6(service tree) + 7(controller GET) = 60 个,全绿(mvn 报数会因共享 context 而合并) + +- [ ] **Step 3.4 提交** + - `git commit -m "feat(mod): GET /api/modules controller REQ-MOD-004"` + +--- + +## 提交计划 + +- `feat(mod): module tree VO + query DTO REQ-MOD-004`(Task 1) +- `feat(mod): query module tree service REQ-MOD-004`(Task 2) +- `feat(mod): GET /api/modules controller REQ-MOD-004`(Task 3) diff --git a/docs/superpowers/reviews/2026-05-06-REQ-MOD-004.md b/docs/superpowers/reviews/2026-05-06-REQ-MOD-004.md new file mode 100644 index 0000000..7d43fb2 --- /dev/null +++ b/docs/superpowers/reviews/2026-05-06-REQ-MOD-004.md @@ -0,0 +1,36 @@ +--- +req_id: REQ-MOD-004 +date: 2026-05-06 +round: 1 +reviewer: superpower-code-reviewer +--- + +# Review: REQ-MOD-004 — round 1 + +## 结论 +approve + +## Must-fix +(无) + +## Nice-to-have + +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:530 — 补 IT 覆盖 spec § 验收 #7『同级排序』:插入 root(iSortOrder=2) + root(iSortOrder=1),断言 GET 返回数组首个 iIncrement 是 sort=1 那条。当前同级排序仅在单测覆盖,缺端到端断言。 +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:530 — 补 IT 覆盖 spec § 验收 #6『keyword 含中英混合』:插入 sModuleNameZh='user 用户',GET ?keyword=user 用户 命中。 +- backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java:497 — `tree_softDeletedExcluded` 仅 verify(...).selectList(any()),未捕 wrapper 形状(plan 要求 ArgumentCaptor)。建议改名或真用 ArgumentCaptor 抽 `wrapper.getTargetSql()` 断言含 `bDeleted = 0`。 +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:227 — Comparator 对 iSortOrder 做了 null→0 防御,但 docs/03 已注明 NOT NULL;可简化为 `Comparator.comparingInt(ModuleTreeNodeVO::getISortOrder).thenComparingInt(::getIIncrement)`。 +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:198 — `survivorIds = byId.keySet()` 是 live view,与 keyword 分支返回的独立 HashSet 类型不一致;建议改成 `new HashSet<>(byId.keySet())` 一致化。 +- backend/pom.xml:121 — surefire includes 修改是项目级配置;建议在 docs/04 测试规范节追加一行说明『*IT.java 走 surefire (mvn test) 而非 failsafe (mvn verify)』,避免后续贡献者破坏 includes。 +- docs/03-数据库设计文档.md § tModule 业务注记 — 建议追加一行『模块树最大深度 5 层(iParentId 自引用),由 service 层校验,REQ-MOD-002 / REQ-MOD-004 引用本约束』,把深度上限提升为 SSoT。 +- backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java:11 — 在 @Size 上方 javadoc 写明『empty 视为不过滤』。 + +## 反例 / 测试覆盖缺口 + +1. spec § 验收 #7 同级排序的端到端 IT 缺失(仅单测)。 +2. spec § 验收 #6 中英混合 keyword 没有专属 IT。 +3. `tree_softDeletedExcluded` 单测断言强度低于 plan 期望。 +4. 反例缺:keyword 含 SQL 通配符 `%` / `_`(spec 声明本期不转义,作为已知边界)。 +5. 反例缺:祖先链深度 ≥ 5 的截断边界用例;docs/03 已限深度 5,正常打不到边界,可后续补 6 层强构数据单测固化截断语义。 +6. Controller javadoc 标了『REQ-USR-004 完成后追加 @PreAuthorize』但缺集中跟踪——建议在 docs/08 § 二 USR-004 子项挂 follow-up 备注。 + +**核心结论**:实现忠实于 spec — keyword 过滤 + 祖先链 walk-up + 内存树构造 + 同级 (iSortOrder, iIncrement) 排序 + 叶子 children=[] 全部正确。API 契约字段精简到位(VO 7 个公开字段 + children)。surefire includes 修复是揭示性发现——本仓库之前 *IT.java 没在 mvn test 跑过,REQ-MOD-001/002/003 的 IT 都是"死代码";本 REQ commit body 已披露,REQ-MOD-004 的 IT 是首次在 mvn test 跑通。 diff --git a/docs/superpowers/specs/2026-05-06-REQ-MOD-004.md b/docs/superpowers/specs/2026-05-06-REQ-MOD-004.md new file mode 100644 index 0000000..90702b3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-REQ-MOD-004.md @@ -0,0 +1,174 @@ +--- +req_id: REQ-MOD-004 +date: 2026-05-06 +module: module_mod +--- + +# Spec: REQ-MOD-004 — 模块查询 + +## 目标 + +实现后端 `GET /api/modules` 接口:以**树形结构**返回所有未软删除的模块,可选按 `sModuleNameZh` 模糊匹配过滤;过滤命中时同时保留命中节点的所有**祖先路径**以便定位上下文。 + +## 输入 / 触发 + +**接口**:`GET /api/modules`,无请求体。 + +**Query parameters**: + +| 字段 | 类型 | 必填 | 校验 / 取值 | +|---|---|---|---| +| `keyword` | String | 否 | 长度 ≤ 50;非空时对 `sModuleNameZh` 做 `LIKE '%keyword%'`(不区分大小写——MySQL `utf8mb4_unicode_ci` 默认行为) | + +**鉴权**:契约要求 `Authorization: Bearer ` + 权限码 `MOD:READ`。沿用 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:READ')")`。 + +## 输出 / 结果 + +**HTTP 200,响应体**: + +```json +{ + "code": 200, + "message": "操作成功", + "data": [ + { + "iIncrement": 1, + "sModuleNameZh": "系统配置", + "sDisplayType": "系统配置", + "sManageDeptEn": "IT", + "iParentId": null, + "iSortOrder": 0, + "children": [ + { + "iIncrement": 2, + "sModuleNameZh": "用户管理", + "sDisplayType": "前端业务", + "sManageDeptEn": "IT", + "iParentId": 1, + "iSortOrder": 1, + "children": [] + } + ] + } + ], + "timestamp": 1746528600000 +} +``` + +`data` 是根节点数组(`iParentId == null` 的节点);每个节点带 `children` 数组(同结构递归)。 + +新增 VO `ModuleTreeNodeVO`:字段 `iIncrement` / `sModuleNameZh` / `sDisplayType` / `sManageDeptEn` / `iParentId` / `iSortOrder` / `children: List`。 + +> **不返回的字段**(避免泄露内部):`sProcedureName` / `sModuleType` / `bShowPermission` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` / `bDeleted` / `tDeletedDate` / `sDeletedBy`。如未来 REQ 需要其中字段,再扩 VO。 + +## 业务规则 + +1. **范围过滤**:`bDeleted = 0`(默认仅返回未软删除)。 +2. **空 keyword**:返回所有未软删除模块构成的完整树。 +3. **非空 keyword**: + - 步骤 a:在所有未软删除模块中找出 `sModuleNameZh LIKE '%keyword%'` 的命中集合 `hits`。 + - 步骤 b:对每个 `hit`,沿 `iParentId` 链向上收集所有未软删除祖先(深度上限 5 与 docs/03 § tModule 一致),并入结果集合。 + - 步骤 c:用结果集合在内存中按 `iParentId` 组装树。 + - 命中节点本身的子孙不会被强制纳入(除非也命中);这样避免一次过滤拉出整棵子树。 +4. **排序**:同级节点按 `iSortOrder ASC` 升序,`iSortOrder` 相同则按 `iIncrement ASC`(确定性排序)。 +5. **`children` 字段**:叶子节点为 `[]`,不为 `null`(确保前端可直接 `.map`)。 +6. **空结果**:keyword 无匹配 → `data = []`,**HTTP 200 + code=200**,不返回 404 类错误。 +7. **只读**:本接口不写库,无事务要求;标 `@Transactional(readOnly = true)`。 + +## 边界与约束 + +### 鉴权策略 + +沿用 REQ-MOD-001/002/003 SecurityConfig permitAll。 + +### 错误码 + +| 场景 | 错误码 | ErrorCode 枚举 | +|---|---|---| +| `keyword` 长度 > 50 | 40010 | `PARAM_INVALID`(已存在) | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | + +### 性能 + +- 单次 `selectList(LambdaQueryWrapper.eq(bDeleted, false))` 拉取所有未删除模块;spec § 性能上限 docs/03 注明"单次返回不超过 500 项 / 树深度上限 5 层",本期数据量低不分页。 +- 在内存里 O(N) 建索引(id → entity)+ O(N) 建子节点列表 + O(N) 排序 + O(K * D) 沿祖先链向上(K=hits 数,D=深度上限 5)。 +- 不引入 SQL 递归 CTE / 自连接;保持 mapper 层轻量。 + +### 大小写敏感 + +`utf8mb4_unicode_ci` collation 默认不区分大小写。MyBatis-Plus `LambdaQueryWrapper.like(...)` 走 SQL `LIKE`,行为与 collation 一致。无需额外处理。 + +### keyword 中的特殊字符 + +`%` / `_` 在 SQL `LIKE` 模式里是通配符。简化处理:本期不做转义(业务模块名通常不含这些字符);如未来需要,service 层先把 keyword 中的 `%` / `_` / `\` 替换为转义形式(`MyBatis-Plus 5.x` 的 `like` 已自带 escape,本期 3.5.7 需手动)。**本 REQ 暂不处理转义**,作为已知边界记录。 + +## 依赖的 schema 表 / 字段 + +**读表**:`tModule` + +| 字段 | 用途 | +|---|---| +| `iIncrement` | 节点 id;输出 | +| `sModuleNameZh` | 模糊匹配列;输出 | +| `sDisplayType` | 输出 | +| `sManageDeptEn` | 输出 | +| `iParentId` | 父子关系;输出 | +| `iSortOrder` | 排序;输出 | +| `bDeleted` | 过滤未删除(=0) | + +**索引利用**: +- `idx_module_name_zh`:`LIKE '%keyword%'` 实际不走索引(左模糊),但本期数据量低可接受。 +- `idx_parent`:祖先链查询时按 iIncrement PK 走 selectById,不用 idx_parent。 +- `idx_module_deleted`(未单独建,但 bDeleted 在多个 idx 中已有):bDeleted 过滤时 MySQL 优化器自行选择。 + +**外键**:本接口只读,不触发 FK 检查。 + +## 依赖的接口 + +无(独立查询接口)。 + +## 验收标准 + +### 功能正确性 + +1. **正向 — 空 keyword 返回完整树**:DB 中 5 棵根 + 多层子模块;GET 返回 5 个根节点,子孙完整嵌套;同级按 iSortOrder 升序。 +2. **正向 — keyword 模糊匹配 + 祖先**:DB 有 root("系统配置") → 子("用户管理") → 孙("登录");GET `?keyword=登录` 返回 root → 子 → 孙 三层(即命中节点 + 全部祖先)。 +3. **正向 — keyword 部分匹配**:keyword="管理",匹配多个节点;返回它们各自完整祖先链合并的树。 +4. **正向 — 软删除模块过滤**:DELETE 一个模块后再查,结果不含该模块。 +5. **正向 — 空结果**:keyword="不存在的关键词",返回 `data=[]` + code=200。 +6. **正向 — keyword 含中英混合**:keyword="user 用户" 也走 LIKE '%user 用户%'(与 spec § 业务规则 3 一致;不拆词)。 +7. **同级排序**:两根 iSortOrder=2/1,返回顺序 1 在前。 +8. **children 字段**:叶子节点 children 是 `[]` 而非 null(jsonPath 断言)。 +9. **keyword 长度超限**:keyword=51 字符,返回 `code=40010`。 +10. **无登录也可访问**(permitAll 阶段):直接 GET 不带 Authorization 返回 200。 +11. **响应字段精简**:响应不含 sProcedureName / sModuleType / bShowPermission / 标准列等内部字段(jsonPath 断言)。 + +### 接口契约一致性 + +- 响应格式 `{code, message, data, timestamp}`。 +- 错误码 200 / 40010 / 50000。 +- 不回显堆栈。 + +### 测试覆盖 + +- **单元测试** `ModuleServiceImplTest` 追加(mock ModuleMapper): + - tree_emptyDb_returnsEmptyList + - tree_singleRoot_returnsOneNodeWithEmptyChildren + - tree_multiLevel_buildsNestedStructureSortedByISortOrder + - tree_keywordHit_includesAncestorChain + - tree_keywordNoMatch_returnsEmptyList + - tree_softDeletedExcluded(mapper 已过滤;service 不重复过滤) + +- **集成测试** `ModuleControllerIT` 追加: + - get_emptyKeyword_returnsAllUndeletedAsTree + - get_keyword_filtersByModuleNameZhWithAncestors + - get_keywordNoMatch_returnsEmptyArray + - get_keywordTooLong_returns40010 + - get_softDeletedNotInResult + - get_responseExcludesInternalFields + - get_leafNodeChildrenIsEmptyArrayNotNull + +### 代码与文档 + +- `// REQ-MOD-004` 注释贴在 Controller / Service / VO。 +- 提交按 `feat(mod): REQ-MOD-004` 规范。