Commit 9e107120a81688178e3086d6c50c4d86c9c597c4

Authored by zichun
1 parent a46be5d2

docs(mod): review approval REQ-MOD-004

docs/08-模块任务管理.md
... ... @@ -63,7 +63,7 @@
63 63 - [x] REQ-MOD-001 模块新增
64 64 - [x] REQ-MOD-002 模块修改
65 65 - [x] REQ-MOD-003 模块删除
66   - - [ ] REQ-MOD-004 模块查询
  66 + - [x] REQ-MOD-004 模块查询
67 67  
68 68 - module_usr 用户管理
69 69 - 依赖: —
... ...
docs/superpowers/plans/2026-05-06-REQ-MOD-004.md 0 → 100644
  1 +---
  2 +req_id: REQ-MOD-004
  3 +date: 2026-05-06
  4 +spec_ref: docs/superpowers/specs/2026-05-06-REQ-MOD-004.md
  5 +---
  6 +
  7 +# REQ-MOD-004 模块查询 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
  10 +
  11 +**Goal:** 实现 `GET /api/modules?keyword=...`:返回未软删除模块的树形结构,可选按 `sModuleNameZh` 模糊匹配并保留命中节点的祖先链。
  12 +
  13 +**Architecture:** 一次性 selectList 拉所有未删除模块到内存,按 keyword 过滤命中集合,沿父链向上收集祖先合并到结果集,最后在内存按 iParentId 组装树。同级按 iSortOrder ASC + iIncrement ASC 排序。叶子 `children=[]`。
  14 +
  15 +**Tech Stack:** 沿用 REQ-MOD-001~003。
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无。
  22 +
  23 +## 文件变更清单
  24 +
  25 +- 创建: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeNodeVO.java` — 树形节点 VO(7 字段 + children 列表)
  26 +- 创建: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java` — 查询参数 DTO(keyword)
  27 +- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `tree(ModuleQueryDTO query): List<ModuleTreeNodeVO>`
  28 +- 修改: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 tree
  29 +- 修改: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@GetMapping`
  30 +- 修改: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 6 个 tree 单测
  31 +- 修改: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 7 个 GET 集成测试
  32 +
  33 +## 任务步骤
  34 +
  35 +### Task 1: VO + DTO
  36 +
  37 +**Files:**
  38 +- Create: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeNodeVO.java`
  39 +- Create: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java`
  40 +
  41 +**API shape:**
  42 +- `ModuleTreeNodeVO`:7 字段 + `List<ModuleTreeNodeVO> children`,Lombok `@Data`,含静态工厂 `from(ModuleEntity e)`(创建带空 children 列表的节点)。
  43 +- `ModuleQueryDTO`:单字段 `@Size(max=50) String keyword`(可空)。Bean Validation 由 controller 触发。
  44 +
  45 +- [ ] **Step 1.1 创建 VO + DTO(无独立单测;Bean Validation 在 IT 层覆盖)**
  46 +
  47 +- [ ] **Step 1.2 提交**
  48 + - `git commit -m "feat(mod): module tree VO + query DTO REQ-MOD-004"`
  49 +
  50 +---
  51 +
  52 +### Task 2: ModuleService.tree — 树构建逻辑(mock 单元测试)
  53 +
  54 +**Files:**
  55 +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java`
  56 +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java`
  57 +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java`
  58 +
  59 +**API shape:**
  60 +- `interface ModuleService` 追加:`List<ModuleTreeNodeVO> tree(ModuleQueryDTO query)`
  61 +- 实现步骤:
  62 + 1. `all = moduleMapper.selectList(LambdaQueryWrapper.eq(BDeleted, false))`
  63 + 2. 若 `query.keyword == null || keyword.isEmpty()` → 跳到步骤 5(用全量构树)
  64 + 3. 否则在 `all` 内存里筛选 `hits = all.filter(e -> e.sModuleNameZh.contains(keyword))`
  65 + 4. 对每个 hit 沿 `iParentId` 链向上收集祖先(用 `byId = all.stream().toMap(IIncrement)` 索引);合并到 `survivors = hits ∪ ancestors`
  66 + 5. 按 iIncrement 索引 `survivors`,按 iParentId 分组子节点列表,同级按 (iSortOrder, iIncrement) ASC 排序
  67 + 6. 取 `iParentId == null || iParentId not in survivors` 的节点作为根(这样过滤后一些祖先链断裂的节点也会被提到根,避免孤立);递归挂 children
  68 + 7. 返回根节点列表
  69 +- 标 `@Transactional(readOnly = true)`
  70 +
  71 +**关键不变量**(写入 plan 锁定):
  72 +- 叶子节点 `children = new ArrayList<>()`,**不为 null**
  73 +- 排序键稳定:`Comparator.comparingInt(ModuleTreeNodeVO::getISortOrder).thenComparingInt(ModuleTreeNodeVO::getIIncrement)`
  74 +- 祖先链深度上限 5(与 docs/03 § tModule 注记一致);超 5 仍向上视为业务异常并记一行 `log.warn`,不抛错(不影响 200 返回)
  75 +
  76 +- [ ] **Step 2.1 写失败测试(6 个)**
  77 + - `tree_emptyDb_returnsEmptyList`:mapper.selectList 返回空 → service 返回 `List.of()`
  78 + - `tree_singleRoot_returnsOneNodeWithEmptyChildren`:1 个 root,断言返回 list 长度 1,children 是 `[]`
  79 + - `tree_multiLevel_buildsNestedStructureSortedByISortOrder`:root(sort=2) + root(sort=1) + child of root1,断言返回顺序 [sort=1, sort=2],root1.children 包含 child
  80 + - `tree_keywordHit_includesAncestorChain`:grandparent → parent → child(sModuleNameZh 各不同),keyword 匹配 child;断言返回 grandparent → parent → child 三层
  81 + - `tree_keywordNoMatch_returnsEmptyList`
  82 + - `tree_softDeletedExcluded`(验证 mapper 调用时 wrapper 含 `eq(bDeleted, false)`;ArgumentCaptor<Wrapper>)
  83 + - 测试方式:`@ExtendWith(MockitoExtension.class)`,构造 `List<ModuleEntity>` 喂 mock 返回值
  84 + - 子会话: FAIL(方法不存在)
  85 +
  86 +- [ ] **Step 2.2 实现 service.tree**
  87 + - 子会话: PASS
  88 +
  89 +- [ ] **Step 2.3 提交**
  90 + - `git commit -m "feat(mod): query module tree service REQ-MOD-004"`
  91 +
  92 +---
  93 +
  94 +### Task 3: ModuleController GET 端点 + 端到端 IT
  95 +
  96 +**Files:**
  97 +- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java`
  98 +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java`
  99 +
  100 +**API shape:**
  101 +- 新方法:
  102 + ```
  103 + @GetMapping
  104 + public ApiResponse<List<ModuleTreeNodeVO>> tree(@Valid ModuleQueryDTO query)
  105 + ```
  106 +- Javadoc:`REQ-MOD-004 模块查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:READ')")`
  107 +- `@Valid` 触发 Bean Validation;keyword 长度超限 → MethodArgumentNotValidException → GlobalExceptionHandler → 40010
  108 +
  109 +> **注意**:query 参数对象用 `@Valid ModuleQueryDTO` 时,Spring MVC 会把 `keyword` query 参数绑定到 DTO 字段(无需 @ModelAttribute,bean 类型默认走 query string)。
  110 +
  111 +- [ ] **Step 3.1 写失败测试(7 个)**
  112 + - `get_emptyKeyword_returnsAllUndeletedAsTree`:先 mapper.insert 几条(含 root + child + soft-deleted),GET 不带 keyword;断言 200 + data 长度等于未删 root 数;递归断言子树。
  113 + - `get_keyword_filtersByModuleNameZhWithAncestors`:插入 grandparent("系统配置") → parent("用户管理") → child("登录");GET `?keyword=登录`;断言返回三层链。
  114 + - `get_keywordNoMatch_returnsEmptyArray`:GET `?keyword=不存在`,断言 `data=[]`,code=200。
  115 + - `get_keywordTooLong_returns40010`:keyword 51 字符。
  116 + - `get_softDeletedNotInResult`:插入一条并立即 update bDeleted=1,GET 全量,断言不在结果。
  117 + - `get_responseExcludesInternalFields`:断言 `$.data[0].sProcedureName` doesNotExist;同时验证 `sModuleType` / `bShowPermission` / `tCreateDate` / `bDeleted` 不出现。
  118 + - `get_leafNodeChildrenIsEmptyArrayNotNull`:断言叶子 `$.data[*].children` is array 且 length=0(非 null)。
  119 + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired ModuleMapper`
  120 + - 子会话: FAIL(端点不存在)
  121 +
  122 +- [ ] **Step 3.2 实现 GET 端点**
  123 + - 子会话: PASS
  124 +
  125 +- [ ] **Step 3.3 跑全量 backend 测试**
  126 + - 期望累计 47 + 6(service tree) + 7(controller GET) = 60 个,全绿(mvn 报数会因共享 context 而合并)
  127 +
  128 +- [ ] **Step 3.4 提交**
  129 + - `git commit -m "feat(mod): GET /api/modules controller REQ-MOD-004"`
  130 +
  131 +---
  132 +
  133 +## 提交计划
  134 +
  135 +- `feat(mod): module tree VO + query DTO REQ-MOD-004`(Task 1)
  136 +- `feat(mod): query module tree service REQ-MOD-004`(Task 2)
  137 +- `feat(mod): GET /api/modules controller REQ-MOD-004`(Task 3)
... ...
docs/superpowers/reviews/2026-05-06-REQ-MOD-004.md 0 → 100644
  1 +---
  2 +req_id: REQ-MOD-004
  3 +date: 2026-05-06
  4 +round: 1
  5 +reviewer: superpower-code-reviewer
  6 +---
  7 +
  8 +# Review: REQ-MOD-004 — round 1
  9 +
  10 +## 结论
  11 +approve
  12 +
  13 +## Must-fix
  14 +(无)
  15 +
  16 +## Nice-to-have
  17 +
  18 +- 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 那条。当前同级排序仅在单测覆盖,缺端到端断言。
  19 +- backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java:530 — 补 IT 覆盖 spec § 验收 #6『keyword 含中英混合』:插入 sModuleNameZh='user 用户',GET ?keyword=user 用户 命中。
  20 +- 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`。
  21 +- 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)`。
  22 +- 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())` 一致化。
  23 +- backend/pom.xml:121 — surefire includes 修改是项目级配置;建议在 docs/04 测试规范节追加一行说明『*IT.java 走 surefire (mvn test) 而非 failsafe (mvn verify)』,避免后续贡献者破坏 includes。
  24 +- docs/03-数据库设计文档.md § tModule 业务注记 — 建议追加一行『模块树最大深度 5 层(iParentId 自引用),由 service 层校验,REQ-MOD-002 / REQ-MOD-004 引用本约束』,把深度上限提升为 SSoT。
  25 +- backend/src/main/java/com/xly/erp/module/mod/dto/ModuleQueryDTO.java:11 — 在 @Size 上方 javadoc 写明『empty 视为不过滤』。
  26 +
  27 +## 反例 / 测试覆盖缺口
  28 +
  29 +1. spec § 验收 #7 同级排序的端到端 IT 缺失(仅单测)。
  30 +2. spec § 验收 #6 中英混合 keyword 没有专属 IT。
  31 +3. `tree_softDeletedExcluded` 单测断言强度低于 plan 期望。
  32 +4. 反例缺:keyword 含 SQL 通配符 `%` / `_`(spec 声明本期不转义,作为已知边界)。
  33 +5. 反例缺:祖先链深度 ≥ 5 的截断边界用例;docs/03 已限深度 5,正常打不到边界,可后续补 6 层强构数据单测固化截断语义。
  34 +6. Controller javadoc 标了『REQ-USR-004 完成后追加 @PreAuthorize』但缺集中跟踪——建议在 docs/08 § 二 USR-004 子项挂 follow-up 备注。
  35 +
  36 +**核心结论**:实现忠实于 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 跑通。
