Commit 230f61fb06b95ca69cfce50fa579d43482043457

Authored by zichun
1 parent a6badd20

docs(mod): spec + plan REQ-MOD-004

docs/superpowers/plans/2026-04-29-REQ-MOD-004.md 0 → 100644
  1 +---
  2 +req_id: REQ-MOD-004
  3 +date: 2026-04-29
  4 +spec_ref: docs/superpowers/specs/2026-04-29-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.
  10 +
  11 +**Goal:** 在 MOD-001~003 已建工程基础上增量实现 `GET /api/mod/modules` 模块树查询:DB 模糊匹配 + 内存拼装森林。
  12 +
  13 +**Architecture:** 复用 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper`;新增 `ModuleTreeVO`、`mapper.selectActiveByKeyword(String)`、`service.listTree(String)`、controller `@GetMapping`。无新外部依赖。空 keyword 由 controller 归一化为 `""`,超长校验在 service。SecurityConfig 已对 `/api/mod/**` permitAll 覆盖该接口。
  14 +
  15 +**Tech Stack:** 沿用(Spring Boot 3.3.5 / MyBatis-Plus / JUnit 5 + Mockito + TestRestTemplate)。
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无(仅 SELECT)。
  22 +
  23 +## 文件变更清单
  24 +
  25 +### 新增
  26 +
  27 +- `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeVO.java` — 树节点出参 VO
  28 +
  29 +### 修改
  30 +
  31 +- `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` — 追加 `List<Module> selectActiveByKeyword(@Param("keyword") String keyword)` 注解 SELECT
  32 +- `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `List<ModuleTreeVO> listTree(String keyword)`
  33 +- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 listTree(trim + 长度校验 + 拼树)
  34 +- `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@GetMapping("/modules")`
  35 +- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 7 用例
  36 +- `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` — 追加 1 用例
  37 +- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 6 用例
  38 +
  39 +## 任务步骤
  40 +
  41 +> 全局:每 commit `<type>(mod): <subject> REQ-MOD-004`;测试派发子会话;现有 53 用例全程绿。
  42 +
  43 +### Task 1: Mapper#selectActiveByKeyword + IT
  44 +
  45 +**Files:**
  46 +- Modify: `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java`
  47 +- Modify: `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java`
  48 +
  49 +**API shape:**
  50 +- `@Select("SELECT iIncrement, sModuleNameZh, sDisplayType, sManageDeptEn, iParentId, iSortOrder FROM tModule WHERE bDeleted = 0 AND sModuleNameZh LIKE CONCAT('%', #{keyword}, '%') ORDER BY iSortOrder ASC, iIncrement ASC")`
  51 +- `List<Module> selectActiveByKeyword(@Param("keyword") String keyword)`
  52 +- 返回的 Module 实例只填查询的 6 列;其他字段为 null(MyBatis 默认行为)
  53 +
  54 +- [ ] **Step 1: 写失败测试 `ModuleMapperIT#selectActiveByKeyword_filtersAndOrders`**
  55 + - 准备 5 行:A "系统-A" iSortOrder=1; B "系统-B" iSortOrder=0; C "用户" iSortOrder=2; D "系统-D" bDeleted=1; E "测试" iSortOrder=3
  56 + - 断言:`selectActiveByKeyword("")` → 4 行,顺序 [B(0), A(1), C(2), E(3)](D 被 bDeleted 过滤)
  57 + - 断言:`selectActiveByKeyword("系统")` → [B, A](D 被 bDeleted 过滤)
  58 + - 断言:`selectActiveByKeyword("不存在XYZ")` → 空 list
  59 +
  60 +- [ ] **Step 2: 实现 mapper 方法**
  61 +
  62 +- [ ] **Step 3: 子会话验证 PASS**
  63 + - 命令:`cd backend && mvn -B test -Dtest=ModuleMapperIT`
  64 +
  65 +- [ ] **Step 4: Commit**
  66 + - `git commit -m "feat(mod): mapper#selectActiveByKeyword REQ-MOD-004"`
  67 +
  68 +### Task 2: ModuleTreeVO + Service.listTree + 单测
  69 +
  70 +**Files:**
  71 +- Create: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleTreeVO.java`
  72 +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java`
  73 +- Modify: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java`
  74 +- Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java`
  75 +
  76 +**API shape:**
  77 +- `ModuleTreeVO` POJO:字段 `iIncrement` / `sModuleNameZh` / `sDisplayType` / `sManageDeptEn` / `iParentId` / `iSortOrder` / `List<ModuleTreeVO> children`(默认 new ArrayList<>());getter/setter;含 `@JsonProperty` 锁定 JSON 名(与 DTO 风格一致)。
  78 +- `ModuleService#listTree(String keyword) : List<ModuleTreeVO>`
  79 +- `ModuleServiceImpl#listTree(String keyword)`:
  80 + 1. `String normalized = keyword == null ? "" : keyword.trim()`
  81 + 2. `if (normalized.length() > 100) throw new BizException(40001, "keyword 长度超过 100 字符")`
  82 + 3. `List<Module> rows = moduleMapper.selectActiveByKeyword(normalized)`
  83 + 4. 拼树:建 `Map<Integer, ModuleTreeVO> idIndex`,遍历 rows 转 VO 入 map;二次遍历:parent 在 map → 挂入 parent.children;否则视为 root 加入返回 list
  84 + 5. 返回 list(保持 SQL ORDER BY 顺序,孤立子节点出现在 root 列表中按其行序)
  85 +- 类级 `@Transactional` 不影响只读;可在方法上加 `@Transactional(readOnly = true)` 显式覆盖(建议)
  86 +
  87 +- [ ] **Step 1: 写失败测试(7 用例)**
  88 + - `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 空
  89 + - `listTree_nullKeyword_treatedAsEmpty`:参数 null;ArgumentCaptor 抓 mapper 入参 == ""
  90 + - `listTree_blankKeyword_treatedAsEmpty`:参数 " ";mapper 入参 == ""
  91 + - `listTree_keywordTooLong_throws40001`:参数 = "x".repeat(101);BizException(40001);mapper 永不调用
  92 + - `listTree_returnsEmptyListWhenNoMatch`:mock 返回 emptyList;返回 List.of()
  93 + - `listTree_orphansBecomeRootsInForest`:mock 返回 [child(id=3,parent=99)];返回 list size==1,第 0 项 iIncrement=3,children 空
  94 + - `listTree_keywordIsTrimmedBeforeQuery`:参数 " 系统 ";mapper 入参 == "系统"
  95 + - 子会话先跑 → 7 用例 FAIL
  96 +
  97 +- [ ] **Step 2: 实现 VO + service**
  98 +
  99 +- [ ] **Step 3: 子会话验证 PASS**
  100 + - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest`
  101 + - 期望:18 (前) + 7 = 25 用例全绿
  102 +
  103 +- [ ] **Step 4: Commit**
  104 + - `git commit -m "feat(mod): module list tree service + vo REQ-MOD-004"`
  105 +
  106 +### Task 3: Controller GET + 6 IT + 全量回归
  107 +
  108 +**Files:**
  109 +- Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java`
  110 +- Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java`
  111 +
  112 +**API shape:**
  113 +- `@GetMapping("/modules") public Result<List<ModuleTreeVO>> list(@RequestParam(required = false) String keyword)`
  114 +- 返回 `Result.ok(moduleService.listTree(keyword))`
  115 +
  116 +- [ ] **Step 1: 写失败测试(6 用例)**
  117 + - `getEmptyKeyword_returnsCompleteTreeAsForest`:直插 root + child(parent=root);GET 带 JWT `/api/mod/modules`;`code=0`,`data` 是数组;找出 iIncrement=root 的节点,children 含 iIncrement=child
  118 + - `getKeywordMatch_returnsForest`:直插 "系统模块A"+"用户模块B";GET `?keyword=系统`;返回数组只含 sModuleNameZh 含"系统"的节点
  119 + - `getKeywordTooLong_returns40001`:`keyword` 101 字符 → `code=40001`
  120 + - `getNoMatch_returnsEmptyArray`:`keyword=不存在的关键字XYZ`;`data` JsonNode isArray && size==0
  121 + - `getWithoutJwt_permitAllStub_returns200`:无 token GET;`code=0`
  122 + - `getTamperedJwt_returns20001`:Authorization "Bearer not.a.real.jwt" → `code=20001`
  123 + - 子会话先跑 → FAIL
  124 +
  125 +- [ ] **Step 2: 实现 controller**
  126 +
  127 +- [ ] **Step 3: 子会话跑全量回归**
  128 + - 命令:`cd backend && mvn -B test`
  129 + - 期望:MOD-001 26 + MOD-002 15 + MOD-003 12 + MOD-004 新增 1(mapperIT) + 7(svc) + 6(IT) = 67 用例全绿
  130 +
  131 +- [ ] **Step 4: Commit**
  132 + - `git commit -m "test(mod): module list integration coverage REQ-MOD-004"`
  133 +
  134 +## 提交计划
  135 +
  136 +| commit | 覆盖 |
  137 +|---|---|
  138 +| `feat(mod): mapper#selectActiveByKeyword REQ-MOD-004` | Task 1 |
  139 +| `feat(mod): module list tree service + vo REQ-MOD-004` | Task 2 |
  140 +| `test(mod): module list integration coverage REQ-MOD-004` | Task 3 |
docs/superpowers/specs/2026-04-29-REQ-MOD-004.md 0 → 100644
  1 +---
  2 +req_id: REQ-MOD-004
  3 +date: 2026-04-29
  4 +module: module_mod
  5 +---
  6 +
  7 +# Spec: REQ-MOD-004 — 模块查询
  8 +
  9 +## 目标
  10 +
  11 +按关键字对 `tModule.sModuleNameZh` 模糊匹配,过滤 `bDeleted=0` 行,按 `iParentId` 拼装为树形(森林)输出。空关键字返回完整模块树。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +### HTTP 接口(docs/05 § REQ-MOD-004)
  16 +
  17 +- Method / Path: `GET /api/mod/modules`
  18 +- Auth: 必需(沿用 MOD-001 stub:路径已在 SecurityConfig `/api/mod/**` permitAll;USR-004 后改 `authenticated()` 即可——不需要 hasAuthority,本接口面向所有登录用户)
  19 +- Query: `keyword`(可选;缺省 / 空串 / 仅空白 → 视作空匹配返回完整树)
  20 +
  21 +### 校验
  22 +
  23 +| 输入 | 校验 | 失败码 |
  24 +|---|---|---|
  25 +| `keyword` | 长度 ≤ 100 字符(与 `sModuleNameZh` 列长一致) | `40001` |
  26 +
  27 +## 输出 / 结果
  28 +
  29 +### 成功响应
  30 +
  31 +```json
  32 +{
  33 + "code": 0,
  34 + "msg": "ok",
  35 + "data": [
  36 + {
  37 + "iIncrement": 1,
  38 + "sModuleNameZh": "系统管理",
  39 + "sDisplayType": "手机端",
  40 + "sManageDeptEn": "IT",
  41 + "iParentId": null,
  42 + "iSortOrder": 0,
  43 + "children": [
  44 + { "iIncrement": 2, "sModuleNameZh": "用户管理", "sDisplayType": "手机端",
  45 + "sManageDeptEn": "IT", "iParentId": 1, "iSortOrder": 0, "children": [] }
  46 + ]
  47 + }
  48 + ]
  49 +}
  50 +```
  51 +
  52 +`data` 是数组(森林),无命中时为 `[]`。
  53 +
  54 +### VO `ModuleTreeVO`
  55 +
  56 +| 字段 | 类型 | 来源 |
  57 +|---|---|---|
  58 +| `iIncrement` | `Integer` | `tModule.iIncrement` |
  59 +| `sModuleNameZh` | `String` | `tModule.sModuleNameZh` |
  60 +| `sDisplayType` | `String` | `tModule.sDisplayType` |
  61 +| `sManageDeptEn` | `String` | `tModule.sManageDeptEn` |
  62 +| `iParentId` | `Integer` | `tModule.iParentId`(null 表根) |
  63 +| `iSortOrder` | `Integer` | `tModule.iSortOrder` |
  64 +| `children` | `List<ModuleTreeVO>` | 拼装得到,叶节点为 `[]` |
  65 +
  66 +> 不返回 `sProcedureName` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` / `bShowPermission` / `sModuleType` / 软删除审计字段——这些不在 docs/05 输出 schema 中。
  67 +
  68 +## 业务规则
  69 +
  70 +1. **关键字归一化**:controller 先 trim;null 或空串均当作空匹配。
  71 +2. **长度校验**:trim 后长度 > 100 → `BizException(40001, "keyword 长度超过 100 字符")`。
  72 +3. **DB 查询**:`SELECT iIncrement, sModuleNameZh, sDisplayType, sManageDeptEn, iParentId, iSortOrder FROM tModule WHERE bDeleted=0 AND sModuleNameZh LIKE CONCAT('%', #{keyword}, '%') ORDER BY iSortOrder ASC, iIncrement ASC`;空 keyword → `LIKE '%%'` 命中所有。
  73 +4. **拼树**(service 内存算法):
  74 + - 把命中行映射为 `ModuleTreeVO`;建 `Map<Integer, ModuleTreeVO> idIndex`
  75 + - 遍历:若 `iParentId != null && idIndex.containsKey(iParentId)` → 挂到 parent.children;否则视作 root(含真 root 与"父被过滤掉"的孤立节点),加入返回 list
  76 + - 由于 SQL 已 ORDER BY,拼装顺序天然有序;同 parent 下 children 顺序 = SQL 顺序
  77 +5. **只读**:service 上 `@Transactional(readOnly = true)`,明示无写副作用。
  78 +6. **空结果**:返回 `[]`,HTTP 200 / `code=0`。
  79 +
  80 +## 边界与约束
  81 +
  82 +- **`keyword` 缺失 / 空 / 仅空白** → 等价空匹配,返回完整有效树
  83 +- **`keyword` > 100 字符** → `40001`
  84 +- **JWT 伪造** → `20001`(filter 短路)
  85 +- **JWT 缺失** → permitAll stub,正常 200(USR-004 后改为要求登录)
  86 +- **SQL LIKE 通配符注入**:`keyword` 含 `%` / `_` 时 MyBatis 直接拼到 LIKE 中——理论上影响匹配范围(`%abc%` 用户输入 `%` 等于 `LIKE '%%abc%%'` 仍匹配所有)。本期不做转义(业务上不敏感且 docs/05 未要求);后续若需要严格匹配可在 service 层做 `keyword.replace("%","\\%").replace("_","\\_")` 处理。spec 记录该选择。
  87 +
  88 +## 实现范围与边界抉择
  89 +
  90 +1. **拼树策略**:选择"过滤命中后拼树(孤立子节点视为 root,即森林)",与 docs/03 § tModule 业务注记一致。**不实现"扩展祖先链以保留树形"**——会让查询语义复杂化(要么二次查祖先,要么 SQL 用 CTE);REQ 卡仅要求"以树形结构展示匹配结果",森林是合法的树形结果。
  91 +2. **Mapper 直接 SQL LIKE**:相对"先全查再内存过滤"更高效;模块数据量大时也撑得住。
  92 +3. **Service 排序在 SQL**:拼树时不再排序,避免重复劳动;测试断言依赖 SQL `ORDER BY iSortOrder ASC, iIncrement ASC`。
  93 +
  94 +## 依赖的 schema 表 / 字段
  95 +
  96 +读取表:`tModule`
  97 +
  98 +| 字段 | 用途 |
  99 +|---|---|
  100 +| `iIncrement` / `sModuleNameZh` / `sDisplayType` / `sManageDeptEn` / `iParentId` / `iSortOrder` | 输出 VO 字段 |
  101 +| `bDeleted` | 过滤条件(=0) |
  102 +
  103 +依赖索引:`idx_module_name_zh(sModuleNameZh)` 命中 LIKE 前缀匹配;`idx_parent(iParentId)` 不直接命中(拼树用内存 Map),但保留供未来 join 用。
  104 +
  105 +## 依赖的接口
  106 +
  107 +无。
  108 +
  109 +## 验收标准
  110 +
  111 +### 单元测试(追加到 `ModuleServiceImplTest`)
  112 +
  113 +- [x] `listTree_emptyKeyword_invokesMapperWithEmptyString_returnsAssembledTree` — Mock `selectActiveByKeyword("")` 返回 5 行(root1, child1, child2, deepChild1, root2),ArgumentCaptor 验 mapper 入参为 `""`;返回结构符合树(root1.children 含 child1/child2;child1.children 含 deepChild1;root2.children 空)
  114 +- [x] `listTree_nullKeyword_treatedAsEmpty` — DTO `keyword=null`,效果同空串
  115 +- [x] `listTree_blankKeyword_treatedAsEmpty` — keyword `" "` trim 后空
  116 +- [x] `listTree_keywordTooLong_throws40001` — keyword 101 字符 → BizException(40001)
  117 +- [x] `listTree_returnsEmptyListWhenNoMatch` — Mock 返回空 list;service 返回 `List.of()`
  118 +- [x] `listTree_orphansBecomeRootsInForest` — Mock 返回 [child](其父 iParentId=99 不在结果集);child 出现在顶层 list
  119 +- [x] `listTree_keywordIsTrimmedBeforeQuery` — keyword `" 系统 "` → mapper 入参 `"系统"`
  120 +
  121 +### Mapper IT(追加到 `ModuleMapperIT`)
  122 +
  123 +- [x] `selectActiveByKeyword_filtersAndOrders` — 准备 5 行(含 1 个 bDeleted=1);查 `keyword=""` → 4 行(按 iSortOrder, iIncrement 升序);查 `keyword="系统"` → 仅命中 sModuleNameZh 含"系统"的活跃行;查 `keyword="不存在"` → 空
  124 +
  125 +### 集成测试(追加到 `ModuleControllerIT`)
  126 +
  127 +- [x] `getEmptyKeyword_returnsCompleteTreeAsForest` — 直插 root + child;GET `/api/mod/modules` 带 JWT;`code=0`;`data` 是数组;至少含 root 节点且其 children 含 child
  128 +- [x] `getKeywordMatch_returnsForest` — 直插含"系统"的 alive 模块 + 不含"系统"的;GET `?keyword=系统`;只返回含"系统"的
  129 +- [x] `getKeywordTooLong_returns40001` — `keyword` 101 字符 → `code=40001`
  130 +- [x] `getNoMatch_returnsEmptyArray` — `keyword=不存在的关键字XYZ`;`data` 是 `[]`
  131 +- [x] `getWithoutJwt_permitAllStub_returns200` — 无 JWT GET;`code=0`
  132 +- [x] `getTamperedJwt_returns20001` — Authorization 伪造 → `code=20001`
  133 +
  134 +### 工程验收
  135 +
  136 +- [x] `cd backend && mvn -B test` 全绿(53 + MOD-004 新增 7(svc) + 1(mapperIT) + 6(controllerIT) = 67 用例)
  137 +- [x] 输出 VO 字段集严格匹配 docs/05 列表,不暴露敏感字段
  138 +- [x] `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点保持(路径已 permitAll,无需新增)