diff --git a/docs/superpowers/plans/2026-04-29-REQ-MOD-004.md b/docs/superpowers/plans/2026-04-29-REQ-MOD-004.md new file mode 100644 index 0000000..a9d58d1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-REQ-MOD-004.md @@ -0,0 +1,140 @@ +--- +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 selectActiveByKeyword(@Param("keyword") String keyword)` 注解 SELECT +- `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `List 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 `(mod): 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 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 children`(默认 new ArrayList<>());getter/setter;含 `@JsonProperty` 锁定 JSON 名(与 DTO 风格一致)。 +- `ModuleService#listTree(String keyword) : List` +- `ModuleServiceImpl#listTree(String keyword)`: + 1. `String normalized = keyword == null ? "" : keyword.trim()` + 2. `if (normalized.length() > 100) throw new BizException(40001, "keyword 长度超过 100 字符")` + 3. `List rows = moduleMapper.selectActiveByKeyword(normalized)` + 4. 拼树:建 `Map 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(@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 | diff --git a/docs/superpowers/specs/2026-04-29-REQ-MOD-004.md b/docs/superpowers/specs/2026-04-29-REQ-MOD-004.md new file mode 100644 index 0000000..99115ac --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-REQ-MOD-004.md @@ -0,0 +1,138 @@ +--- +req_id: REQ-MOD-004 +date: 2026-04-29 +module: module_mod +--- + +# Spec: REQ-MOD-004 — 模块查询 + +## 目标 + +按关键字对 `tModule.sModuleNameZh` 模糊匹配,过滤 `bDeleted=0` 行,按 `iParentId` 拼装为树形(森林)输出。空关键字返回完整模块树。 + +## 输入 / 触发 + +### HTTP 接口(docs/05 § REQ-MOD-004) + +- Method / Path: `GET /api/mod/modules` +- Auth: 必需(沿用 MOD-001 stub:路径已在 SecurityConfig `/api/mod/**` permitAll;USR-004 后改 `authenticated()` 即可——不需要 hasAuthority,本接口面向所有登录用户) +- Query: `keyword`(可选;缺省 / 空串 / 仅空白 → 视作空匹配返回完整树) + +### 校验 + +| 输入 | 校验 | 失败码 | +|---|---|---| +| `keyword` | 长度 ≤ 100 字符(与 `sModuleNameZh` 列长一致) | `40001` | + +## 输出 / 结果 + +### 成功响应 + +```json +{ + "code": 0, + "msg": "ok", + "data": [ + { + "iIncrement": 1, + "sModuleNameZh": "系统管理", + "sDisplayType": "手机端", + "sManageDeptEn": "IT", + "iParentId": null, + "iSortOrder": 0, + "children": [ + { "iIncrement": 2, "sModuleNameZh": "用户管理", "sDisplayType": "手机端", + "sManageDeptEn": "IT", "iParentId": 1, "iSortOrder": 0, "children": [] } + ] + } + ] +} +``` + +`data` 是数组(森林),无命中时为 `[]`。 + +### VO `ModuleTreeVO` + +| 字段 | 类型 | 来源 | +|---|---|---| +| `iIncrement` | `Integer` | `tModule.iIncrement` | +| `sModuleNameZh` | `String` | `tModule.sModuleNameZh` | +| `sDisplayType` | `String` | `tModule.sDisplayType` | +| `sManageDeptEn` | `String` | `tModule.sManageDeptEn` | +| `iParentId` | `Integer` | `tModule.iParentId`(null 表根) | +| `iSortOrder` | `Integer` | `tModule.iSortOrder` | +| `children` | `List` | 拼装得到,叶节点为 `[]` | + +> 不返回 `sProcedureName` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` / `bShowPermission` / `sModuleType` / 软删除审计字段——这些不在 docs/05 输出 schema 中。 + +## 业务规则 + +1. **关键字归一化**:controller 先 trim;null 或空串均当作空匹配。 +2. **长度校验**:trim 后长度 > 100 → `BizException(40001, "keyword 长度超过 100 字符")`。 +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 '%%'` 命中所有。 +4. **拼树**(service 内存算法): + - 把命中行映射为 `ModuleTreeVO`;建 `Map idIndex` + - 遍历:若 `iParentId != null && idIndex.containsKey(iParentId)` → 挂到 parent.children;否则视作 root(含真 root 与"父被过滤掉"的孤立节点),加入返回 list + - 由于 SQL 已 ORDER BY,拼装顺序天然有序;同 parent 下 children 顺序 = SQL 顺序 +5. **只读**:service 上 `@Transactional(readOnly = true)`,明示无写副作用。 +6. **空结果**:返回 `[]`,HTTP 200 / `code=0`。 + +## 边界与约束 + +- **`keyword` 缺失 / 空 / 仅空白** → 等价空匹配,返回完整有效树 +- **`keyword` > 100 字符** → `40001` +- **JWT 伪造** → `20001`(filter 短路) +- **JWT 缺失** → permitAll stub,正常 200(USR-004 后改为要求登录) +- **SQL LIKE 通配符注入**:`keyword` 含 `%` / `_` 时 MyBatis 直接拼到 LIKE 中——理论上影响匹配范围(`%abc%` 用户输入 `%` 等于 `LIKE '%%abc%%'` 仍匹配所有)。本期不做转义(业务上不敏感且 docs/05 未要求);后续若需要严格匹配可在 service 层做 `keyword.replace("%","\\%").replace("_","\\_")` 处理。spec 记录该选择。 + +## 实现范围与边界抉择 + +1. **拼树策略**:选择"过滤命中后拼树(孤立子节点视为 root,即森林)",与 docs/03 § tModule 业务注记一致。**不实现"扩展祖先链以保留树形"**——会让查询语义复杂化(要么二次查祖先,要么 SQL 用 CTE);REQ 卡仅要求"以树形结构展示匹配结果",森林是合法的树形结果。 +2. **Mapper 直接 SQL LIKE**:相对"先全查再内存过滤"更高效;模块数据量大时也撑得住。 +3. **Service 排序在 SQL**:拼树时不再排序,避免重复劳动;测试断言依赖 SQL `ORDER BY iSortOrder ASC, iIncrement ASC`。 + +## 依赖的 schema 表 / 字段 + +读取表:`tModule` + +| 字段 | 用途 | +|---|---| +| `iIncrement` / `sModuleNameZh` / `sDisplayType` / `sManageDeptEn` / `iParentId` / `iSortOrder` | 输出 VO 字段 | +| `bDeleted` | 过滤条件(=0) | + +依赖索引:`idx_module_name_zh(sModuleNameZh)` 命中 LIKE 前缀匹配;`idx_parent(iParentId)` 不直接命中(拼树用内存 Map),但保留供未来 join 用。 + +## 依赖的接口 + +无。 + +## 验收标准 + +### 单元测试(追加到 `ModuleServiceImplTest`) + +- [x] `listTree_emptyKeyword_invokesMapperWithEmptyString_returnsAssembledTree` — Mock `selectActiveByKeyword("")` 返回 5 行(root1, child1, child2, deepChild1, root2),ArgumentCaptor 验 mapper 入参为 `""`;返回结构符合树(root1.children 含 child1/child2;child1.children 含 deepChild1;root2.children 空) +- [x] `listTree_nullKeyword_treatedAsEmpty` — DTO `keyword=null`,效果同空串 +- [x] `listTree_blankKeyword_treatedAsEmpty` — keyword `" "` trim 后空 +- [x] `listTree_keywordTooLong_throws40001` — keyword 101 字符 → BizException(40001) +- [x] `listTree_returnsEmptyListWhenNoMatch` — Mock 返回空 list;service 返回 `List.of()` +- [x] `listTree_orphansBecomeRootsInForest` — Mock 返回 [child](其父 iParentId=99 不在结果集);child 出现在顶层 list +- [x] `listTree_keywordIsTrimmedBeforeQuery` — keyword `" 系统 "` → mapper 入参 `"系统"` + +### Mapper IT(追加到 `ModuleMapperIT`) + +- [x] `selectActiveByKeyword_filtersAndOrders` — 准备 5 行(含 1 个 bDeleted=1);查 `keyword=""` → 4 行(按 iSortOrder, iIncrement 升序);查 `keyword="系统"` → 仅命中 sModuleNameZh 含"系统"的活跃行;查 `keyword="不存在"` → 空 + +### 集成测试(追加到 `ModuleControllerIT`) + +- [x] `getEmptyKeyword_returnsCompleteTreeAsForest` — 直插 root + child;GET `/api/mod/modules` 带 JWT;`code=0`;`data` 是数组;至少含 root 节点且其 children 含 child +- [x] `getKeywordMatch_returnsForest` — 直插含"系统"的 alive 模块 + 不含"系统"的;GET `?keyword=系统`;只返回含"系统"的 +- [x] `getKeywordTooLong_returns40001` — `keyword` 101 字符 → `code=40001` +- [x] `getNoMatch_returnsEmptyArray` — `keyword=不存在的关键字XYZ`;`data` 是 `[]` +- [x] `getWithoutJwt_permitAllStub_returns200` — 无 JWT GET;`code=0` +- [x] `getTamperedJwt_returns20001` — Authorization 伪造 → `code=20001` + +### 工程验收 + +- [x] `cd backend && mvn -B test` 全绿(53 + MOD-004 新增 7(svc) + 1(mapperIT) + 6(controllerIT) = 67 用例) +- [x] 输出 VO 字段集严格匹配 docs/05 列表,不暴露敏感字段 +- [x] `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点保持(路径已 permitAll,无需新增)