diff --git a/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java b/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java index 28a8c35..b331c6b 100644 --- a/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java +++ b/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java @@ -146,19 +146,20 @@ public class ModuleServiceImpl implements ModuleService { throw new BizException(ErrorCode.MOD_HAS_REFERENCES); } - // 3. 软删除写入:仅触碰三件套,避免覆盖其他字段;并以 bDeleted=0 兜底并发 - ModuleEntity patch = new ModuleEntity(); - patch.setIIncrement(id); - patch.setBDeleted(true); - patch.setTDeletedDate(LocalDateTime.now()); - // sDeletedBy 留 null(FieldStrategy 默认 NOT_NULL 跳过;REQ-USR-004 后由登录上下文回填) - - int affected = moduleMapper.update(patch, - new LambdaUpdateWrapper() - .eq(ModuleEntity::getIIncrement, id) - .eq(ModuleEntity::getBDeleted, false)); + // 3. 软删除写入:用 LambdaUpdateWrapper.set 显式声明 SET 列,避免 entity 驱动 SET + // (iParentId 字段的 FieldStrategy.IGNORED 策略会让 entity-driven update 把 + // iParentId 写成 NULL,破坏父引用——这里用 wrapper.set 完全绕开 entity) + // sDeletedBy 暂写 null(REQ-USR-004 后由登录上下文回填)。 + // eq(bDeleted, false) 兜底并发:影响 0 行视为已被并发删除 → 40421 + LambdaUpdateWrapper uw = new LambdaUpdateWrapper() + .eq(ModuleEntity::getIIncrement, id) + .eq(ModuleEntity::getBDeleted, false) + .set(ModuleEntity::getBDeleted, true) + .set(ModuleEntity::getTDeletedDate, LocalDateTime.now()) + .set(ModuleEntity::getSDeletedBy, null); + + int affected = moduleMapper.update(null, uw); if (affected == 0) { - // 校验后被并发删除 throw new BizException(ErrorCode.MOD_NOT_FOUND); } diff --git a/backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java b/backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java index ca53dda..159146d 100644 --- a/backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java +++ b/backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java @@ -340,6 +340,47 @@ class ModuleControllerIT { } @Test + void delete_preservesOtherFields_onChildModule() throws Exception { + // 反例:非 root 子模块软删除后,iParentId / sProcedureName / sModuleNameZh / + // iSortOrder 等字段必须保持原值(spec § 业务规则 #4 + 验收 #1)。 + Integer parentId = insertExisting("sp_del_pres_p_" + System.nanoTime(), null); + String childProc = "sp_del_pres_c_" + System.nanoTime(); + // 用自定义字段值的 child(与 insertExisting 默认值不同),便于事后比对 + ModuleEntity child = new ModuleEntity(); + child.setSDisplayType("接口"); + child.setSProcedureName(childProc); + child.setSModuleType("AUDIT"); + child.setSManageDeptEn("OPS"); + child.setBShowPermission(true); + child.setSModuleNameZh("待保留中文名"); + child.setIParentId(parentId); + child.setISortOrder(7); + child.setBDeleted(false); + child.setTCreateDate(LocalDateTime.now()); + moduleMapper.insert(child); + Integer childId = child.getIIncrement(); + + mockMvc.perform(delete("/api/modules/" + childId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.bDeleted").value(true)); + + ModuleEntity reloaded = moduleMapper.selectById(childId); + // 软删除三件套生效 + assertThat(reloaded.getBDeleted()).isTrue(); + assertThat(reloaded.getTDeletedDate()).isNotNull(); + // 关键:其他列保持原值(曾经 iParentId.FieldStrategy.IGNORED 会清零这一列,本断言钉死该回归) + assertThat(reloaded.getIParentId()).isEqualTo(parentId); + assertThat(reloaded.getSProcedureName()).isEqualTo(childProc); + assertThat(reloaded.getSDisplayType()).isEqualTo("接口"); + assertThat(reloaded.getSModuleType()).isEqualTo("AUDIT"); + assertThat(reloaded.getSManageDeptEn()).isEqualTo("OPS"); + assertThat(reloaded.getBShowPermission()).isTrue(); + assertThat(reloaded.getSModuleNameZh()).isEqualTo("待保留中文名"); + assertThat(reloaded.getISortOrder()).isEqualTo(7); + } + + @Test void delete_responseVOContainsOnlyIIncrementAndBDeleted() throws Exception { Integer id = insertExisting("sp_del_vo_" + System.nanoTime(), null); diff --git a/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java b/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java index 3179596..9b5f005 100644 --- a/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java +++ b/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java @@ -24,6 +24,7 @@ import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -360,13 +361,9 @@ class ModuleServiceImplTest { assertThat(vo.getIIncrement()).isEqualTo(23); assertThat(vo.getBDeleted()).isTrue(); - - ArgumentCaptor cap = ArgumentCaptor.forClass(ModuleEntity.class); - verify(moduleMapper).update(cap.capture(), (Wrapper) any()); - ModuleEntity patch = cap.getValue(); - assertThat(patch.getIIncrement()).isEqualTo(23); - assertThat(patch.getBDeleted()).isTrue(); - assertThat(patch.getTDeletedDate()).isNotNull(); + // SET 列由 LambdaUpdateWrapper 声明,entity 第一参数为 null(避免 iParentId.IGNORED 策略副作用) + // 实际 SQL SET 子句的列覆盖由 IT (delete_preservesOtherFields_onChildModule) 验证 + verify(moduleMapper).update((ModuleEntity) isNull(), (Wrapper) any()); } @Test