... ...
docs/superpowers/specs/2026-05-06-REQ-MOD-004.md 0 → 100644
  1 +---
  2 +req_id: REQ-MOD-004
  3 +date: 2026-05-06
  4 +module: module_mod
  5 +---
  6 +
  7 +# Spec: REQ-MOD-004 — 模块查询
  8 +
  9 +## 目标
  10 +
  11 +实现后端 `GET /api/modules` 接口:以**树形结构**返回所有未软删除的模块,可选按 `sModuleNameZh` 模糊匹配过滤;过滤命中时同时保留命中节点的所有**祖先路径**以便定位上下文。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +**接口**:`GET /api/modules`,无请求体。
  16 +
  17 +**Query parameters**:
  18 +
  19 +| 字段 | 类型 | 必填 | 校验 / 取值 |
  20 +|---|---|---|---|
  21 +| `keyword` | String | 否 | 长度 ≤ 50;非空时对 `sModuleNameZh` 做 `LIKE '%keyword%'`(不区分大小写——MySQL `utf8mb4_unicode_ci` 默认行为) |
  22 +
  23 +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + 权限码 `MOD:READ`。沿用 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:READ')")`。
  24 +
  25 +## 输出 / 结果
  26 +
  27 +**HTTP 200,响应体**:
  28 +
  29 +```json
  30 +{
  31 + "code": 200,
  32 + "message": "操作成功",
  33 + "data": [
  34 + {
  35 + "iIncrement": 1,
  36 + "sModuleNameZh": "系统配置",
  37 + "sDisplayType": "系统配置",
  38 + "sManageDeptEn": "IT",
  39 + "iParentId": null,
  40 + "iSortOrder": 0,
  41 + "children": [
  42 + {
  43 + "iIncrement": 2,
  44 + "sModuleNameZh": "用户管理",
  45 + "sDisplayType": "前端业务",
  46 + "sManageDeptEn": "IT",
  47 + "iParentId": 1,
  48 + "iSortOrder": 1,
  49 + "children": []
  50 + }
  51 + ]
  52 + }
  53 + ],
  54 + "timestamp": 1746528600000
  55 +}
  56 +```
  57 +
  58 +`data` 是根节点数组(`iParentId == null` 的节点);每个节点带 `children` 数组(同结构递归)。
  59 +
  60 +新增 VO `ModuleTreeNodeVO`:字段 `iIncrement` / `sModuleNameZh` / `sDisplayType` / `sManageDeptEn` / `iParentId` / `iSortOrder` / `children: List<ModuleTreeNodeVO>`。
  61 +
  62 +> **不返回的字段**(避免泄露内部):`sProcedureName` / `sModuleType` / `bShowPermission` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` / `bDeleted` / `tDeletedDate` / `sDeletedBy`。如未来 REQ 需要其中字段,再扩 VO。
  63 +
  64 +## 业务规则
  65 +
  66 +1. **范围过滤**:`bDeleted = 0`(默认仅返回未软删除)。
  67 +2. **空 keyword**:返回所有未软删除模块构成的完整树。
  68 +3. **非空 keyword**:
  69 + - 步骤 a:在所有未软删除模块中找出 `sModuleNameZh LIKE '%keyword%'` 的命中集合 `hits`。
  70 + - 步骤 b:对每个 `hit`,沿 `iParentId` 链向上收集所有未软删除祖先(深度上限 5 与 docs/03 § tModule 一致),并入结果集合。
  71 + - 步骤 c:用结果集合在内存中按 `iParentId` 组装树。
  72 + - 命中节点本身的子孙不会被强制纳入(除非也命中);这样避免一次过滤拉出整棵子树。
  73 +4. **排序**:同级节点按 `iSortOrder ASC` 升序,`iSortOrder` 相同则按 `iIncrement ASC`(确定性排序)。
  74 +5. **`children` 字段**:叶子节点为 `[]`,不为 `null`(确保前端可直接 `.map`)。
  75 +6. **空结果**:keyword 无匹配 → `data = []`,**HTTP 200 + code=200**,不返回 404 类错误。
  76 +7. **只读**:本接口不写库,无事务要求;标 `@Transactional(readOnly = true)`。
  77 +
  78 +## 边界与约束
  79 +
  80 +### 鉴权策略
  81 +
  82 +沿用 REQ-MOD-001/002/003 SecurityConfig permitAll。
  83 +
  84 +### 错误码
  85 +
  86 +| 场景 | 错误码 | ErrorCode 枚举 |
  87 +|---|---|---|
  88 +| `keyword` 长度 > 50 | 40010 | `PARAM_INVALID`(已存在) |
  89 +| 服务端兜底 | 50000 | `INTERNAL_ERROR` |
  90 +
  91 +### 性能
  92 +
  93 +- 单次 `selectList(LambdaQueryWrapper.eq(bDeleted, false))` 拉取所有未删除模块;spec § 性能上限 docs/03 注明"单次返回不超过 500 项 / 树深度上限 5 层",本期数据量低不分页。
  94 +- 在内存里 O(N) 建索引(id → entity)+ O(N) 建子节点列表 + O(N) 排序 + O(K * D) 沿祖先链向上(K=hits 数,D=深度上限 5)。
  95 +- 不引入 SQL 递归 CTE / 自连接;保持 mapper 层轻量。
  96 +
  97 +### 大小写敏感
  98 +
  99 +`utf8mb4_unicode_ci` collation 默认不区分大小写。MyBatis-Plus `LambdaQueryWrapper.like(...)` 走 SQL `LIKE`,行为与 collation 一致。无需额外处理。
  100 +
  101 +### keyword 中的特殊字符
  102 +
  103 +`%` / `_` 在 SQL `LIKE` 模式里是通配符。简化处理:本期不做转义(业务模块名通常不含这些字符);如未来需要,service 层先把 keyword 中的 `%` / `_` / `\` 替换为转义形式(`MyBatis-Plus 5.x` 的 `like` 已自带 escape,本期 3.5.7 需手动)。**本 REQ 暂不处理转义**,作为已知边界记录。
  104 +
  105 +## 依赖的 schema 表 / 字段
  106 +
  107 +**读表**:`tModule`
  108 +
  109 +| 字段 | 用途 |
  110 +|---|---|
  111 +| `iIncrement` | 节点 id;输出 |
  112 +| `sModuleNameZh` | 模糊匹配列;输出 |
  113 +| `sDisplayType` | 输出 |
  114 +| `sManageDeptEn` | 输出 |
  115 +| `iParentId` | 父子关系;输出 |
  116 +| `iSortOrder` | 排序;输出 |
  117 +| `bDeleted` | 过滤未删除(=0) |
  118 +
  119 +**索引利用**:
  120 +- `idx_module_name_zh`:`LIKE '%keyword%'` 实际不走索引(左模糊),但本期数据量低可接受。
  121 +- `idx_parent`:祖先链查询时按 iIncrement PK 走 selectById,不用 idx_parent。
  122 +- `idx_module_deleted`(未单独建,但 bDeleted 在多个 idx 中已有):bDeleted 过滤时 MySQL 优化器自行选择。
  123 +
  124 +**外键**:本接口只读,不触发 FK 检查。
  125 +
  126 +## 依赖的接口
  127 +
  128 +无(独立查询接口)。
  129 +
  130 +## 验收标准
  131 +
  132 +### 功能正确性
  133 +
  134 +1. **正向 — 空 keyword 返回完整树**:DB 中 5 棵根 + 多层子模块;GET 返回 5 个根节点,子孙完整嵌套;同级按 iSortOrder 升序。
  135 +2. **正向 — keyword 模糊匹配 + 祖先**:DB 有 root("系统配置") → 子("用户管理") → 孙("登录");GET `?keyword=登录` 返回 root → 子 → 孙 三层(即命中节点 + 全部祖先)。
  136 +3. **正向 — keyword 部分匹配**:keyword="管理",匹配多个节点;返回它们各自完整祖先链合并的树。
  137 +4. **正向 — 软删除模块过滤**:DELETE 一个模块后再查,结果不含该模块。
  138 +5. **正向 — 空结果**:keyword="不存在的关键词",返回 `data=[]` + code=200。
  139 +6. **正向 — keyword 含中英混合**:keyword="user 用户" 也走 LIKE '%user 用户%'(与 spec § 业务规则 3 一致;不拆词)。
  140 +7. **同级排序**:两根 iSortOrder=2/1,返回顺序 1 在前。
  141 +8. **children 字段**:叶子节点 children 是 `[]` 而非 null(jsonPath 断言)。
  142 +9. **keyword 长度超限**:keyword=51 字符,返回 `code=40010`。
  143 +10. **无登录也可访问**(permitAll 阶段):直接 GET 不带 Authorization 返回 200。
  144 +11. **响应字段精简**:响应不含 sProcedureName / sModuleType / bShowPermission / 标准列等内部字段(jsonPath 断言)。
  145 +
  146 +### 接口契约一致性
  147 +
  148 +- 响应格式 `{code, message, data, timestamp}`。
  149 +- 错误码 200 / 40010 / 50000。
  150 +- 不回显堆栈。
  151 +
  152 +### 测试覆盖
  153 +
  154 +- **单元测试** `ModuleServiceImplTest` 追加(mock ModuleMapper):
  155 + - tree_emptyDb_returnsEmptyList
  156 + - tree_singleRoot_returnsOneNodeWithEmptyChildren
  157 + - tree_multiLevel_buildsNestedStructureSortedByISortOrder
  158 + - tree_keywordHit_includesAncestorChain
  159 + - tree_keywordNoMatch_returnsEmptyList
  160 + - tree_softDeletedExcluded(mapper 已过滤;service 不重复过滤)
  161 +
  162 +- **集成测试** `ModuleControllerIT` 追加:
  163 + - get_emptyKeyword_returnsAllUndeletedAsTree
  164 + - get_keyword_filtersByModuleNameZhWithAncestors
  165 + - get_keywordNoMatch_returnsEmptyArray
  166 + - get_keywordTooLong_returns40010
  167 + - get_softDeletedNotInResult
  168 + - get_responseExcludesInternalFields
  169 + - get_leafNodeChildrenIsEmptyArrayNotNull
  170 +
  171 +### 代码与文档
  172 +
  173 +- `// REQ-MOD-004` 注释贴在 Controller / Service / VO。
  174 +- 提交按 `feat(mod): <subject> REQ-MOD-004` 规范。
... ...