package com.ruoyi.account.service.impl; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.account.api.dto.ChargingCardAllocateDTO; import com.ruoyi.account.api.dto.ChargingCardAllocateItemDTO; import com.ruoyi.account.api.dto.ChargingCardAuthDTO; import com.ruoyi.account.api.dto.ChargingCardDeductDTO; import com.ruoyi.account.api.dto.ChargingCardExcelDTO; import com.ruoyi.account.api.dto.ChargingCardQueryDTO; import com.ruoyi.account.api.dto.ChargingCardSaveDTO; import com.ruoyi.account.api.dto.ChargingCardStatusDTO; import com.ruoyi.account.api.model.TChargingCard; import com.ruoyi.account.api.vo.ChargingCardAllocateVO; import com.ruoyi.account.api.vo.ChargingCardDeductVO; import com.ruoyi.account.mapper.TChargingCardMapper; import com.ruoyi.account.service.PartnerAppUserAccessService; import com.ruoyi.common.core.context.SecurityContextHolder; import com.ruoyi.common.core.exception.ServiceException; import com.ruoyi.common.core.domain.R; import com.ruoyi.common.security.utils.SecurityUtils; import com.ruoyi.other.api.domain.TFleet; import com.ruoyi.other.api.domain.TGroupCompany; import com.ruoyi.other.api.feignClient.GroupCompanyClient; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.AfterEach; 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.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.dao.DuplicateKeyException; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.support.TransactionCallback; import org.springframework.transaction.support.TransactionTemplate; import java.math.BigDecimal; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class TChargingCardServiceImplTest { @Mock private TChargingCardMapper mapper; @Mock private TransactionTemplate transactionTemplate; @Mock private GroupCompanyClient groupCompanyClient; @Mock private RedissonClient redissonClient; @Mock private RLock quotaLock; @Mock private PartnerAppUserAccessService partnerAppUserAccessService; private TChargingCardServiceImpl service; @BeforeAll static void initTableInfo() { // MP 的 lambdaUpdate 会通过 TableInfoHelper.getTableInfo 取表元数据, // 单元测试不启动 Spring,需要手动初始化 TableInfo,否则抛 MybatisPlusException TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), TChargingCard.class); } @BeforeEach void setUp() throws InterruptedException { service = spy(new TChargingCardServiceImpl()); ReflectionTestUtils.setField(service, "baseMapper", mapper); ReflectionTestUtils.setField(service, "transactionTemplate", transactionTemplate); ReflectionTestUtils.setField(service, "groupCompanyClient", groupCompanyClient); ReflectionTestUtils.setField(service, "redissonClient", redissonClient); ReflectionTestUtils.setField(service, "partnerAppUserAccessService", partnerAppUserAccessService); // 默认透传(模拟非公司账号),公司账号隔离场景由具体用例覆盖打桩 lenient().when(partnerAppUserAccessService.resolveCompanyFilter(any())).thenAnswer(invocation -> invocation.getArgument(0)); lenient().when(partnerAppUserAccessService.getCurrentCompanyId()).thenReturn(null); lenient().when(redissonClient.getLock(anyString())).thenReturn(quotaLock); lenient().when(quotaLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(true); lenient().when(quotaLock.isHeldByCurrentThread()).thenReturn(true); SecurityContextHolder.setUserId("123"); } @AfterEach void tearDown() { SecurityContextHolder.remove(); } @Test void selectCardPageUsesSafeDefaultsAndPassesQuery() { Page expected = new Page<>(1, 10); when(mapper.selectCardPage(any(Page.class), any())).thenReturn(expected); IPage result = service.selectCardPage(null); ArgumentCaptor> page = ArgumentCaptor.forClass(Page.class); verify(mapper).selectCardPage(page.capture(), any()); assertEquals(1, page.getValue().getCurrent()); assertEquals(50, page.getValue().getSize()); assertSame(expected, result); } @Test void saveCardTrimsAndSetsDefaults() { when(mapper.countByCardNo(anyString(), isNull())).thenReturn(0); when(mapper.insert(any(TChargingCard.class))).thenAnswer(invocation -> 1); ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setCardNo(" CARD-1 "); assertTrue(service.saveCard(dto)); ArgumentCaptor card = ArgumentCaptor.forClass(TChargingCard.class); verify(mapper).insert(card.capture()); assertEquals("CARD-1", card.getValue().getCardNo()); assertEquals(BigDecimal.ZERO, card.getValue().getBalance()); assertEquals(Integer.valueOf(1), card.getValue().getStatus()); assertEquals(Integer.valueOf(0), card.getValue().getDelFlag()); assertEquals(Long.valueOf(123), card.getValue().getCreateBy()); assertNotNull(card.getValue().getCreateTime()); } @Test void saveCardRejectsDuplicate() { when(mapper.countByCardNo("CARD-1", null)).thenReturn(1); ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setCardNo("CARD-1"); ServiceException exception = assertThrows(ServiceException.class, () -> service.saveCard(dto)); assertEquals("卡号已存在", exception.getMessage()); verify(mapper, never()).insert(any()); } @Test void updateCardRequiresExistingCardAndDoesNotChangeBalance() { ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setId(7L); dto.setCardNo(" CARD-7 "); when(mapper.countByCardNo("CARD-7", 7L)).thenReturn(0); TChargingCard existing = new TChargingCard(); existing.setId(7L); when(mapper.selectById(7L)).thenReturn(existing); when(mapper.updateById(any(TChargingCard.class))).thenReturn(1); assertTrue(service.updateCard(dto)); ArgumentCaptor card = ArgumentCaptor.forClass(TChargingCard.class); verify(mapper).updateById(card.capture()); assertEquals("CARD-7", card.getValue().getCardNo()); assertEquals(null, card.getValue().getBalance()); } @Test void deleteCardsRemovesDistinctIds() { TChargingCard first = new TChargingCard(); first.setId(1L); TChargingCard second = new TChargingCard(); second.setId(2L); doReturn(Arrays.asList(first, second)).when(service).listByIds(anyList()); doReturn(true).when(service).removeByIds(anyList()); assertTrue(service.deleteCards(Arrays.asList(1L, 1L, 2L))); verify(service).removeByIds(anyList()); } @Test void updateStatusRejectsInvalidStatus() { ChargingCardStatusDTO dto = new ChargingCardStatusDTO(); dto.setId(1L); dto.setStatus(3); ServiceException exception = assertThrows(ServiceException.class, () -> service.updateStatus(dto)); assertEquals("卡片状态参数错误", exception.getMessage()); } @Test void importCardsRejectsDuplicateRows() { ChargingCardExcelDTO first = new ChargingCardExcelDTO(); first.setCardNo("CARD-1"); ChargingCardExcelDTO second = new ChargingCardExcelDTO(); second.setCardNo(" CARD-1 "); ServiceException exception = assertThrows(ServiceException.class, () -> service.importCards(Arrays.asList(first, second))); assertEquals("第3行卡号在文件中重复", exception.getMessage()); } @Test void selectExportListUsesEmptyQueryWhenNull() { when(mapper.selectExportList(any())).thenReturn(Collections.emptyList()); assertTrue(service.selectExportList(null).isEmpty()); verify(mapper).selectExportList(any()); } // ==================== V0.2 定稿:卡密码配置(仅配置密码的卡刷卡时才校验) ==================== @Test void saveCardEncodesPasswordWhenProvided() { when(mapper.countByCardNo(anyString(), isNull())).thenReturn(0); when(mapper.insert(any(TChargingCard.class))).thenAnswer(invocation -> 1); ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setCardNo("CARD-PWD"); dto.setPassword(" 123456 "); assertTrue(service.saveCard(dto)); ArgumentCaptor card = ArgumentCaptor.forClass(TChargingCard.class); verify(mapper).insert(card.capture()); assertNotNull(card.getValue().getPassword()); assertTrue(SecurityUtils.matchesPassword("123456", card.getValue().getPassword())); assertEquals(Integer.valueOf(0), card.getValue().getChargingFlag()); } @Test void saveCardKeepsPasswordNullWhenNotConfigured() { when(mapper.countByCardNo(anyString(), isNull())).thenReturn(0); when(mapper.insert(any(TChargingCard.class))).thenAnswer(invocation -> 1); ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setCardNo("CARD-NOPWD"); assertTrue(service.saveCard(dto)); ArgumentCaptor card = ArgumentCaptor.forClass(TChargingCard.class); verify(mapper).insert(card.capture()); assertEquals(null, card.getValue().getPassword()); } @Test void updateCardRejectsCardInChargingLock() { ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setId(7L); dto.setCardNo("CARD-7"); when(mapper.countByCardNo("CARD-7", 7L)).thenReturn(0); TChargingCard locked = new TChargingCard(); locked.setId(7L); locked.setCardNo("CARD-7"); locked.setChargingFlag(1); when(mapper.selectById(7L)).thenReturn(locked); ServiceException exception = assertThrows(ServiceException.class, () -> service.updateCard(dto)); assertTrue(exception.getMessage().contains("正在充电中")); verify(mapper, never()).updateById(any()); } @Test void updateCardResetsPasswordOnlyWhenProvided() { ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setId(7L); dto.setCardNo("CARD-7"); dto.setPassword("new-pwd"); when(mapper.countByCardNo("CARD-7", 7L)).thenReturn(0); TChargingCard existing = new TChargingCard(); existing.setId(7L); existing.setCardNo("CARD-7"); when(mapper.selectById(7L)).thenReturn(existing); when(mapper.updateById(any(TChargingCard.class))).thenReturn(1); assertTrue(service.updateCard(dto)); ArgumentCaptor card = ArgumentCaptor.forClass(TChargingCard.class); verify(mapper).updateById(card.capture()); assertNotNull(card.getValue().getPassword()); assertTrue(SecurityUtils.matchesPassword("new-pwd", card.getValue().getPassword())); } @Test void updateCardKeepsPasswordWhenNotProvided() { ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setId(7L); dto.setCardNo("CARD-7"); when(mapper.countByCardNo("CARD-7", 7L)).thenReturn(0); TChargingCard existing = new TChargingCard(); existing.setId(7L); existing.setCardNo("CARD-7"); when(mapper.selectById(7L)).thenReturn(existing); when(mapper.updateById(any(TChargingCard.class))).thenReturn(1); assertTrue(service.updateCard(dto)); ArgumentCaptor card = ArgumentCaptor.forClass(TChargingCard.class); verify(mapper).updateById(card.capture()); assertEquals(null, card.getValue().getPassword()); } // ==================== V0.2 定稿:充电期间卡片管理操作互斥 ==================== @Test void deleteCardsRejectsCardInChargingLock() { TChargingCard first = new TChargingCard(); first.setId(1L); TChargingCard locked = new TChargingCard(); locked.setId(2L); locked.setCardNo("CARD-2"); locked.setChargingFlag(1); doReturn(Arrays.asList(first, locked)).when(service).listByIds(anyList()); ServiceException exception = assertThrows(ServiceException.class, () -> service.deleteCards(Arrays.asList(1L, 2L))); assertTrue(exception.getMessage().contains("正在充电中")); verify(service, never()).removeByIds(anyList()); } // ==================== 批量解绑车队 ==================== @Test void unbindFleetRejectsNullOrEmptyIds() { ServiceException nullEx = assertThrows(ServiceException.class, () -> service.unbindFleet(null)); assertEquals("请选择需要解绑车队的卡片", nullEx.getMessage()); ServiceException emptyEx = assertThrows(ServiceException.class, () -> service.unbindFleet(Collections.emptyList())); assertEquals("请选择需要解绑车队的卡片", emptyEx.getMessage()); } @Test void unbindFleetRejectsMissingRecords() { TChargingCard existing = new TChargingCard(); existing.setId(1L); doReturn(Collections.singletonList(existing)).when(service).listByIds(anyList()); ServiceException exception = assertThrows(ServiceException.class, () -> service.unbindFleet(Arrays.asList(1L, 2L))); assertEquals("部分卡片不存在或已删除", exception.getMessage()); verify(mapper, never()).update(isNull(), any(Wrapper.class)); } @Test void unbindFleetClearsFleetIdAndIgnoresUnboundRecords() { TChargingCard bound = new TChargingCard(); bound.setId(1L); bound.setFleetId(5); TChargingCard unbound = new TChargingCard(); unbound.setId(2L); doReturn(Arrays.asList(bound, unbound)).when(service).listByIds(anyList()); when(mapper.update(isNull(), any(Wrapper.class))).thenReturn(1); int result = service.unbindFleet(Arrays.asList(1L, 2L, 2L, null)); assertEquals(1, result); ArgumentCaptor wrapperCaptor = ArgumentCaptor.forClass(Wrapper.class); verify(mapper).update(isNull(), wrapperCaptor.capture()); String sqlSet = wrapperCaptor.getValue().getSqlSet(); assertTrue(sqlSet.contains("fleet_id=")); assertTrue(sqlSet.contains("update_time=")); assertTrue(sqlSet.contains("update_by=")); } @Test void unbindFleetReturnsZeroWhenAllUnbound() { TChargingCard unbound = new TChargingCard(); unbound.setId(1L); doReturn(Collections.singletonList(unbound)).when(service).listByIds(anyList()); int result = service.unbindFleet(Collections.singletonList(1L)); assertEquals(0, result); verify(mapper, never()).update(isNull(), any(Wrapper.class)); } @Test void updateStatusRejectsCardInChargingLock() { ChargingCardStatusDTO dto = new ChargingCardStatusDTO(); dto.setId(1L); dto.setStatus(2); TChargingCard locked = new TChargingCard(); locked.setId(1L); locked.setCardNo("CARD-1"); locked.setChargingFlag(1); when(mapper.selectById(1L)).thenReturn(locked); ServiceException exception = assertThrows(ServiceException.class, () -> service.updateStatus(dto)); assertTrue(exception.getMessage().contains("正在充电中")); verify(mapper, never()).updateById(any()); } // ==================== 卡片所属公司/车队 + 初始分配额度 ==================== @Test void saveCardDeductsCompanyBalanceWhenAllocationQuotaProvided() { when(mapper.countByCardNo(anyString(), isNull())).thenReturn(0); when(mapper.insert(any(TChargingCard.class))).thenReturn(1); when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司"))); when(groupCompanyClient.getFleetById(3)).thenReturn(R.ok(fleet(3, 9, "测试车队"))); when(groupCompanyClient.deductBalance(9, new BigDecimal("100.00"))) .thenReturn(R.ok(new BigDecimal("900.00"))); ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setCardNo(" CARD-Q1 "); dto.setCompanyId(9); dto.setFleetId(3); dto.setBalance(new BigDecimal("100")); assertTrue(service.saveCard(dto)); ArgumentCaptor card = ArgumentCaptor.forClass(TChargingCard.class); verify(mapper).insert(card.capture()); assertEquals(Integer.valueOf(9), card.getValue().getCompanyId()); assertEquals(Integer.valueOf(3), card.getValue().getFleetId()); assertEquals(new BigDecimal("100.00"), card.getValue().getBalance()); verify(groupCompanyClient).deductBalance(9, new BigDecimal("100.00")); } @Test void saveCardRejectsQuotaForShareModeCompany() { when(mapper.countByCardNo(anyString(), isNull())).thenReturn(0); TGroupCompany shareCompany = allocationCompany(9, "共享公司"); shareCompany.setAccountMode(1); when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(shareCompany)); ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setCardNo("CARD-Q2"); dto.setCompanyId(9); dto.setBalance(new BigDecimal("10.00")); ServiceException exception = assertThrows(ServiceException.class, () -> service.saveCard(dto)); assertEquals("仅分配模式的公司可填写初始分配额度", exception.getMessage()); verify(groupCompanyClient, never()).deductBalance(any(), any()); verify(mapper, never()).insert(any(TChargingCard.class)); } @Test void saveCardKeepsQuotaEmptyAndSkipsBalanceWhenCompanyMissing() { when(mapper.countByCardNo(anyString(), isNull())).thenReturn(0); when(mapper.insert(any(TChargingCard.class))).thenReturn(1); ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setCardNo("CARD-Q3"); assertTrue(service.saveCard(dto)); ArgumentCaptor card = ArgumentCaptor.forClass(TChargingCard.class); verify(mapper).insert(card.capture()); assertEquals(null, card.getValue().getCompanyId()); assertEquals(0, card.getValue().getBalance().compareTo(BigDecimal.ZERO)); verify(groupCompanyClient, never()).deductBalance(any(), any()); } @Test void saveCardRejectsFleetNotBelongingToCompany() { when(mapper.countByCardNo(anyString(), isNull())).thenReturn(0); when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司"))); when(groupCompanyClient.getFleetById(3)).thenReturn(R.ok(fleet(3, 11, "他司车队"))); ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setCardNo("CARD-Q4"); dto.setCompanyId(9); dto.setFleetId(3); ServiceException exception = assertThrows(ServiceException.class, () -> service.saveCard(dto)); assertEquals("所属车队不属于所选公司", exception.getMessage()); } @Test void updateCardDeductsQuotaDiffWhenQuotaIncreased() { ChargingCardSaveDTO dto = quotaUpdateDto(7L, "CARD-7", 9, new BigDecimal("150.00")); when(mapper.countByCardNo("CARD-7", 7L)).thenReturn(0); when(mapper.selectById(7L)).thenReturn(cardWithQuota(7L, 9, new BigDecimal("100.00"))); when(mapper.updateById(any(TChargingCard.class))).thenReturn(1); when(mapper.update(isNull(), any(Wrapper.class))).thenReturn(1); when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司"))); when(groupCompanyClient.deductBalance(9, new BigDecimal("50.00"))) .thenReturn(R.ok(new BigDecimal("50.00"))); assertTrue(service.updateCard(dto)); verify(groupCompanyClient).deductBalance(9, new BigDecimal("50.00")); verify(groupCompanyClient, never()).refundBalance(any(), any()); } @Test void updateCardRefundsQuotaDiffWhenQuotaReduced() { ChargingCardSaveDTO dto = quotaUpdateDto(7L, "CARD-7", 9, new BigDecimal("80.00")); when(mapper.countByCardNo("CARD-7", 7L)).thenReturn(0); when(mapper.selectById(7L)).thenReturn(cardWithQuota(7L, 9, new BigDecimal("150.00"))); when(mapper.updateById(any(TChargingCard.class))).thenReturn(1); when(mapper.update(isNull(), any(Wrapper.class))).thenReturn(1); when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司"))); when(groupCompanyClient.refundBalance(9, new BigDecimal("70.00"))) .thenReturn(R.ok(new BigDecimal("70.00"))); assertTrue(service.updateCard(dto)); verify(groupCompanyClient).refundBalance(9, new BigDecimal("70.00")); verify(groupCompanyClient, never()).deductBalance(any(), any()); } @Test void updateCardSwitchesCompanyRefundsOldAndDeductsNew() { ChargingCardSaveDTO dto = quotaUpdateDto(7L, "CARD-7", 11, new BigDecimal("60.00")); when(mapper.countByCardNo("CARD-7", 7L)).thenReturn(0); when(mapper.selectById(7L)).thenReturn(cardWithQuota(7L, 9, new BigDecimal("100.00"))); when(mapper.updateById(any(TChargingCard.class))).thenReturn(1); when(mapper.update(isNull(), any(Wrapper.class))).thenReturn(1); when(groupCompanyClient.getCompanyById(11)).thenReturn(R.ok(allocationCompany(11, "新公司"))); when(groupCompanyClient.refundBalance(9, new BigDecimal("100.00"))) .thenReturn(R.ok(new BigDecimal("100.00"))); when(groupCompanyClient.deductBalance(11, new BigDecimal("60.00"))) .thenReturn(R.ok(new BigDecimal("60.00"))); assertTrue(service.updateCard(dto)); verify(groupCompanyClient).refundBalance(9, new BigDecimal("100.00")); verify(groupCompanyClient).deductBalance(11, new BigDecimal("60.00")); } @Test void updateCardCompensatesRefundWhenUpdateFails() { ChargingCardSaveDTO dto = quotaUpdateDto(7L, "CARD-7", 9, new BigDecimal("150.00")); when(mapper.countByCardNo("CARD-7", 7L)).thenReturn(0); when(mapper.selectById(7L)).thenReturn(cardWithQuota(7L, 9, new BigDecimal("100.00"))); when(mapper.updateById(any(TChargingCard.class))).thenReturn(0); when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司"))); when(groupCompanyClient.deductBalance(9, new BigDecimal("50.00"))) .thenReturn(R.ok(new BigDecimal("100.00"))); when(groupCompanyClient.refundBalance(9, new BigDecimal("50.00"))) .thenReturn(R.ok(new BigDecimal("150.00"))); ServiceException exception = assertThrows(ServiceException.class, () -> service.updateCard(dto)); assertEquals("修改卡片失败", exception.getMessage()); verify(groupCompanyClient).refundBalance(9, new BigDecimal("50.00")); } @Test void updateCardRejectsWhenQuotaLockUnavailable() throws InterruptedException { ChargingCardSaveDTO dto = quotaUpdateDto(7L, "CARD-7", 9, new BigDecimal("150.00")); when(mapper.countByCardNo("CARD-7", 7L)).thenReturn(0); when(mapper.selectById(7L)).thenReturn(cardWithQuota(7L, 9, new BigDecimal("100.00"))); when(quotaLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(false); ServiceException exception = assertThrows(ServiceException.class, () -> service.updateCard(dto)); assertEquals("该卡片额度正在处理中,请稍后重试", exception.getMessage()); verify(groupCompanyClient, never()).deductBalance(any(), any()); } @Test void selectCardPageFillsCompanyAndFleetNames() { TChargingCard record = cardWithQuota(1L, 9, new BigDecimal("10.00")); record.setFleetId(3); Page page = new Page<>(1, 10); page.setRecords(Collections.singletonList(record)); when(mapper.selectCardPage(any(Page.class), any())).thenReturn(page); when(groupCompanyClient.listCompaniesByIds(anyList())) .thenReturn(R.ok(Collections.singletonList(allocationCompany(9, "测试公司")))); when(groupCompanyClient.listFleetsByIds(anyList())) .thenReturn(R.ok(Collections.singletonList(fleet(3, 9, "测试车队")))); IPage result = service.selectCardPage(null); TChargingCard filled = result.getRecords().get(0); assertEquals("测试公司", filled.getCompanyName()); assertEquals("测试车队", filled.getFleetName()); // 分配模式:可用余额取卡片自身余额,非负为正常、无超额 assertEquals(2, filled.getAccountMode()); assertEquals(0, new BigDecimal("10.00").compareTo(filled.getAvailableBalance())); assertNull(filled.getOverrun()); assertEquals(1, filled.getAccountStatus()); } @Test void selectCardPageUsesCompanyBalanceForSharedModeCard() { TChargingCard record = cardWithQuota(2L, 9, null); Page page = new Page<>(1, 10); page.setRecords(Collections.singletonList(record)); when(mapper.selectCardPage(any(Page.class), any())).thenReturn(page); TGroupCompany shared = new TGroupCompany(); shared.setId(9); shared.setName("共享公司"); shared.setAccountMode(1); shared.setAvailableBalance(new BigDecimal("100.00")); when(groupCompanyClient.listCompaniesByIds(anyList())) .thenReturn(R.ok(Collections.singletonList(shared))); IPage result = service.selectCardPage(null); TChargingCard filled = result.getRecords().get(0); assertEquals(1, filled.getAccountMode()); assertEquals(0, new BigDecimal("100.00").compareTo(filled.getAvailableBalance())); assertNull(filled.getOverrun()); assertEquals(1, filled.getAccountStatus()); } @Test void selectCardPageComputesOverrunForNegativeCardBalance() { TChargingCard record = cardWithQuota(3L, 9, new BigDecimal("-5.50")); Page page = new Page<>(1, 10); page.setRecords(Collections.singletonList(record)); when(mapper.selectCardPage(any(Page.class), any())).thenReturn(page); when(groupCompanyClient.listCompaniesByIds(anyList())) .thenReturn(R.ok(Collections.singletonList(allocationCompany(9, "分配公司")))); IPage result = service.selectCardPage(null); TChargingCard filled = result.getRecords().get(0); assertEquals(0, new BigDecimal("-5.50").compareTo(filled.getAvailableBalance())); assertEquals(0, new BigDecimal("5.50").compareTo(filled.getOverrun())); assertEquals(2, filled.getAccountStatus()); } @Test void selectCardPageLeavesAccountFieldsEmptyWhenCompanyUnavailable() { TChargingCard record = cardWithQuota(4L, 9, new BigDecimal("8.00")); Page page = new Page<>(1, 10); page.setRecords(Collections.singletonList(record)); when(mapper.selectCardPage(any(Page.class), any())).thenReturn(page); when(groupCompanyClient.listCompaniesByIds(anyList())).thenReturn(R.fail("远程不可用")); IPage result = service.selectCardPage(null); TChargingCard filled = result.getRecords().get(0); assertNull(filled.getCompanyName()); assertNull(filled.getAccountMode()); assertNull(filled.getAvailableBalance()); assertNull(filled.getOverrun()); assertNull(filled.getAccountStatus()); } @Test void selectExportListFillsCompanyFleetAndAccountInfo() { ChargingCardExcelDTO item = new ChargingCardExcelDTO(); item.setCardNo("CARD-9"); item.setCompanyId(9); item.setFleetId(3); item.setBalance(new BigDecimal("20.00")); when(mapper.selectExportList(any())).thenReturn(Collections.singletonList(item)); when(groupCompanyClient.listCompaniesByIds(anyList())) .thenReturn(R.ok(Collections.singletonList(allocationCompany(9, "测试公司")))); when(groupCompanyClient.listFleetsByIds(anyList())) .thenReturn(R.ok(Collections.singletonList(fleet(3, 9, "测试车队")))); List result = service.selectExportList(null); assertEquals("测试公司", result.get(0).getCompanyName()); assertEquals("测试车队", result.get(0).getFleetName()); assertEquals(2, result.get(0).getAccountMode()); assertEquals(0, new BigDecimal("20.00").compareTo(result.get(0).getAvailableBalance())); assertNull(result.get(0).getOverrun()); assertEquals(1, result.get(0).getAccountStatus()); } private TGroupCompany allocationCompany(Integer id, String name) { TGroupCompany company = new TGroupCompany(); company.setId(id); company.setName(name); company.setAccountMode(2); company.setStatus(1); return company; } private TFleet fleet(Integer id, Integer companyId, String name) { TFleet fleet = new TFleet(); fleet.setId(id); fleet.setCompanyId(companyId); fleet.setFleetName(name); return fleet; } private TChargingCard cardWithQuota(Long id, Integer companyId, BigDecimal quota) { TChargingCard card = new TChargingCard(); card.setId(id); card.setCompanyId(companyId); card.setBalance(quota); return card; } private ChargingCardSaveDTO quotaUpdateDto(Long id, String cardNo, Integer companyId, BigDecimal quota) { ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setId(id); dto.setCardNo(cardNo); dto.setCompanyId(companyId); dto.setBalance(quota); return dto; } // ==================== V0.2 定稿:刷卡启动鉴权(条件密码校验) ==================== @Test void verifyCardForChargingPassesWithoutPasswordConfigured() { TChargingCard card = new TChargingCard(); card.setId(1L); card.setCardNo("CARD-NOPWD"); card.setStatus(1); card.setPassword(null); when(mapper.selectOne(any(Wrapper.class))).thenReturn(card); TChargingCard result = service.verifyCardForCharging(authDto("CARD-NOPWD", "whatever")); assertSame(card, result); } @Test void verifyCardForChargingPassesWithEmptyPasswordWhenNotConfigured() { TChargingCard card = new TChargingCard(); card.setId(1L); card.setCardNo("CARD-NOPWD"); card.setStatus(1); card.setPassword(""); when(mapper.selectOne(any(Wrapper.class))).thenReturn(card); TChargingCard result = service.verifyCardForCharging(authDto("CARD-NOPWD", null)); assertSame(card, result); } @Test void verifyCardForChargingRequiresPasswordWhenConfigured() { TChargingCard card = new TChargingCard(); card.setId(1L); card.setCardNo("CARD-PWD"); card.setStatus(1); card.setPassword(SecurityUtils.encryptPassword("123456")); when(mapper.selectOne(any(Wrapper.class))).thenReturn(card); ServiceException exception = assertThrows(ServiceException.class, () -> service.verifyCardForCharging(authDto("CARD-PWD", null))); assertEquals("卡片已配置密码,请输入卡密码", exception.getMessage()); } @Test void verifyCardForChargingRejectsWrongPasswordWhenConfigured() { TChargingCard card = new TChargingCard(); card.setId(1L); card.setCardNo("CARD-PWD"); card.setStatus(1); card.setPassword(SecurityUtils.encryptPassword("123456")); when(mapper.selectOne(any(Wrapper.class))).thenReturn(card); ServiceException exception = assertThrows(ServiceException.class, () -> service.verifyCardForCharging(authDto("CARD-PWD", "bad-pwd"))); assertEquals("卡密码错误", exception.getMessage()); } @Test void verifyCardForChargingRejectsDisabledCard() { TChargingCard card = new TChargingCard(); card.setId(1L); card.setCardNo("CARD-1"); card.setStatus(2); when(mapper.selectOne(any(Wrapper.class))).thenReturn(card); ServiceException exception = assertThrows(ServiceException.class, () -> service.verifyCardForCharging(authDto("CARD-1", null))); assertEquals("卡片已禁用,无法启动充电", exception.getMessage()); } @Test void verifyCardForChargingRejectsMissingCard() { when(mapper.selectOne(any(Wrapper.class))).thenReturn(null); ServiceException exception = assertThrows(ServiceException.class, () -> service.verifyCardForCharging(authDto("NO-SUCH", null))); assertEquals("卡片不存在或已删除", exception.getMessage()); } // ==================== V0.2 定稿:充电锁定 / 解锁 ==================== @Test void lockCardForChargingReturnsMapperResult() { when(mapper.lockCardForCharging(1L, "SD20260909001")).thenReturn(1); assertTrue(service.lockCardForCharging(1L, "SD20260909001")); when(mapper.lockCardForCharging(1L, "SD20260909001")).thenReturn(0); assertEquals(false, service.lockCardForCharging(1L, "SD20260909001")); } @Test void unlockCardForChargingIsIdempotentWhenAlreadyUnlocked() { when(mapper.unlockCardForCharging(1L, "SD20260909001")).thenReturn(0); TChargingCard unlocked = new TChargingCard(); unlocked.setId(1L); unlocked.setChargingOrderCode(null); when(mapper.selectById(1L)).thenReturn(unlocked); assertTrue(service.unlockCardForCharging(1L, "SD20260909001")); } @Test void unlockCardForChargingRefusesWhenOccupiedByAnotherOrder() { when(mapper.unlockCardForCharging(1L, "SD-OLD")).thenReturn(0); TChargingCard occupied = new TChargingCard(); occupied.setId(1L); occupied.setChargingOrderCode("SD-NEW"); when(mapper.selectById(1L)).thenReturn(occupied); assertEquals(false, service.unlockCardForCharging(1L, "SD-OLD")); } // ==================== V0.2 定稿:最终结算扣款(幂等 + 允许负余额) ==================== @Test void deductBalanceForChargingAllowsNegativeBalance() { stubTransaction(); TChargingCard beforeCard = new TChargingCard(); beforeCard.setId(1L); beforeCard.setCardNo("CARD-1"); beforeCard.setBalance(new BigDecimal("5.00")); TChargingCard afterCard = new TChargingCard(); afterCard.setId(1L); afterCard.setCardNo("CARD-1"); afterCard.setBalance(new BigDecimal("-5.00")); when(mapper.selectById(1L)).thenReturn(beforeCard, afterCard); when(mapper.insertOrderDeductRecord(1L, new BigDecimal("10.00"), "SD20260909001", 0L)).thenReturn(1); when(mapper.deductBalanceByCardId(1L, new BigDecimal("10.00"))).thenReturn(1); ChargingCardDeductVO result = service.deductBalanceForCharging( deductDto(1L, "SD20260909001", new BigDecimal("10.00"))); assertEquals(false, result.isDuplicated()); assertEquals(0, result.getAfterBalance().compareTo(new BigDecimal("-5.00"))); verify(mapper).insertOrderDeductRecord(1L, new BigDecimal("10.00"), "SD20260909001", 0L); verify(mapper).deductBalanceByCardId(1L, new BigDecimal("10.00")); } @Test void deductBalanceForChargingDuplicateRequestDoesNotDeductTwice() { stubTransaction(); TChargingCard beforeCard = new TChargingCard(); beforeCard.setId(1L); beforeCard.setCardNo("CARD-1"); beforeCard.setBalance(new BigDecimal("-5.00")); when(mapper.selectById(1L)).thenReturn(beforeCard); when(mapper.insertOrderDeductRecord(1L, new BigDecimal("10.00"), "SD20260909001", 0L)) .thenThrow(new DuplicateKeyException("uk_order_code")); ChargingCardDeductVO result = service.deductBalanceForCharging( deductDto(1L, "SD20260909001", new BigDecimal("10.00"))); assertEquals(true, result.isDuplicated()); verify(mapper, never()).deductBalanceByCardId(anyLong(), any()); } @Test void listDeductedOrderCodesNormalizesInputAndQueriesMapper() { when(mapper.selectDeductedOrderCodes(Arrays.asList("SD1", "SD2"))) .thenReturn(Collections.singletonList("SD1")); assertEquals(Collections.singletonList("SD1"), service.listDeductedOrderCodes(Arrays.asList(" SD1 ", "", null, "SD1", "SD2"))); verify(mapper).selectDeductedOrderCodes(Arrays.asList("SD1", "SD2")); } @Test void listDeductedOrderCodesSkipsDatabaseForEmptyInput() { assertTrue(service.listDeductedOrderCodes(Collections.emptyList()).isEmpty()); verify(mapper, never()).selectDeductedOrderCodes(anyList()); } @Test void deductBalanceForChargingRejectsMissingCard() { stubTransaction(); when(mapper.selectById(1L)).thenReturn(null); ServiceException exception = assertThrows(ServiceException.class, () -> service.deductBalanceForCharging(deductDto(1L, "SD20260909001", new BigDecimal("10.00")))); assertEquals("卡片不存在或已删除", exception.getMessage()); } @Test void deductBalanceForChargingRejectsInvalidAmount() { ServiceException exception = assertThrows(ServiceException.class, () -> service.deductBalanceForCharging(deductDto(1L, "SD20260909001", new BigDecimal("0")))); assertEquals("扣款金额必须大于0", exception.getMessage()); } private void stubTransaction() { when(transactionTemplate.execute(any(TransactionCallback.class))).thenAnswer(invocation -> { TransactionCallback callback = invocation.getArgument(0); return callback.doInTransaction(null); }); } private ChargingCardAuthDTO authDto(String cardNo, String password) { ChargingCardAuthDTO dto = new ChargingCardAuthDTO(); dto.setCardNo(cardNo); dto.setPassword(password); return dto; } private ChargingCardDeductDTO deductDto(Long cardId, String orderCode, BigDecimal amount) { ChargingCardDeductDTO dto = new ChargingCardDeductDTO(); dto.setCardId(cardId); dto.setOrderCode(orderCode); dto.setAmount(amount); return dto; } // ==================== 卡片额度分配 ==================== @Test void getAllocateOverviewNormalizesAmounts() { TGroupCompany company = allocationCompany(9, "测试公司"); company.setAvailableBalance(new BigDecimal("8420.5")); company.setTotalRechargeAmount(null); when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(company)); ChargingCardAllocateVO vo = service.getAllocateOverview(9); assertEquals(Integer.valueOf(9), vo.getCompanyId()); assertEquals("测试公司", vo.getCompanyName()); assertEquals(new BigDecimal("8420.50"), vo.getAvailableBalance()); assertEquals(new BigDecimal("0.00"), vo.getTotalRechargeAmount()); } @Test void getAllocateOverviewRejectsMissingCompanyId() { ServiceException exception = assertThrows(ServiceException.class, () -> service.getAllocateOverview(null)); assertEquals("请选择公司", exception.getMessage()); } @Test void allocateQuotaAddsCardBalanceAndDeductsCompanyBalance() { when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司"))); TChargingCard first = allocateCard(1L, 9, "CARD-1", "10.00"); TChargingCard second = allocateCard(2L, 9, "CARD-2", "20.00"); doReturn(Arrays.asList(first, second)).when(service).listByIds(anyCollection()); when(mapper.adjustBalance(anyList(), any(BigDecimal.class), any(Integer.class), any(BigDecimal.class), any(Long.class))).thenReturn(1); when(mapper.insertBalanceRecord(anyLong(), any(Integer.class), any(BigDecimal.class), any(), any(Long.class))) .thenReturn(1); when(groupCompanyClient.deductBalance(9, new BigDecimal("30.00"))).thenReturn(R.ok(new BigDecimal("970.00"))); ChargingCardAllocateVO vo = service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00"), allocateItem(2L, "20.00"))); assertEquals(new BigDecimal("970.00"), vo.getAvailableBalance()); assertEquals("测试公司", vo.getCompanyName()); verify(groupCompanyClient).deductBalance(9, new BigDecimal("30.00")); verify(mapper).insertBalanceRecord(1L, 1, new BigDecimal("10.00"), "额度分配(公司:测试公司)", 123L); verify(mapper).insertBalanceRecord(2L, 1, new BigDecimal("20.00"), "额度分配(公司:测试公司)", 123L); verify(redissonClient).getLock("CHARGING_CARD_QUOTA:COMPANY:9"); } @Test void allocateQuotaRejectsSharedModeCompany() { TGroupCompany shared = allocationCompany(9, "共享公司"); shared.setAccountMode(1); when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(shared)); ServiceException exception = assertThrows(ServiceException.class, () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00")))); assertEquals("该公司为共享模式,不支持额度分配", exception.getMessage()); verify(groupCompanyClient, never()).deductBalance(any(Integer.class), any(BigDecimal.class)); } @Test void allocateQuotaRejectsCardOfAnotherCompany() { when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司"))); TChargingCard foreign = allocateCard(1L, 8, "CARD-1", "10.00"); doReturn(Collections.singletonList(foreign)).when(service).listByIds(anyCollection()); ServiceException exception = assertThrows(ServiceException.class, () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00")))); assertTrue(exception.getMessage().contains("不属于所选公司")); verify(groupCompanyClient, never()).deductBalance(any(Integer.class), any(BigDecimal.class)); } @Test void allocateQuotaRefundsCompanyBalanceWhenCardUpdateFails() { when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司"))); TChargingCard card = allocateCard(1L, 9, "CARD-1", "10.00"); doReturn(Collections.singletonList(card)).when(service).listByIds(anyCollection()); when(groupCompanyClient.deductBalance(9, new BigDecimal("10.00"))) .thenReturn(R.ok(new BigDecimal("990.00"))); when(mapper.adjustBalance(anyList(), any(BigDecimal.class), any(Integer.class), any(BigDecimal.class), any(Long.class))).thenReturn(0); ServiceException exception = assertThrows(ServiceException.class, () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00")))); assertTrue(exception.getMessage().contains("余额分配失败")); verify(groupCompanyClient).refundBalance(9, new BigDecimal("10.00")); } @Test void allocateQuotaReportsInsufficientCompanyBalance() { when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司"))); TChargingCard card = allocateCard(1L, 9, "CARD-1", "0.00"); doReturn(Collections.singletonList(card)).when(service).listByIds(anyCollection()); when(groupCompanyClient.deductBalance(9, new BigDecimal("500.00"))).thenReturn(R.fail("公司可用余额不足")); ServiceException exception = assertThrows(ServiceException.class, () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "500.00")))); assertEquals("公司可分配余额不足,无法完成本次分配", exception.getMessage()); verify(mapper, never()).adjustBalance(anyList(), any(BigDecimal.class), any(Integer.class), any(BigDecimal.class), any(Long.class)); } @Test void allocateQuotaRejectsInvalidAmount() { ServiceException exception = assertThrows(ServiceException.class, () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "0")))); assertEquals("分配金额必须大于0", exception.getMessage()); verify(groupCompanyClient, never()).getCompanyById(any(Integer.class)); } @Test void allocateQuotaRejectsDuplicateCards() { ServiceException exception = assertThrows(ServiceException.class, () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00"), allocateItem(1L, "5.00")))); assertEquals("分配明细中存在重复卡片", exception.getMessage()); } @Test void allocateQuotaRejectsChargingLockedCard() { when(groupCompanyClient.getCompanyById(9)).thenReturn(R.ok(allocationCompany(9, "测试公司"))); TChargingCard card = allocateCard(1L, 9, "CARD-1", "10.00"); card.setChargingFlag(1); doReturn(Collections.singletonList(card)).when(service).listByIds(anyCollection()); ServiceException exception = assertThrows(ServiceException.class, () -> service.allocateQuota(allocateDto(9, allocateItem(1L, "10.00")))); assertTrue(exception.getMessage().contains("充电中")); verify(groupCompanyClient, never()).deductBalance(any(Integer.class), any(BigDecimal.class)); } private TChargingCard allocateCard(Long id, Integer companyId, String cardNo, String balance) { TChargingCard card = new TChargingCard(); card.setId(id); card.setCompanyId(companyId); card.setCardNo(cardNo); card.setBalance(new BigDecimal(balance)); card.setStatus(1); card.setChargingFlag(0); return card; } private ChargingCardAllocateItemDTO allocateItem(Long cardId, String amount) { ChargingCardAllocateItemDTO item = new ChargingCardAllocateItemDTO(); item.setCardId(cardId); item.setAmount(new BigDecimal(amount)); return item; } private ChargingCardAllocateDTO allocateDto(Integer companyId, ChargingCardAllocateItemDTO... items) { ChargingCardAllocateDTO dto = new ChargingCardAllocateDTO(); dto.setCompanyId(companyId); dto.setItems(Arrays.asList(items)); return dto; } // ==================== 公司账号数据隔离 ==================== @Test void selectCardPageForcesCompanyFilterForCompanyAccount() { // 公司账号:前端传入的公司筛选被强制覆盖为本公司 when(partnerAppUserAccessService.resolveCompanyFilter(any())).thenReturn(2); ChargingCardQueryDTO query = new ChargingCardQueryDTO(); query.setCompanyId(9); when(mapper.selectCardPage(any(Page.class), any())).thenReturn(new Page<>(1, 10)); service.selectCardPage(query); ArgumentCaptor captor = ArgumentCaptor.forClass(ChargingCardQueryDTO.class); verify(mapper).selectCardPage(any(Page.class), captor.capture()); assertEquals(Integer.valueOf(2), captor.getValue().getCompanyId()); } @Test void updateCardRejectsCrossCompanyRecord() { // 公司账号:编辑其他公司的卡片被越权校验拦截 TChargingCard existing = allocateCard(5L, 9, "CARD-X", "0"); when(mapper.selectById(5L)).thenReturn(existing); doThrow(new ServiceException("无权操作其他公司的数据")).when(partnerAppUserAccessService).assertCompanyVisible(9); ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setId(5L); dto.setCardNo("CARD-X"); ServiceException ex = assertThrows(ServiceException.class, () -> service.updateCard(dto)); assertEquals("无权操作其他公司的数据", ex.getMessage()); verify(mapper, never()).updateById(any(TChargingCard.class)); } @Test void saveCardForcesCompanyAccountOwnership() { // 公司账号:新增卡片强制归属本公司 when(partnerAppUserAccessService.resolveCompanyFilter(any())).thenReturn(2); when(mapper.countByCardNo(anyString(), isNull())).thenReturn(0); when(mapper.insert(any(TChargingCard.class))).thenReturn(1); when(groupCompanyClient.getCompanyById(2)).thenReturn(R.ok(allocationCompany(2, "本公司"))); ChargingCardSaveDTO dto = new ChargingCardSaveDTO(); dto.setCardNo(" CARD-C1 "); dto.setCompanyId(9); assertTrue(service.saveCard(dto)); ArgumentCaptor card = ArgumentCaptor.forClass(TChargingCard.class); verify(mapper).insert(card.capture()); assertEquals(Integer.valueOf(2), card.getValue().getCompanyId()); } }