--- 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)