| | |
| | | package com.ruoyi.account.service.impl; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.Wrapper; |
| | | import com.baomidou.mybatisplus.core.metadata.IPage; |
| | | import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
| | | import com.ruoyi.account.api.dto.ChargingCardAuthDTO; |
| | | import com.ruoyi.account.api.dto.ChargingCardBalanceDTO; |
| | | import com.ruoyi.account.api.dto.ChargingCardDeductDTO; |
| | | import com.ruoyi.account.api.dto.ChargingCardExcelDTO; |
| | | 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.ChargingCardDeductVO; |
| | | import com.ruoyi.account.mapper.TChargingCardMapper; |
| | | 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.service.TokenService; |
| | | import com.ruoyi.common.security.utils.SecurityUtils; |
| | | import com.ruoyi.system.api.domain.SysUser; |
| | | import com.ruoyi.system.api.feignClient.SysUserClient; |
| | | import com.ruoyi.system.api.model.LoginUser; |
| | |
| | | import org.mockito.ArgumentCaptor; |
| | | import org.mockito.Mock; |
| | | import org.mockito.junit.jupiter.MockitoExtension; |
| | | 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 static org.junit.jupiter.api.Assertions.assertTrue; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | 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; |
| | |
| | | @Mock private TChargingCardMapper mapper; |
| | | @Mock private TokenService tokenService; |
| | | @Mock private SysUserClient sysUserClient; |
| | | @Mock private TransactionTemplate transactionTemplate; |
| | | private TChargingCardServiceImpl service; |
| | | |
| | | @BeforeEach |
| | |
| | | ReflectionTestUtils.setField(service, "baseMapper", mapper); |
| | | ReflectionTestUtils.setField(service, "tokenService", tokenService); |
| | | ReflectionTestUtils.setField(service, "sysUserClient", sysUserClient); |
| | | ReflectionTestUtils.setField(service, "transactionTemplate", transactionTemplate); |
| | | SecurityContextHolder.setUserId("123"); |
| | | } |
| | | |
| | |
| | | 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<TChargingCard> 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<TChargingCard> 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<TChargingCard> 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<TChargingCard> 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 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 updateBalanceRejectsCardInChargingLock() { |
| | | LoginUser loginUser = new LoginUser(); |
| | | loginUser.setUserid(123L); |
| | | SysUser user = new SysUser(); |
| | | user.setOperationPassword(SecurityUtils.encryptPassword("pwd")); |
| | | when(tokenService.getLoginUser()).thenReturn(loginUser); |
| | | when(sysUserClient.getSysUser(123L)).thenReturn(R.ok(user)); |
| | | TChargingCard locked = new TChargingCard(); |
| | | locked.setId(1L); |
| | | locked.setCardNo("CARD-1"); |
| | | locked.setChargingFlag(1); |
| | | when(mapper.selectBatchIds(anyList())).thenReturn(Collections.singletonList(locked)); |
| | | ChargingCardBalanceDTO dto = new ChargingCardBalanceDTO(); |
| | | dto.setIds(Collections.singletonList(1L)); |
| | | dto.setType(2); |
| | | dto.setAmount(new BigDecimal("10.00")); |
| | | dto.setOperationPassword("pwd"); |
| | | |
| | | ServiceException exception = assertThrows(ServiceException.class, () -> service.updateBalance(dto)); |
| | | |
| | | assertTrue(exception.getMessage().contains("正在充电中")); |
| | | verify(mapper, never()).adjustBalance(anyList(), any(), any(), any(), any()); |
| | | } |
| | | |
| | | // ==================== 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 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<Object> 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; |
| | | } |
| | | } |