--- req_id: REQ-MOD-003 date: 2026-05-06 spec_ref: docs/superpowers/specs/2026-05-06-REQ-MOD-003.md --- # REQ-MOD-003 模块删除 Implementation Plan > **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 实现 `DELETE /api/modules/{id}`:在校验子模块未删除引用的前提下,对目标做软删除(写 bDeleted/tDeletedDate/sDeletedBy)。 **Architecture:** 复用 REQ-MOD-001/002 已建立的体系。Service 先 selectById 校验目标存在 + 未删除(40421),再 selectCount 子模块未删除引用(40912),最后用 `mapper.update(entity, wrapper)` 加 `bDeleted = 0` 条件做并发安全的软删除(影响 0 行视为并发删除 → 40421)。返回精简 VO。 **Tech Stack:** 沿用 REQ-MOD-001/002(Spring Boot 3.2.5 + MyBatis-Plus 3.5.7 + JUnit 5 + Mockito)。 --- ## Schema 改动 无(软删除字段 `bDeleted` / `tDeletedDate` / `sDeletedBy` 在 V1 已建)。 ## 文件变更清单 - 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 `MOD_HAS_REFERENCES(40912, "存在子模块或外部业务引用,禁止删除")` - 创建: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleDeleteResultVO.java` — 精简 VO(iIncrement + bDeleted) - 修改: `backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java` — 追加 `delete(Integer id): ModuleDeleteResultVO` - 修改: `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java` — 实现 delete - 修改: `backend/src/main/java/com/xly/erp/module/mod/controller/ModuleController.java` — 追加 `@DeleteMapping("/{id}")` - 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 1 个错误码断言 - 修改: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 追加 6 个 delete 单测 - 修改: `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 追加 6 个 DELETE 集成测试 ## 任务步骤 ### Task 1: 错误码 + 精简 VO **Files:** - Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` - Create: `backend/src/main/java/com/xly/erp/module/mod/vo/ModuleDeleteResultVO.java` - Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` **API shape:** - `MOD_HAS_REFERENCES(40912, "存在子模块或外部业务引用,禁止删除")` - `ModuleDeleteResultVO` 字段:`Integer iIncrement` + `Boolean bDeleted`(带 `@Data` + 静态工厂 `of(Integer id, Boolean deleted)`) - [ ] **Step 1.1 写失败断言** - 在 `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 末尾追加: `assertThat(ErrorCode.MOD_HAS_REFERENCES.getCode()).isEqualTo(40912);` - 子会话 FAIL(枚举常量不存在) - [ ] **Step 1.2 追加枚举常量 + 创建 VO** - [ ] **Step 1.3 子会话验证 ApiResponseTest 全绿** - [ ] **Step 1.4 提交** - `git commit -m "feat(common): error code MOD_HAS_REFERENCES + delete VO REQ-MOD-003"` --- ### Task 2: ModuleService.delete — 业务逻辑(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` - Modify: `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java`(追加 6 个测试) **API shape:** - `interface ModuleService` 追加:`ModuleDeleteResultVO delete(Integer id)` - 实现步骤(写在 plan 锁定): 1. `target = moduleMapper.selectById(id)`;`target == null || target.bDeleted == true` → `BizException(MOD_NOT_FOUND)`(40421) 2. 子模块计数:`childCount = moduleMapper.selectCount(LambdaQueryWrapper.eq(iParentId, id).eq(bDeleted, false))` - `childCount > 0` → `BizException(MOD_HAS_REFERENCES)`(40912) 3. 构造**只含软删除三件套 + 主键**的更新 entity(避免触碰其他字段): ``` ModuleEntity patch = new ModuleEntity(); patch.setIIncrement(id); patch.setBDeleted(true); patch.setTDeletedDate(LocalDateTime.now()); patch.setSDeletedBy(null); // FieldStrategy 默认 NOT_NULL,会被 MP 跳过;这里靠 IGNORED 或显式 update wrapper 写入。 ``` **方式选择**:用 `moduleMapper.update(patch, new LambdaUpdateWrapper().eq(iIncrement, id).eq(bDeleted, false))`,确保只更新仍未删除的目标,且 `update` 影响行数为并发兜底信号。 4. `int affected = moduleMapper.update(...)`;`affected == 0` → `BizException(MOD_NOT_FOUND)`(视为目标在校验后被并发删除) 5. `return ModuleDeleteResultVO.of(id, true)` - 标 `@Transactional(rollbackFor = Exception.class)` - [ ] **Step 2.1 写失败测试(6 个)** - `delete_targetNotFound_throws40421`:`selectById` → null - `delete_targetAlreadyDeleted_throws40421`:target.bDeleted=true - `delete_hasUndeletedChildren_throws40912`:selectCount > 0 - `delete_leafModule_returnsResult`:selectCount=0、update 返回 1,断言 VO + 断言传给 update 的 entity 字段(bDeleted=true、tDeletedDate 非 null、iIncrement 正确) - `delete_softDeletedChildren_doesNotBlock`:selectCount=0(已删子不计入),update 返回 1,断言成功 - `delete_concurrentRace_throws40421`:selectById 返回未删除目标,selectCount=0,update 返回 0 → 抛 40421 - 测试方式:`@ExtendWith(MockitoExtension.class)` + `ArgumentCaptor` 捕获 `update` 实参;用 `any(Wrapper.class)` 占位 wrapper - 子会话: FAIL(方法不存在) - [ ] **Step 2.2 实现 delete** - 注意:`moduleMapper.update(entity, wrapper)` 在 BaseMapper 里有重载,可能与 `update(entity, T)` 冲突。预期签名 `int update(@Param("et") T entity, @Param("ew") Wrapper updateWrapper)`,调用为 `moduleMapper.update(patch, wrapper)`。Mockito stub 时用 `when(moduleMapper.update(any(ModuleEntity.class), any(Wrapper.class)))` 应能消歧;若仍 ambiguous,按 REQ-MOD-001 经验改用显式 cast `(ModuleEntity) any()` + `(Wrapper) any()`。 - [ ] **Step 2.3 子会话确认 ModuleServiceImplTest 全部绿** - 累计:6 (create) + 8 (update) + 6 (delete) = 20 - [ ] **Step 2.4 提交** - `git commit -m "feat(mod): delete module service REQ-MOD-003"` --- ### Task 3: ModuleController DELETE 端点 + 端到端 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`(追加 6 个集成用例) **API shape:** - 新方法: ``` @DeleteMapping("/{id}") public ApiResponse delete(@PathVariable Integer id) ``` - Javadoc:`REQ-MOD-003 模块删除 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('MOD:DELETE')")` - [ ] **Step 3.1 写失败测试(6 个)** - `delete_validLeaf_returns200WithBDeletedTrue`:mapper.insert 一条 → DELETE → 断言 200 + data.bDeleted=true + selectById 验证 DB 中 bDeleted=true / tDeletedDate 非 null - `delete_targetNotFound_returns40421`:DELETE /api/modules/999999 - `delete_targetAlreadyDeleted_returns40421`:mapper.insert 一条并立刻把 bDeleted 置 true,DELETE 返回 40421 - `delete_hasUndeletedChildren_returns40912`:parent + child(bDeleted=0),DELETE parent → 40912;selectById parent 验证 bDeleted 仍 false - `delete_softDeletedChildren_doesNotBlock_returns200`:先 DELETE child(应成功),再 DELETE parent → 200 - `delete_responseVOContainsOnlyIIncrementAndBDeleted`:断言 `$.data` 路径只有 iIncrement + bDeleted 两个字段(用 jsonPath `$.data.sProcedureName` 不存在) - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired ModuleMapper` 直接预置数据 - 子会话: FAIL(端点不存在) - [ ] **Step 3.2 实现 DELETE 端点** - 子会话: PASS - [ ] **Step 3.3 跑全量 backend 测试** - `cd backend && mvn -B test` - 期望累计 34 + 1(error code 断言扩展) + 6(service delete unit) + 6(controller delete IT) = 47 个,全绿 - [ ] **Step 3.4 提交** - `git commit -m "feat(mod): DELETE /api/modules/{id} controller REQ-MOD-003"` --- ## 提交计划 - `feat(common): error code MOD_HAS_REFERENCES + delete VO REQ-MOD-003`(覆盖 Task 1) - `feat(mod): delete module service REQ-MOD-003`(覆盖 Task 2) - `feat(mod): DELETE /api/modules/{id} controller REQ-MOD-003`(覆盖 Task 3)