--- 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,无需新增)