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-tddexecutes 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_yieldsViolationModuleUpdateDTOValidationTest#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 锁定):
-
target = moduleMapper.selectById(id);target == null || target.bDeleted == true→ 抛BizException(MOD_NOT_FOUND) - 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 += 1depth 超 5 仍未结束 → 视为深度违规,但 docs/03 业务注记说"深度上限 5"是预期不变量;本期不强制拦截更深,仅环路检查。
- 等于
- 字段合并到
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 上的原值)
moduleMapper.updateById(target)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<ModuleEntity>捕获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<ModuleVO> 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}bodyiParentId={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)