puzhibing
2026-09-29 e665bc906ba6ef715acfa55ebe3ad2370154626d
修改VIN码订单流水
6个文件已添加
6个文件已修改
512 ■■■■■ 已修改文件
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/factory/FleetUnbindFallbackFactory.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-account/src/main/java/com/ruoyi/account/api/feignClient/FleetUnbindClient.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-account/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/controller/FleetUnbindController.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/FleetUnbindService.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/FleetUnbindServiceImpl.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-account/src/test/java/com/ruoyi/account/service/impl/FleetUnbindServiceImplTest.java 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TVinChargingOrderServiceImpl.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/controller/TFleetController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/TFleetService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TFleetServiceImpl.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/service/impl/TFleetServiceImplTest.java 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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
@@ -10,3 +10,4 @@
com.ruoyi.account.api.factory.VinManagementFallbackFactory
com.ruoyi.account.api.factory.ChargingCardFallbackFactory
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);