--- req_id: REQ-MOD-002 date: 2026-04-29 spec_ref: docs/superpowers/specs/2026-04-29-REQ-MOD-002.md --- # REQ-MOD-002 模块修改 Implementation Plan > **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在 MOD-001 已有工程基础上增量实现 `PUT /api/mod/modules/{id}`,更新 7 个可编辑字段,保留 `sProcedureName` / `sCreatedBy` / 标准列;含目标存在性、枚举、父链合法性(自指 / 不存在 / 环)四类校验。 **Architecture:** 复用 `ModuleService` / `ModuleServiceImpl` / `ModuleController` / `ModuleMapper` / `Module entity`,新增 `UpdateModuleDTO`、`ModuleService#update`、controller `@PutMapping`、`mapper.selectParentIdById`(轻量父链查询)。SecurityConfig 路径白名单从 `POST /api/mod/modules` 扩展为 `/api/mod/**` 以覆盖 MOD-002~004。环检测在 service 层用循环回溯,最大深度 50。 **Tech Stack:** Spring Boot 3.3.5 / MyBatis-Plus / Spring Security(已有);JUnit 5 + Mockito + TestRestTemplate(已有)。 --- ## Schema 改动 无(`tModule` schema 已满足;本 REQ 仅 UPDATE 操作)。 ## 文件变更清单 ### 新增 - `backend/src/main/java/com/xly/erp/module/mod/dto/UpdateModuleDTO.java` — 入参 DTO(无 `sProcedureName` 字段) ### 修改 - `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` — `requestMatchers(POST, "/api/mod/modules")` → `requestMatchers("/api/mod/**")`,stub 注释保持 - `backend/src/main/java/com/xly/erp/module/mod/mapper/ModuleMapper.java` — 追加 `Integer selectParentIdById(Integer id)` 注解 SELECT 方法(仅查 iParentId 列,不取整行) - `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `Integer update(Integer id, UpdateModuleDTO dto)` 方法 - `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 `update(...)`:目标存在性 → 枚举 → iParentId 三重校验 → mapper.updateById - `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@PutMapping("/modules/{id}")` 端点 - `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 7 个 update 用例 - `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 7 个 PUT IT 用例 - `backend/src/test/java/com/xly/erp/module/mod/mapper/ModuleMapperIT.java` — 追加 1 个 `selectParentIdById` 用例 ## 任务步骤 > 全局约束:每 commit 形如 `(mod): REQ-MOD-002`;测试派发到子会话执行;现有 26 用例全程保持绿。 ### Task 1: SecurityConfig 路径白名单扩范围 **Files:** - Modify: `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java:25` **API shape:** - 改一行:`requestMatchers(HttpMethod.POST, "/api/mod/modules").permitAll()` → `requestMatchers("/api/mod/**").permitAll()` - 注释保留 `// REQ-MOD-001 stub: see USR-004 follow-up`(语义不变,覆盖范围扩大) - [ ] **Step 1: 修改 SecurityConfig** - 单行 Edit;不动任何其他类 - [ ] **Step 2: 子会话验证 PASS** - 命令:`cd backend && mvn -B test -Dtest=ModuleControllerIT` - 期望:现有 7 用例全绿(permitAll 范围扩大不收紧已有路径) - [ ] **Step 3: Commit** - `git commit -m "refactor(mod): widen permitAll stub to /api/mod/** REQ-MOD-002"` ### Task 2: ModuleMapper 追加父 ID 查询 **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 iParentId FROM tModule WHERE iIncrement = #{id} AND bDeleted = 0")` `Integer selectParentIdById(@Param("id") Integer id)` - 命中行的 `iParentId` 可能为 NULL(根模块)→ 返回 null - 未命中(不存在 / 已软删)→ 返回 null(与"根模块"在调用方语义不同,需要调用方明确校验存在性) - [ ] **Step 1: 写失败测试 `ModuleMapperIT#selectParentIdById_returnsNullForRootOrMissing_andValueForChild`** - 准备 3 行:root(iParentId=NULL)、child(iParentId=root)、deleted(iParentId=root, bDeleted=1) - 断言:`selectParentIdById(root.id) == null`;`selectParentIdById(child.id) == root.id`;`selectParentIdById(deleted.id) == null`;`selectParentIdById(99999999) == null` - [ ] **Step 2: 实现 mapper 方法** - [ ] **Step 3: 子会话验证 PASS** - 命令:`cd backend && mvn -B test -Dtest=ModuleMapperIT` - [ ] **Step 4: Commit** - `git commit -m "feat(mod): mapper#selectParentIdById for cycle check REQ-MOD-002"` ### Task 3: UpdateModuleDTO + Service.update 合法路径 **Files:** - Create: `backend/src/main/java/com/xly/erp/module/mod/dto/UpdateModuleDTO.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:** - `UpdateModuleDTO` 字段(带 `@JsonProperty` 锁定 JSON 名 + Bean Validation): - `@NotBlank String sDisplayType` - `@NotBlank @Size(max=50) String sModuleType` - `@NotBlank @Size(max=50) String sManageDeptEn` - `Boolean bShowPermission`(可空) - `@NotBlank @Size(max=100) String sModuleNameZh` - `Integer iParentId`(可空) - `Integer iSortOrder`(可空) - **不含 `sProcedureName`** - `ModuleService#update(Integer id, UpdateModuleDTO dto) : Integer` - `ModuleServiceImpl#update`: 1. `Module original = moduleMapper.selectById(id)`;若 null 或 `original.getBDeleted()==true` → `BizException(40400, "模块不存在或已删除")` 2. 枚举校验同 MOD-001 → 40010 3. `iParentId` 校验(4 类)(在本 task 不全实现,仅留 hook,详见 Task 4):本 task 暂只对 null/合法路径走通;非 null 时调 `existsActiveById` 但暂不做自指/环检测,留 Task 4 加。 4. 构造 `Module entity`:仅 set `iIncrement` 和可改 7 字段(其余 null);`bShowPermission` null → false 5. `moduleMapper.updateById(entity)` 返回 `id` - [ ] **Step 1: 写失败测试** - 在 `ModuleServiceImplTest` 追加 3 用例: - `updateWithValidDto_invokesUpdateById_withEditableFieldsOnly` - `updateWithTargetNotFound_throws40400` - `updateWithBShowPermissionNull_setsFalseInEntity` - mock 准备:`moduleMapper.selectById(id)` 返回 stub Module(含原 sProcedureName / sCreatedBy);`moduleMapper.updateById(any(Module.class))` 返回 1 - ArgumentCaptor 抓传给 `updateById` 的 entity,断言:`iIncrement` 是路径 id;`sProcedureName == null`;`sCreatedBy == null`;`tCreateDate == null`;`sBrandsId == null`;`sSubsidiaryId == null`;可改字段被透传 - 子会话先跑 → FAIL(方法不存在) - [ ] **Step 2: 实现 DTO + Service** - DTO 与 CreateModuleDTO 平行结构;Service 实现仅覆盖 Task 3 三个用例所需逻辑 - [ ] **Step 3: 子会话验证 PASS** - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest` - 期望:6 (MOD-001) + 3 (本 task) = 9 用例全绿 - [ ] **Step 4: Commit** - `git commit -m "feat(mod): module update dto + service happy path REQ-MOD-002"` ### Task 4: Service 校验分支(枚举 + iParentId 三类) **Files:** - 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:** 不变(仅补 update 方法内部校验逻辑) **校验顺序**(service 实现): 1. 目标存在 / 枚举(已在 Task 3 实现) 2. 若 `iParentId != null`: a. `iParentId.equals(id)` → `BizException(40021, "父模块不能指向自身")` b. `!moduleMapper.existsActiveById(iParentId)` → `BizException(40021, "父模块不存在或已删除")` c. 环检测:`Integer cur = iParentId; for (int depth = 0; cur != null && depth < 50; depth++) { if (cur.equals(id)) throw BizException(40021,"父模块链构成环路"); cur = moduleMapper.selectParentIdById(cur); }`;若 depth 达到 50 → `BizException(40021, "父模块链超过最大层级")` - [ ] **Step 1: 写失败测试** - 在 `ModuleServiceImplTest` 追加 4 用例: - `updateWithInvalidDisplayType_throws40010` - `updateWithSelfParentId_throws40021`(msg contains "自身") - `updateWithMissingParent_throws40021`(msg contains "父模块不存在") - `updateWithCyclicParent_throws40021`(msg contains "环路";mock 编排:`existsActiveById(parent)=true`;`selectParentIdById(parent) = id`) - 子会话先跑 → 4 用例 FAIL - [ ] **Step 2: 实现校验分支** - 严格按上述 a/b/c 顺序,**a 在 b 之前**(自指应当先于存在性,否则用户传自身 id 在父表里又恰好不存在时报错信息会误导) - [ ] **Step 3: 子会话验证 PASS** - 命令:`cd backend && mvn -B test -Dtest=ModuleServiceImplTest` - 期望:6 + 3 + 4 = 13 用例全绿 - [ ] **Step 4: Commit** - `git commit -m "feat(mod): module update parent validation REQ-MOD-002"` ### Task 5: ModuleController PUT + 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:** - `@PutMapping("/modules/{id}") public Result> update(@PathVariable Integer id, @Valid @RequestBody UpdateModuleDTO dto)` - 返回 `Result.ok(Map.of("iIncrement", moduleService.update(id, dto)))` - [ ] **Step 1: 写失败测试 `ModuleControllerIT#putValidBody_with_jwt_returns200_andUpdatesEditableFields`** - 步骤:① JdbcTemplate 直插一行原始数据(含 sProcedureName="sp_test_orig"、sCreatedBy="ORIG_USER"、sBrandsId="XLY"、sModuleNameZh="原名");② PUT body 改 sModuleNameZh="新名"、sDisplayType="前端业务";带 JWT="ADMIN001";③ 期望 `code=0`,`data.iIncrement` 等于 ① 的 id;④ JdbcTemplate 查行:`sModuleNameZh="新名"`、`sDisplayType="前端业务"`、`sProcedureName="sp_test_orig"`(保留)、`sCreatedBy="ORIG_USER"`(保留) - [ ] **Step 2: 实现 controller PUT** - 与 POST 同结构 - [ ] **Step 3: 子会话验证 PASS** - 命令:`cd backend && mvn -B test -Dtest='ModuleControllerIT#putValidBody_with_jwt_returns200_andUpdatesEditableFields'` - [ ] **Step 4: Commit** - `git commit -m "feat(mod): PUT /api/mod/modules/{id} controller REQ-MOD-002"` ### Task 6: IT 异常路径补全 + 全量回归 **Files:** - Modify: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` **API shape:** 不新增(覆盖 PUT 接口 6 条异常路径) - [ ] **Step 1: 在 IT 中追加 6 个用例** - `putNonExistentId_returns40400` — PUT `/api/mod/modules/99999999` body 合法 → `code=40400` - `putInvalidDisplayType_returns40010` — `sDisplayType="火星"` → `code=40010` - `putSelfParent_returns40021` — body `iParentId == path id` → `code=40021` - `putCyclicParent_returns40021` — 准备:先插 root,再插 child(parent=root);PUT root 把 `iParentId=child.id` → `code=40021` - `putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy` — 先插原行(sCreatedBy="ORIG_USER"),无 token PUT;期望 `code=0`,DB 中 sCreatedBy 仍为 "ORIG_USER"(不被覆盖为 STUB_ADMIN) - `putTamperedJwt_returns20001` — Authorization 头 `Bearer not.a.real.jwt` → `code=20001` - 6 个用例先跑 → 期望 FAIL(部分分支需 Task 4/5 已经覆盖;这里主要补 IT 端到端) - [ ] **Step 2: 让测试通过** - service / controller / config 已实现;本 task 主要排查 RestTemplate 行为(4xx 是否抛、URL 拼接、JSON parse);不应当为让测试通过新增业务分支 - [ ] **Step 3: 子会话跑全量回归** - 命令:`cd backend && mvn -B test` - 期望:MOD-001 26 用例 + MOD-002 新增 1(mapperIT) + 7(serviceTest) + 7(controllerIT) = 41 用例全绿 - [ ] **Step 4: Commit** - `git commit -m "test(mod): module update integration coverage REQ-MOD-002"` ## 提交计划 | commit | 覆盖 | |---|---| | `refactor(mod): widen permitAll stub to /api/mod/** REQ-MOD-002` | Task 1 | | `feat(mod): mapper#selectParentIdById for cycle check REQ-MOD-002` | Task 2 | | `feat(mod): module update dto + service happy path REQ-MOD-002` | Task 3 | | `feat(mod): module update parent validation REQ-MOD-002` | Task 4 | | `feat(mod): PUT /api/mod/modules/{id} controller REQ-MOD-002` | Task 5 | | `test(mod): module update integration coverage REQ-MOD-002` | Task 6 |