ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/FleetUnbindFallbackFactory.java
New file @@ -0,0 +1,29 @@ package com.ruoyi.account.api.factory; import com.ruoyi.account.api.feignClient.FleetUnbindClient; import com.ruoyi.common.core.domain.R; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cloud.openfeign.FallbackFactory; import org.springframework.stereotype.Component; import java.util.List; /** * 车队解绑服务降级处理 */ @Component public class FleetUnbindFallbackFactory implements FallbackFactory<FleetUnbindClient> { private static final Logger log = LoggerFactory.getLogger(FleetUnbindFallbackFactory.class); @Override public FleetUnbindClient create(Throwable throwable) { log.error("车队解绑服务调用失败:{}", throwable.getMessage()); return new FleetUnbindClient() { @Override public R<Integer> unbindByFleetIds(List<Integer> fleetIds) { return R.fail("车队关联数据解绑失败:" + throwable.getMessage()); } }; } } ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/FleetUnbindClient.java
New file @@ -0,0 +1,28 @@ package com.ruoyi.account.api.feignClient; import com.ruoyi.account.api.factory.FleetUnbindFallbackFactory; import com.ruoyi.common.core.constant.ServiceNameConstants; import com.ruoyi.common.core.domain.R; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import java.util.List; /** * 车队解绑内部服务(车队删除时批量清空 VIN码/充电卡/白名单的车队归属) * <p>仅限内部服务调用(other 服务删除车队链路),禁止走管理后台权限体系。</p> */ @FeignClient(contextId = "FleetUnbindClient", value = ServiceNameConstants.ACCOUNT_SERVICE, fallbackFactory = FleetUnbindFallbackFactory.class) public interface FleetUnbindClient { /** * 按车队ID批量解绑(单事务内清空 t_vin_management / t_charging_card / t_app_user_whitelist 的 fleet_id) * * @param fleetIds 车队ID列表(自动去重、过滤空值) * @return 实际解绑的记录总数(三表之和,0 表示无挂靠数据) */ @PostMapping("/fleet-unbind/inner/unbind") R<Integer> unbindByFleetIds(@RequestBody List<Integer> fleetIds); } ruoyi-api/ruoyi-api-account/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -9,4 +9,5 @@ com.ruoyi.account.api.factory.JianGuanAccountFallbackFactory com.ruoyi.account.api.factory.VinManagementFallbackFactory com.ruoyi.account.api.factory.ChargingCardFallbackFactory com.ruoyi.account.api.factory.CompanyAllocateRecordFallbackFactory com.ruoyi.account.api.factory.CompanyAllocateRecordFallbackFactory com.ruoyi.account.api.factory.FleetUnbindFallbackFactory ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/FleetUnbindController.java
New file @@ -0,0 +1,37 @@ package com.ruoyi.account.controller; import com.ruoyi.account.service.FleetUnbindService; import com.ruoyi.common.core.domain.R; import io.swagger.annotations.ApiOperation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; /** * <p> * 车队解绑内部接口 前端控制器(other 服务删除车队链路使用,无管理端权限) * </p> * * @author mxcd * @since 2026-09-29 */ @RestController @RequestMapping("/fleet-unbind") public class FleetUnbindController { @Autowired private FleetUnbindService fleetUnbindService; /** * 内部接口:按车队ID批量解绑 VIN码/充电卡/白名单的车队归属(单事务,返回实际解绑数量) */ @ApiOperation(value = "内部接口:按车队批量解绑(清空三表 fleet_id,返回实际解绑数量)") @PostMapping("/inner/unbind") public R<Integer> unbindByFleetIds(@RequestBody List<Integer> fleetIds) { return R.ok(fleetUnbindService.unbindByFleetIds(fleetIds)); } } ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/FleetUnbindService.java
New file @@ -0,0 +1,21 @@ package com.ruoyi.account.service; import java.util.List; /** * 车队解绑服务(车队删除时批量清空 VIN码/充电卡/白名单的车队归属) * * @author mxcd * @since 2026-09-29 */ public interface FleetUnbindService { /** * 按车队ID批量解绑:单事务内清空 t_vin_management / t_charging_card / t_app_user_whitelist 三表的 fleet_id, * 仅清空车队归属,公司、余额、锁定额度、状态等不受影响;逻辑删除数据自动过滤 * * @param fleetIds 车队ID列表(自动去重、过滤空值,空列表直接返回 0) * @return 实际解绑的记录总数(三表影响行数之和) */ int unbindByFleetIds(List<Integer> fleetIds); } ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/FleetUnbindServiceImpl.java
New file @@ -0,0 +1,74 @@ package com.ruoyi.account.service.impl; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.ruoyi.account.api.model.TAppUserWhitelist; import com.ruoyi.account.api.model.TChargingCard; import com.ruoyi.account.api.model.TVinManagement; import com.ruoyi.account.mapper.TAppUserWhitelistMapper; import com.ruoyi.account.mapper.TChargingCardMapper; import com.ruoyi.account.mapper.TVinManagementMapper; import com.ruoyi.account.service.FleetUnbindService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; /** * <p> * 车队解绑服务实现(内部链路:other 服务删除车队时触发,无管理端用户上下文) * </p> * * @author mxcd * @since 2026-09-29 */ @Service public class FleetUnbindServiceImpl implements FleetUnbindService { @Resource private TVinManagementMapper vinManagementMapper; @Resource private TChargingCardMapper chargingCardMapper; @Resource private TAppUserWhitelistMapper appUserWhitelistMapper; @Override @Transactional(rollbackFor = Exception.class) public int unbindByFleetIds(List<Integer> fleetIds) { List<Integer> distinct = normalize(fleetIds); if (distinct.isEmpty()) { return 0; } LocalDateTime now = LocalDateTime.now(); int total = 0; // 三表均在单事务内执行,任一失败整体回滚(调用方按失败中止车队删除,两侧数据保持一致) // 显式 set null 清空车队归属(MyBatis-Plus 字段策略会忽略实体 null,需走 UpdateWrapper) total += vinManagementMapper.update(null, new LambdaUpdateWrapper<TVinManagement>() .in(TVinManagement::getFleetId, distinct) .set(TVinManagement::getFleetId, null) .set(TVinManagement::getUpdateTime, now)); total += chargingCardMapper.update(null, new LambdaUpdateWrapper<TChargingCard>() .in(TChargingCard::getFleetId, distinct) .set(TChargingCard::getFleetId, null) .set(TChargingCard::getUpdateTime, now)); // 白名单实体无 updateTime 映射字段(表有 update_time 列),沿用现有解绑实现走 setSql total += appUserWhitelistMapper.update(null, new LambdaUpdateWrapper<TAppUserWhitelist>() .in(TAppUserWhitelist::getFleetId, distinct) .set(TAppUserWhitelist::getFleetId, null) .setSql("update_time = now()")); return total; } private List<Integer> normalize(List<Integer> fleetIds) { if (fleetIds == null || fleetIds.isEmpty()) { return Collections.emptyList(); } return fleetIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList()); } } ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/FleetUnbindServiceImplTest.java
New file @@ -0,0 +1,157 @@ package com.ruoyi.account.service.impl; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.ruoyi.account.api.model.TAppUserWhitelist; import com.ruoyi.account.api.model.TChargingCard; import com.ruoyi.account.api.model.TVinManagement; import com.ruoyi.account.mapper.TAppUserWhitelistMapper; import com.ruoyi.account.mapper.TChargingCardMapper; import com.ruoyi.account.mapper.TVinManagementMapper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import java.util.Arrays; import java.util.Collections; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; 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.verifyNoInteractions; import static org.mockito.Mockito.when; /** * 车队解绑聚合服务单测(覆盖三表清空 fleet_id、去重过滤与空入参短路)。 * Mapper 以 Mock 替代,不依赖 Spring 容器与数据库。 */ @ExtendWith(MockitoExtension.class) class FleetUnbindServiceImplTest { @Mock private TVinManagementMapper vinMapper; @Mock private TChargingCardMapper cardMapper; @Mock private TAppUserWhitelistMapper whitelistMapper; private FleetUnbindServiceImpl service; @BeforeAll static void initTableInfo() { // LambdaUpdateWrapper 的 lambda 列名解析依赖 TableInfo 元数据, // 单元测试不启动 Spring,需要手动初始化三个实体的 TableInfo,否则 NPE TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), TVinManagement.class); TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), TChargingCard.class); TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), TAppUserWhitelist.class); } @BeforeEach void setUp() { service = new FleetUnbindServiceImpl(); ReflectionTestUtils.setField(service, "vinManagementMapper", vinMapper); ReflectionTestUtils.setField(service, "chargingCardMapper", cardMapper); ReflectionTestUtils.setField(service, "appUserWhitelistMapper", whitelistMapper); } @Test void unbindsAllThreeTablesAndSumsAffectedRows() { when(vinMapper.update(isNull(), any())).thenReturn(2); when(cardMapper.update(isNull(), any())).thenReturn(1); when(whitelistMapper.update(isNull(), any())).thenReturn(3); int result = service.unbindByFleetIds(Arrays.asList(4, 5)); assertEquals(6, result); verify(vinMapper).update(isNull(), any()); verify(cardMapper).update(isNull(), any()); verify(whitelistMapper).update(isNull(), any()); } @Test @SuppressWarnings({"unchecked", "rawtypes"}) void clearsFleetIdWithUpdateWrapperInAllTables() { when(vinMapper.update(isNull(), any())).thenReturn(0); when(cardMapper.update(isNull(), any())).thenReturn(0); when(whitelistMapper.update(isNull(), any())).thenReturn(0); service.unbindByFleetIds(Collections.singletonList(9)); // 三表均显式 set fleet_id = null(字段策略会忽略实体 null,必须走 UpdateWrapper set) ArgumentCaptor<Wrapper> vinCaptor = ArgumentCaptor.forClass(Wrapper.class); verify(vinMapper).update(isNull(), vinCaptor.capture()); LambdaUpdateWrapper<TVinManagement> vinWrapper = (LambdaUpdateWrapper<TVinManagement>) vinCaptor.getValue(); assertTrue(vinWrapper.getSqlSet().contains("fleet_id="), vinWrapper.getSqlSet()); assertTrue(vinWrapper.getSqlSegment().contains("IN"), vinWrapper.getSqlSegment()); ArgumentCaptor<Wrapper> cardCaptor = ArgumentCaptor.forClass(Wrapper.class); verify(cardMapper).update(isNull(), cardCaptor.capture()); LambdaUpdateWrapper<TChargingCard> cardWrapper = (LambdaUpdateWrapper<TChargingCard>) cardCaptor.getValue(); assertTrue(cardWrapper.getSqlSet().contains("fleet_id="), cardWrapper.getSqlSet()); assertTrue(cardWrapper.getSqlSegment().contains("IN"), cardWrapper.getSqlSegment()); ArgumentCaptor<Wrapper> whitelistCaptor = ArgumentCaptor.forClass(Wrapper.class); verify(whitelistMapper).update(isNull(), whitelistCaptor.capture()); LambdaUpdateWrapper<TAppUserWhitelist> whitelistWrapper = (LambdaUpdateWrapper<TAppUserWhitelist>) whitelistCaptor.getValue(); assertTrue(whitelistWrapper.getSqlSet().contains("fleet_id="), whitelistWrapper.getSqlSet()); assertTrue(whitelistWrapper.getSqlSegment().contains("IN"), whitelistWrapper.getSqlSegment()); // 白名单实体无 updateTime 映射,沿用 setSql 方式更新时间 assertTrue(whitelistWrapper.getSqlSet().contains("update_time = now()"), whitelistWrapper.getSqlSet()); } @Test void deduplicatesAndFiltersNullFleetIds() { when(vinMapper.update(isNull(), any())).thenReturn(0); when(cardMapper.update(isNull(), any())).thenReturn(0); when(whitelistMapper.update(isNull(), any())).thenReturn(0); service.unbindByFleetIds(Arrays.asList(4, null, 4, 5)); // 去重过滤后仅 4、5 两个绑定值参与 IN 条件 @SuppressWarnings({"unchecked", "rawtypes"}) ArgumentCaptor<Wrapper> captor = ArgumentCaptor.forClass(Wrapper.class); verify(vinMapper).update(isNull(), captor.capture()); LambdaUpdateWrapper<TVinManagement> wrapper = (LambdaUpdateWrapper<TVinManagement>) captor.getValue(); assertEquals(2, wrapper.getParamNameValuePairs().size()); } @Test void returnsZeroWithoutTouchingTablesForNullOrEmptyInput() { assertEquals(0, service.unbindByFleetIds(null)); assertEquals(0, service.unbindByFleetIds(Collections.emptyList())); assertEquals(0, service.unbindByFleetIds(Arrays.asList(null, null))); verifyNoInteractions(vinMapper, cardMapper, whitelistMapper); } @Test void returnsZeroWhenNoBoundRecords() { when(vinMapper.update(isNull(), any())).thenReturn(0); when(cardMapper.update(isNull(), any())).thenReturn(0); when(whitelistMapper.update(isNull(), any())).thenReturn(0); List<Integer> fleetIds = Collections.singletonList(7); int result = service.unbindByFleetIds(fleetIds); assertEquals(0, result); // 无挂靠数据时仍对三表各执行一次解绑语句(0 行受影响),保证幂等清理 verify(vinMapper).update(isNull(), any()); verify(cardMapper).update(isNull(), any()); verify(whitelistMapper).update(isNull(), any()); verify(vinMapper, never()).selectList(any()); } } ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java
@@ -911,7 +911,7 @@ // 避免无快照订单在结算时按 0 价计费):关单(状态-1)并释放锁定额度 if (!saveStrategySnapshot(order.getId(), strategyId)) { log.error("[VD建单] 计费策略快照保存失败,终止本次启动 code={} strategyId={}", order.getCode(), strategyId); closeTimeoutStartingOrder(order); closeStartingOrderWithoutGunReset(order); return DeviceStartChargingVO.fail(null); } log.info("[VD建单] VIN码充电订单创建成功 code={} vinId={} gun={} accountMode={} lockAmount={} maxEndTime={}", @@ -2017,6 +2017,28 @@ log.warn("[VD补偿] 启动超时关单 code={} vinId={} gunId={}", order.getCode(), order.getVinId(), order.getChargingGunId()); } /** * 严格模式启动失败关单:仅关闭订单并释放锁定额度,不复位充电枪 * (枪可能处于离线/故障等业务态,避免误复位为可用;订单已关单,不会残留占用) */ private void closeStartingOrderWithoutGunReset(TVinChargingOrder order) { TVinChargingOrder update = new TVinChargingOrder(); update.setStatus(ORDER_STATUS_START_FAILED); update.setEndMode(0); update.setEndTime(LocalDateTime.now()); boolean closed = update(update, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<TVinChargingOrder>() .eq(TVinChargingOrder::getId, order.getId()) .eq(TVinChargingOrder::getStatus, ORDER_STATUS_STARTING)); if (!closed) { log.info("[VD建单] 严格模式关单跳过,订单状态已变化 code={}", order.getCode()); return; } // 释放申请时锁定的账户额度(未产生消费) unlockVinChargeContextQuietly(order, "启动关单"); pushRegulatoryAsync(order, false, false); log.warn("[VD建单] 严格模式启动失败关单(不复位充电枪) code={} vinId={} gunId={}", order.getCode(), order.getVinId(), order.getChargingGunId()); } @Override public void compensationSettle(TVinChargingOrder order) { // 无设备交易记录:以订单累计电量构造全平段账单,复用统一结算入口 ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/controller/TFleetController.java
@@ -138,7 +138,7 @@ if (idList.isEmpty()) { return AjaxResult.error("车队ID不能为空"); } fleetService.removeByIds(idList); fleetService.deleteFleets(idList); return AjaxResult.success(); } ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TFleetService.java
@@ -54,4 +54,13 @@ */ void fillAggregates(List<TFleet> fleets); /** * 删除车队(先远程解绑该车队下的 VIN码/充电卡/白名单车队归属,解绑成功后再删除车队)。 * 解绑失败时抛出异常并中止删除,车队与绑定关系均保持一致。 * * @param ids 车队ID列表(自动去重、过滤空值) * @return 是否删除成功 */ boolean deleteFleets(List<Integer> ids); } ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TFleetServiceImpl.java
@@ -3,6 +3,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.ruoyi.account.api.feignClient.FleetUnbindClient; import com.ruoyi.account.api.feignClient.VinManagementClient; import com.ruoyi.account.api.dto.VinManagementQueryDTO; import com.ruoyi.account.api.model.TVinManagement; @@ -62,6 +63,9 @@ @Resource private TChargeAuthorizationMapper chargeAuthorizationMapper; @Resource private FleetUnbindClient fleetUnbindClient; @Override public Page<TVinManagement> selectVinPage(Integer fleetId, Integer current, Integer size) { @@ -276,4 +280,31 @@ throw new ServiceException((row > 0 ? "第" + row + "行" : "") + "启用状态错误"); } @Override public boolean deleteFleets(List<Integer> ids) { if (ids == null || ids.isEmpty()) { throw new ServiceException("请选择需要删除的车队"); } List<Integer> distinct = ids.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList()); if (distinct.isEmpty()) { throw new ServiceException("请选择需要删除的车队"); } if (listByIds(distinct).size() != distinct.size()) { throw new ServiceException("部分车队不存在或已删除"); } // 先解绑该车队下的 VIN码/充电卡/白名单(跨服务无分布式事务:解绑失败即中止删除,两侧数据保持一致; // 解绑成功后删除失败仅剩车队未删,可重试删除,不会产生已删车队挂靠的脏引用) try { R<Integer> response = fleetUnbindClient.unbindByFleetIds(distinct); if (response == null || !R.isSuccess(response)) { throw new ServiceException("车队关联数据解绑失败:" + (response == null ? "服务无响应" : response.getMsg())); } } catch (ServiceException ex) { throw ex; } catch (Exception ex) { throw new ServiceException("车队关联数据解绑失败,请稍后重试"); } return removeByIds(distinct); } } ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TFleetServiceImplTest.java
@@ -2,6 +2,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.account.api.dto.VinManagementQueryDTO; import com.ruoyi.account.api.feignClient.FleetUnbindClient; import com.ruoyi.account.api.feignClient.VinManagementClient; import com.ruoyi.account.api.model.TVinManagement; import com.ruoyi.common.core.domain.R; @@ -21,6 +22,7 @@ import java.math.BigDecimal; import java.util.Arrays; import java.util.Collections; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -280,6 +282,103 @@ return service; } @Test void deleteFleetsUnbindsThenRemoves() { FleetUnbindClient unbindClient = Mockito.mock(FleetUnbindClient.class); Mockito.when(unbindClient.unbindByFleetIds(Mockito.anyList())).thenReturn(R.ok(3)); TFleetServiceImpl service = Mockito.spy(new TFleetServiceImpl()); ReflectionTestUtils.setField(service, "fleetUnbindClient", unbindClient); Mockito.doReturn(Arrays.asList(fleet(1), fleet(2))).when(service).listByIds(Mockito.anyList()); Mockito.doReturn(true).when(service).removeByIds(Mockito.anyList()); assertTrue(service.deleteFleets(Arrays.asList(1, 2))); @SuppressWarnings("unchecked") ArgumentCaptor<List<Integer>> idsCaptor = ArgumentCaptor.forClass(List.class); Mockito.verify(unbindClient).unbindByFleetIds(idsCaptor.capture()); assertEquals(Arrays.asList(1, 2), idsCaptor.getValue()); Mockito.verify(service).removeByIds(Mockito.anyList()); } @Test void deleteFleetsDeduplicatesAndFiltersNullIds() { FleetUnbindClient unbindClient = Mockito.mock(FleetUnbindClient.class); Mockito.when(unbindClient.unbindByFleetIds(Mockito.anyList())).thenReturn(R.ok(0)); TFleetServiceImpl service = Mockito.spy(new TFleetServiceImpl()); ReflectionTestUtils.setField(service, "fleetUnbindClient", unbindClient); Mockito.doReturn(Collections.singletonList(fleet(1))).when(service).listByIds(Mockito.anyList()); Mockito.doReturn(true).when(service).removeByIds(Mockito.anyList()); assertTrue(service.deleteFleets(Arrays.asList(1, null, 1))); @SuppressWarnings("unchecked") ArgumentCaptor<List<Integer>> idsCaptor = ArgumentCaptor.forClass(List.class); Mockito.verify(unbindClient).unbindByFleetIds(idsCaptor.capture()); assertEquals(Collections.singletonList(1), idsCaptor.getValue()); Mockito.verify(service).removeByIds(Collections.singletonList(1)); } @Test void deleteFleetsRejectsNullOrEmptyIds() { TFleetServiceImpl service = Mockito.spy(new TFleetServiceImpl()); ReflectionTestUtils.setField(service, "fleetUnbindClient", Mockito.mock(FleetUnbindClient.class)); ServiceException nullEx = assertThrows(ServiceException.class, () -> service.deleteFleets(null)); assertTrue(nullEx.getMessage().contains("请选择需要删除的车队"), nullEx.getMessage()); ServiceException emptyEx = assertThrows(ServiceException.class, () -> service.deleteFleets(Arrays.asList(null, null))); assertTrue(emptyEx.getMessage().contains("请选择需要删除的车队"), emptyEx.getMessage()); } @Test void deleteFleetsRejectsMissingFleetWithoutUnbinding() { FleetUnbindClient unbindClient = Mockito.mock(FleetUnbindClient.class); TFleetServiceImpl service = Mockito.spy(new TFleetServiceImpl()); ReflectionTestUtils.setField(service, "fleetUnbindClient", unbindClient); Mockito.doReturn(Collections.emptyList()).when(service).listByIds(Mockito.anyList()); ServiceException ex = assertThrows(ServiceException.class, () -> service.deleteFleets(Collections.singletonList(9))); assertTrue(ex.getMessage().contains("部分车队不存在"), ex.getMessage()); Mockito.verifyNoInteractions(unbindClient); Mockito.verify(service, Mockito.never()).removeByIds(Mockito.anyList()); } @Test void deleteFleetsAbortsWhenUnbindFails() { FleetUnbindClient unbindClient = Mockito.mock(FleetUnbindClient.class); Mockito.when(unbindClient.unbindByFleetIds(Mockito.anyList())).thenReturn(R.fail("车队关联数据解绑失败:超时")); TFleetServiceImpl service = Mockito.spy(new TFleetServiceImpl()); ReflectionTestUtils.setField(service, "fleetUnbindClient", unbindClient); Mockito.doReturn(Collections.singletonList(fleet(1))).when(service).listByIds(Mockito.anyList()); ServiceException ex = assertThrows(ServiceException.class, () -> service.deleteFleets(Collections.singletonList(1))); // 解绑失败中止删除:车队与绑定关系均保留,两侧数据保持一致 assertTrue(ex.getMessage().contains("车队关联数据解绑失败"), ex.getMessage()); Mockito.verify(service, Mockito.never()).removeByIds(Mockito.anyList()); } @Test void deleteFleetsAbortsWhenRemoteThrows() { FleetUnbindClient unbindClient = Mockito.mock(FleetUnbindClient.class); Mockito.when(unbindClient.unbindByFleetIds(Mockito.anyList())) .thenThrow(new RuntimeException("connect refused")); TFleetServiceImpl service = Mockito.spy(new TFleetServiceImpl()); ReflectionTestUtils.setField(service, "fleetUnbindClient", unbindClient); Mockito.doReturn(Collections.singletonList(fleet(1))).when(service).listByIds(Mockito.anyList()); ServiceException ex = assertThrows(ServiceException.class, () -> service.deleteFleets(Collections.singletonList(1))); assertTrue(ex.getMessage().contains("车队关联数据解绑失败"), ex.getMessage()); Mockito.verify(service, Mockito.never()).removeByIds(Mockito.anyList()); } private TFleet fleet(Integer id) { TFleet fleet = new TFleet(); fleet.setId(id);