2026-05-06-REQ-MOD-002.md 10.5 KB

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):
      • 等于 idBizException(MOD_PARENT_LOOP)
      • parent = moduleMapper.selectById(dto.iParentId)parent == null || parent.bDeletedBizException(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_throws40421selectById(id) → null
    • update_targetSoftDeleted_throws40421selectById(id).bDeleted=true
    • update_parentSelfReference_throws40921dto.iParentId == id
    • update_parentNotFound_throws40411selectById(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_returns40421PUT /api/modules/999999
    • put_parentNotFound_returns40411iParentId=999999
    • put_parentSelfRef_returns40921PUT /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)