--- req_id: REQ-MOD-002 date: 2026-05-06 spec_ref: docs/superpowers/specs/2026-05-06-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:** 实现 `PUT /api/modules/{id}` 接口:复用 REQ-MOD-001 已落地的 entity / mapper / common / config 体系,仅追加 ModuleUpdateDTO + Service.update + Controller 方法 + 错误码常量。 **Architecture:** Service 层先校验目标存在性(按 PK + bDeleted=0 查),再校验 iParentId 自引用 / 父不存在 / 父是后代环路(沿父链 walk up,深度上限 5),最后 `updateById` 落库。环路检查走"自下而上"路径,O(depth) 复杂度。 **Tech Stack:** 沿用 REQ-MOD-001(Spring Boot 3.2.5 + MyBatis-Plus 3.5.7 + JUnit 5 + Mockito)。 --- ## Schema 改动 无(`tModule` schema 已由 V1 提供,本 REQ 不加列)。 ## 文件变更清单 - 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 `MOD_NOT_FOUND(40421, "模块不存在或已删除")` 和 `MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代")` - 创建: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java` — PUT 入参(无 sProcedureName) - 修改: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `update(Integer id, ModuleUpdateDTO dto): ModuleVO` - 修改: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 `update` 方法(含父校验、环路检查、字段合并) - 修改: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@PutMapping("/{id}") ModuleVO update(...)` - 创建: `backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java` — DTO Bean Validation 单测 - 修改: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 `update_*` 系列单元测试(mock ModuleMapper) - 修改: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 `put_*` 系列集成测试 ## 任务步骤 ### Task 1: 追加错误码常量 **Files:** - Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` - Test: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java`(追加断言) **API shape:** - `MOD_NOT_FOUND(40421, "模块不存在或已删除")` - `MOD_PARENT_LOOP(40921, "iParentId 不能等于自身或后代")` - [ ] **Step 1.1 写失败断言** - 在 `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 末尾追加: - `assertThat(ErrorCode.MOD_NOT_FOUND.getCode()).isEqualTo(40421);` - `assertThat(ErrorCode.MOD_PARENT_LOOP.getCode()).isEqualTo(40921);` - 子会话确认 FAIL(编译错:枚举常量不存在) - [ ] **Step 1.2 追加枚举常量** - [ ] **Step 1.3 子会话确认 ApiResponseTest 全绿(5 个测试,第 5 个含新断言)** - [ ] **Step 1.4 提交** - `git commit -m "feat(common): error codes for module update REQ-MOD-002"` --- ### Task 2: ModuleUpdateDTO + 校验单测 **Files:** - Create: `backend/src/main/java/com/xly/erp/module/mod/dto/ModuleUpdateDTO.java` - Test: `backend/src/test/java/com/xly/erp/module/mod/dto/ModuleUpdateDTOValidationTest.java` **API shape:** - 字段(与 REQ-MOD-001 的 `ModuleCreateDTO` 相比剥除 `sProcedureName`;其余 7 个字段、注解、长度规则**完全一致**): - `@NotBlank @Pattern(...) String sDisplayType` - `@NotBlank @Size(max=50) String sModuleType` - `@NotBlank @Size(max=50) String sManageDeptEn` - `Boolean bShowPermission`(可空) - `@NotBlank @Size(max=100) String sModuleNameZh` - `Integer iParentId`(可空) - `@Min(0) Integer iSortOrder`(可空) - [ ] **Step 2.1 写失败测试(4 个)** - `ModuleUpdateDTOValidationTest#allValidFields_yieldsNoViolations` - `ModuleUpdateDTOValidationTest#blankRequiredFields_yieldsViolations`(5 个 @NotBlank) - `ModuleUpdateDTOValidationTest#invalidDisplayTypeEnum_yieldsViolation` - `ModuleUpdateDTOValidationTest#negativeSortOrder_yieldsViolation` - 子会话: FAIL(DTO 不存在) - [ ] **Step 2.2 实现 ModuleUpdateDTO** - 子会话: PASS - [ ] **Step 2.3 提交** - `git commit -m "feat(mod): module update DTO REQ-MOD-002"` --- ### Task 3: ModuleService.update — 业务逻辑(mock 单元测试) **Files:** - 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`(实现 + 私有 helper) - Test: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java`(追加 8 个测试) **API shape:** - `interface ModuleService` 追加:`ModuleVO update(Integer id, ModuleUpdateDTO dto)` - 实现步骤(写在 plan 锁定): 1. `target = moduleMapper.selectById(id)`;`target == null || target.bDeleted == true` → 抛 `BizException(MOD_NOT_FOUND)` 2. iParentId 校验(仅当 `dto.iParentId != null`): - 等于 `id` → `BizException(MOD_PARENT_LOOP)` - `parent = moduleMapper.selectById(dto.iParentId)`;`parent == null || parent.bDeleted` → `BizException(MOD_PARENT_NOT_FOUND)` - **环路检查**(沿父链 walk up,从 `dto.iParentId` 出发,最多 5 层): ``` cur = parent; depth = 1 while cur.iParentId != null && depth <= 5: if cur.iParentId == id: throw MOD_PARENT_LOOP cur = moduleMapper.selectById(cur.iParentId) if cur == null or cur.bDeleted: break // 链断在已删除节点,视为非环 depth += 1 ``` depth 超 5 仍未结束 → 视为深度违规,但 docs/03 业务注记说"深度上限 5"是预期不变量;本期不强制拦截更深,仅环路检查。 3. 字段合并到 `target`: - 必填字段(5 个 @NotBlank + sDisplayType):直接覆盖(dto 为 null 不可能,validation 已挡) - `bShowPermission`:dto 非 null 覆盖;null 保留 `target.bShowPermission` - `iParentId`:dto 中存在该 key 即覆盖(含 null 设根)。**实现细节**:DTO 用 Integer,区分"未传"和"显式传 null"在 Spring MVC 反序列化层不区分(都是 Integer null)。本 REQ 取语义"传 null = 设根","key 缺失 = 设根"等价。 - `iSortOrder`:dto 非 null 覆盖;null 保留 - `sProcedureName` / `iIncrement` / `tCreateDate` / `sId` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` / `bDeleted` / `tDeletedDate` / `sDeletedBy`:**完全不动**(沿用 target 上的原值) 4. `moduleMapper.updateById(target)` 5. `return ModuleVO.from(target)` - 标 `@Transactional(rollbackFor = Exception.class)` - [ ] **Step 3.1 写失败测试(8 个)** - `update_targetNotFound_throws40421`:`selectById(id)` → null - `update_targetSoftDeleted_throws40421`:`selectById(id).bDeleted=true` - `update_parentSelfReference_throws40921`:`dto.iParentId == id` - `update_parentNotFound_throws40411`:`selectById(parentId)` → null - `update_parentIsDescendant_throws40921`:构造 grandparent(id)→parent→child 链,dto.iParentId=child.id - `update_full_returnsVOWithUpdatedFields`:mock target 与 update 路径,断言传给 `updateById` 的 entity: - 已修改字段:sDisplayType / sModuleType / sManageDeptEn / sModuleNameZh / iParentId / iSortOrder / bShowPermission - 保持原值:sProcedureName / iIncrement / tCreateDate / sCreatedBy / bDeleted - `update_partialNullFields_keepsOriginalValues`:dto 中 bShowPermission=null + iSortOrder=null,断言落库 entity 的对应字段保留 target 原值 - `update_clearParent_setsParentToNull`:dto.iParentId=null,target.iParentId 原本是 7;断言 entity.iParentId == null - 测试方式:`@ExtendWith(MockitoExtension.class)` + `ArgumentCaptor` 捕获 `updateById` 实参 - 子会话: FAIL(方法不存在) - [ ] **Step 3.2 实现 ModuleService 接口签名 + ModuleServiceImpl.update** - [ ] **Step 3.3 子会话确认全部 mock 单测通过** - 全量 `mvn -B test -Dtest=ModuleServiceImplTest` 应绿(含 REQ-MOD-001 的 6 个 + 新增 8 个 = 14 个) - [ ] **Step 3.4 提交** - `git commit -m "feat(mod): update module service REQ-MOD-002"` --- ### Task 4: ModuleController PUT 端点 + 端到端 IT **Files:** - Modify: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` - Test: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java`(追加 8 个集成用例) **API shape:** - 类上保留 `@RequestMapping("/api/modules")` - 新方法: ``` @PutMapping("/{id}") public ApiResponse update(@PathVariable Integer id, @Valid @RequestBody ModuleUpdateDTO dto) ``` - 注释:`// REQ-MOD-002 模块修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:UPDATE')")` - [ ] **Step 4.1 写失败测试(8 个)** - `put_validUpdate_returns200`:先用 ModuleMapper 直接 insert 一条,再 PUT 改若干字段,断言响应 + DB 字段 - `put_setParentToNull_clearsParent`:先建 parent + child(iParentId=parent.id),PUT child 把 iParentId=null,断言 DB 中 child.iParentId IS NULL - `put_targetNotFound_returns40421`:`PUT /api/modules/999999` - `put_parentNotFound_returns40411`:`iParentId=999999` - `put_parentSelfRef_returns40921`:`PUT /api/modules/{id}` body `iParentId={id}` - `put_parentIsDescendant_returns40921`:建 grandparent→parent→child 三层;PUT grandparent 把 iParentId=child.id - `put_missingRequired_returns40010`:缺 sModuleNameZh - `put_ignoresProcedureNameField_doesNotChange`:body 含 `"sProcedureName":"hijack"`,断言 DB 中 sProcedureName 仍为原值 - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired ModuleMapper` 直接预置数据 - 子会话: FAIL(端点不存在) - [ ] **Step 4.2 实现 PUT 端点** - 子会话: PASS - [ ] **Step 4.3 跑全量 backend 测试** - `cd backend && mvn -B test` - 期望累计 22 + 4(DTO valid) + 8(service update) + 8(controller put) = 42 个,全绿 - [ ] **Step 4.4 提交** - `git commit -m "feat(mod): PUT /api/modules/{id} controller REQ-MOD-002"` --- ## 提交计划 - `feat(common): error codes for module update REQ-MOD-002`(覆盖 Task 1) - `feat(mod): module update DTO REQ-MOD-002`(覆盖 Task 2) - `feat(mod): update module service REQ-MOD-002`(覆盖 Task 3) - `feat(mod): PUT /api/modules/{id} controller REQ-MOD-002`(覆盖 Task 4)