---
req_id: REQ-MOD-004
date: 2026-04-29
spec_ref: docs/superpowers/specs/2026-04-29-REQ-MOD-004.md
---

# REQ-MOD-004 模块查询 Implementation Plan

> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task.

**Goal:** 在 MOD-001~003 已建工程基础上增量实现 `GET /api/mod/modules` 模块树查询：DB 模糊匹配 + 内存拼装森林。

**Architecture:** 复用 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper`；新增 `ModuleTreeVO`、`mapper.selectActiveByKeyword(String)`、`service.listTree(String)`、controller `@GetMapping`。无新外部依赖。空 keyword 由 controller 归一化为 `""`，超长校验在 service。SecurityConfig 已对 `/api/mod/**` permitAll 覆盖该接口。

**Tech Stack:** 沿用（Spring Boot 3.3.5 / MyBatis-Plus / JUnit 5 + Mockito + TestRestTemplate）。

---

## Schema 改动

无（仅 SELECT）。

## 文件变更清单

### 新增

- `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeVO.java` — 树节点出参 VO

### 修改

- `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` — 追加 `List<Module> selectActiveByKeyword(@Param("keyword") String keyword)` 注解 SELECT
- `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `List<ModuleTreeVO> listTree(String keyword)`
- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 listTree（trim + 长度校验 + 拼树）
- `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@GetMapping("/modules")`
- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 7 用例
- `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` — 追加 1 用例
- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 6 用例

## 任务步骤

> 全局：每 commit `<type>(mod): <subject> REQ-MOD-004`；测试派发子会话；现有 53 用例全程绿。

### Task 1: Mapper#selectActiveByKeyword + IT

**Files:**
- Modify: `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java`
- Modify: `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java`

**API shape:**
- `@Select("SELECT iIncrement, sModuleNameZh, sDisplayType, sManageDeptEn, iParentId, iSortOrder FROM tModule WHERE bDeleted = 0 AND sModuleNameZh LIKE CONCAT('%', #{keyword}, '%') ORDER BY iSortOrder ASC, iIncrement ASC")`
- `List<Module> selectActiveByKeyword(@Param("keyword") String keyword)`
- 返回的 Module 实例只填查询的 6 列；其他字段为 null（MyBatis 默认行为）

- [ ] **Step 1: 写失败测试 `ModuleMapperIT#selectActiveByKeyword_filtersAndOrders`**
  - 准备 5 行：A "系统-A" iSortOrder=1; B "系统-B" iSortOrder=0; C "用户" iSortOrder=2; D "系统-D" bDeleted=1; E "测试" iSortOrder=3
  - 断言：`selectActiveByKeyword("")` → 4 行，顺序 [B(0), A(1), C(2), E(3)]（D 被 bDeleted 过滤）
  - 断言：`selectActiveByKeyword("系统")` → [B, A]（D 被 bDeleted 过滤）
  - 断言：`selectActiveByKeyword("不存在XYZ")` → 空 list

- [ ] **Step 2: 实现 mapper 方法**

- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest=ModuleMapperIT`

- [ ] **Step 4: Commit**
  - `git commit -m "feat(mod): mapper#selectActiveByKeyword REQ-MOD-004"`

### Task 2: ModuleTreeVO + Service.listTree + 单测

**Files:**
- Create: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeVO.java`
- 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:**
- `ModuleTreeVO` POJO：字段 `iIncrement` / `sModuleNameZh` / `sDisplayType` / `sManageDeptEn` / `iParentId` / `iSortOrder` / `List<ModuleTreeVO> children`（默认 new ArrayList<>()）；getter/setter；含 `@JsonProperty` 锁定 JSON 名（与 DTO 风格一致）。
- `ModuleService#listTree(String keyword) : List<ModuleTreeVO>`
- `ModuleServiceImpl#listTree(String keyword)`：
  1. `String normalized = keyword == null ? "" : keyword.trim()`
  2. `if (normalized.length() > 100) throw new BizException(40001, "keyword 长度超过 100 字符")`
  3. `List<Module> rows = moduleMapper.selectActiveByKeyword(normalized)`
  4. 拼树：建 `Map<Integer, ModuleTreeVO> idIndex`，遍历 rows 转 VO 入 map；二次遍历：parent 在 map → 挂入 parent.children；否则视为 root 加入返回 list
  5. 返回 list（保持 SQL ORDER BY 顺序，孤立子节点出现在 root 列表中按其行序）
- 类级 `@Transactional` 不影响只读；可在方法上加 `@Transactional(readOnly = true)` 显式覆盖（建议）

- [ ] **Step 1: 写失败测试（7 用例）**
  - `listTree_emptyKeyword_invokesMapperWithEmptyString_returnsAssembledTree`：mock 返回 [root1(id=1,parent=null), root2(id=2,parent=null), child1(id=3,parent=1), child2(id=4,parent=1), grand1(id=5,parent=3)]；断言返回 list size==2；root1.children size==2 含 child1+child2；child1.children 含 grand1；root2.children 空
  - `listTree_nullKeyword_treatedAsEmpty`：参数 null；ArgumentCaptor 抓 mapper 入参 == ""
  - `listTree_blankKeyword_treatedAsEmpty`：参数 "   "；mapper 入参 == ""
  - `listTree_keywordTooLong_throws40001`：参数 = "x".repeat(101)；BizException(40001)；mapper 永不调用
  - `listTree_returnsEmptyListWhenNoMatch`：mock 返回 emptyList；返回 List.of()
  - `listTree_orphansBecomeRootsInForest`：mock 返回 [child(id=3,parent=99)]；返回 list size==1，第 0 项 iIncrement=3，children 空
  - `listTree_keywordIsTrimmedBeforeQuery`：参数 "  系统  "；mapper 入参 == "系统"
  - 子会话先跑 → 7 用例 FAIL

- [ ] **Step 2: 实现 VO + service**

- [ ] **Step 3: 子会话验证 PASS**
  - 命令：`cd backend && mvn -B test -Dtest=ModuleServiceImplTest`
  - 期望：18 (前) + 7 = 25 用例全绿

- [ ] **Step 4: Commit**
  - `git commit -m "feat(mod): module list tree service + vo REQ-MOD-004"`

### Task 3: Controller GET + 6 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("/modules") public Result<List<ModuleTreeVO>> list(@RequestParam(required = false) String keyword)`
- 返回 `Result.ok(moduleService.listTree(keyword))`

- [ ] **Step 1: 写失败测试（6 用例）**
  - `getEmptyKeyword_returnsCompleteTreeAsForest`：直插 root + child(parent=root)；GET 带 JWT `/api/mod/modules`；`code=0`，`data` 是数组；找出 iIncrement=root 的节点，children 含 iIncrement=child
  - `getKeywordMatch_returnsForest`：直插 "系统模块A"+"用户模块B"；GET `?keyword=系统`；返回数组只含 sModuleNameZh 含"系统"的节点
  - `getKeywordTooLong_returns40001`：`keyword` 101 字符 → `code=40001`
  - `getNoMatch_returnsEmptyArray`：`keyword=不存在的关键字XYZ`；`data` JsonNode isArray && size==0
  - `getWithoutJwt_permitAllStub_returns200`：无 token GET；`code=0`
  - `getTamperedJwt_returns20001`：Authorization "Bearer not.a.real.jwt" → `code=20001`
  - 子会话先跑 → FAIL

- [ ] **Step 2: 实现 controller**

- [ ] **Step 3: 子会话跑全量回归**
  - 命令：`cd backend && mvn -B test`
  - 期望：MOD-001 26 + MOD-002 15 + MOD-003 12 + MOD-004 新增 1(mapperIT) + 7(svc) + 6(IT) = 67 用例全绿

- [ ] **Step 4: Commit**
  - `git commit -m "test(mod): module list integration coverage REQ-MOD-004"`

## 提交计划

| commit | 覆盖 |
|---|---|
| `feat(mod): mapper#selectActiveByKeyword REQ-MOD-004` | Task 1 |
| `feat(mod): module list tree service + vo REQ-MOD-004` | Task 2 |
| `test(mod): module list integration coverage REQ-MOD-004` | Task 3 |
