Commit 230f61fb06b95ca69cfce50fa579d43482043457
1 parent
a6badd20
docs(mod): spec + plan REQ-MOD-004
Showing
2 changed files
with
278 additions
and
0 deletions
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,无需新增) |