|
@@ -1,247 +0,0 @@
|
|
|
-package com.payment.platform.module.payment.account.service;
|
|
|
|
|
-
|
|
|
|
|
-import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
|
|
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
|
|
-import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
|
|
-import com.payment.platform.module.payment.account.entity.TransferEntity;
|
|
|
|
|
-import com.payment.platform.module.payment.account.mapper.TransferMapper;
|
|
|
|
|
-import com.payment.platform.module.payment.openapi.service.OpenapiService;
|
|
|
|
|
-import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
|
|
-import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
-import org.junit.jupiter.api.Test;
|
|
|
|
|
-import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
|
|
-import org.junit.jupiter.params.ParameterizedTest;
|
|
|
|
|
-import org.junit.jupiter.params.provider.CsvSource;
|
|
|
|
|
-import org.junit.jupiter.params.provider.ValueSource;
|
|
|
|
|
-import org.mockito.ArgumentCaptor;
|
|
|
|
|
-import org.mockito.Mock;
|
|
|
|
|
-import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
|
-import org.springframework.test.util.ReflectionTestUtils;
|
|
|
|
|
-
|
|
|
|
|
-import java.time.OffsetDateTime;
|
|
|
|
|
-import java.util.List;
|
|
|
|
|
-import java.util.Map;
|
|
|
|
|
-
|
|
|
|
|
-import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
|
-import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
-import static org.mockito.Mockito.*;
|
|
|
|
|
-
|
|
|
|
|
-/**
|
|
|
|
|
- * 转账自动反查(定时兜底)—— 对应 Python {@code AccountService.retry_dealing_transfers}。
|
|
|
|
|
- *
|
|
|
|
|
- * <p>这个功能在本批之前**完全不存在**:{@code next_retry_at} 只被三处写入、零处读取,
|
|
|
|
|
- * 一笔收不到支付宝异步通知的 DEALING 转账会永远停在 DEALING,除非有人手点
|
|
|
|
|
- * {@code POST /transfer/sync-all}。本测试钉住补上的那条腿。
|
|
|
|
|
- *
|
|
|
|
|
- * <p>量具说明:{@code queryTransferStatus} 走的是支付宝网关,测试里用 {@code spy} 换掉
|
|
|
|
|
- * (**不是**换掉整个容器)——被测的是它外面的判定与写库逻辑。
|
|
|
|
|
- */
|
|
|
|
|
-@ExtendWith(MockitoExtension.class)
|
|
|
|
|
-class TransferRetryTest {
|
|
|
|
|
-
|
|
|
|
|
- private static final OffsetDateTime CREATED = OffsetDateTime.parse("2026-09-01T10:00:00+08:00");
|
|
|
|
|
-
|
|
|
|
|
- @Mock private TransferMapper transferMapper;
|
|
|
|
|
- @Mock private OpenapiService openapiService;
|
|
|
|
|
-
|
|
|
|
|
- private AlipayTransferService service;
|
|
|
|
|
-
|
|
|
|
|
- @BeforeEach
|
|
|
|
|
- void setUp() {
|
|
|
|
|
- service = spy(new AlipayTransferService(
|
|
|
|
|
- null, null, transferMapper, null, null, null, null));
|
|
|
|
|
- // openapiService 在生产类是 @Lazy @Autowired 字段注入(不在构造器参数里)
|
|
|
|
|
- ReflectionTestUtils.setField(service, "openapiService", openapiService);
|
|
|
|
|
- // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
|
|
|
|
|
- TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), TransferEntity.class);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ==================== 夹具 ====================
|
|
|
|
|
-
|
|
|
|
|
- private TransferEntity dealing(String payeeType, Integer retryCount) {
|
|
|
|
|
- TransferEntity t = new TransferEntity();
|
|
|
|
|
- t.setId(1L);
|
|
|
|
|
- t.setStatus("DEALING");
|
|
|
|
|
- t.setOutBizNo("OB1");
|
|
|
|
|
- t.setOrderNo("ON1");
|
|
|
|
|
- t.setEnterpriseId("E1");
|
|
|
|
|
- t.setTenantId(1L);
|
|
|
|
|
- t.setPayeeType(payeeType);
|
|
|
|
|
- t.setRetryCount(retryCount);
|
|
|
|
|
- t.setNextRetryAt(CREATED.plusMinutes(5));
|
|
|
|
|
- t.setCreatedTime(CREATED);
|
|
|
|
|
- return t;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private void givenPending(TransferEntity... rows) {
|
|
|
|
|
- when(transferMapper.selectList(any())).thenReturn(List.of(rows));
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private void givenQueryReturns(String status) {
|
|
|
|
|
- doReturn(Map.of("status", status, "pay_fund_order_id", "F1"))
|
|
|
|
|
- .when(service).queryTransferStatus(any(), any(), any());
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ==================== 1. 终态:写终态 + 清退避 + 对外回调 ====================
|
|
|
|
|
-
|
|
|
|
|
- @Test
|
|
|
|
|
- void terminalStatus_writesIt_clearsRetryFields_andNotifiesOpenApi() {
|
|
|
|
|
- TransferEntity t = dealing("BANKCARD_ACCOUNT", 1);
|
|
|
|
|
- givenPending(t);
|
|
|
|
|
- givenQueryReturns("SUCCESS");
|
|
|
|
|
-
|
|
|
|
|
- service.retryDealingTransfers();
|
|
|
|
|
-
|
|
|
|
|
- assertEquals("SUCCESS", t.getStatus());
|
|
|
|
|
- assertEquals(4, t.getRetryCount(), "终态后 retry_count 必须是 4(= 不再反查)");
|
|
|
|
|
- assertNull(t.getNextRetryAt(), "终态后必须清掉 next_retry_at");
|
|
|
|
|
- assertEquals("F1", t.getFundOrderId(), "终态要落支付宝资金流水号");
|
|
|
|
|
- verify(transferMapper).updateById(t);
|
|
|
|
|
- verify(openapiService).notifyTransferResult("ON1", "OB1", 1L);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @Test
|
|
|
|
|
- void terminalStatus_withoutOrderNo_notifiesByOutBizNo() {
|
|
|
|
|
- TransferEntity t = dealing("BANKCARD_ACCOUNT", 1);
|
|
|
|
|
- t.setOrderNo(null);
|
|
|
|
|
- givenPending(t);
|
|
|
|
|
- givenQueryReturns("FAIL");
|
|
|
|
|
-
|
|
|
|
|
- service.retryDealingTransfers();
|
|
|
|
|
-
|
|
|
|
|
- assertEquals("FAIL", t.getStatus());
|
|
|
|
|
- verify(openapiService).notifyTransferResult("OB1", "OB1", 1L);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ==================== 2. 仍 DEALING:对卡转账按档位退避 ====================
|
|
|
|
|
-
|
|
|
|
|
- @ParameterizedTest(name = "retryCount={0} ⇒ next_retry_at = created_time + {1} 分钟")
|
|
|
|
|
- @CsvSource({"0,15", "1,60", "2,120"})
|
|
|
|
|
- void cardStillDealing_advancesBackoffFromCreatedTime(int retryCount, int expectedMinutes) {
|
|
|
|
|
- TransferEntity t = dealing("BANKCARD_ACCOUNT", retryCount);
|
|
|
|
|
- givenPending(t);
|
|
|
|
|
- givenQueryReturns("DEALING");
|
|
|
|
|
-
|
|
|
|
|
- service.retryDealingTransfers();
|
|
|
|
|
-
|
|
|
|
|
- assertEquals("DEALING", t.getStatus(), "还在处理中就不该动 status");
|
|
|
|
|
- assertEquals(retryCount + 1, t.getRetryCount());
|
|
|
|
|
- assertEquals(CREATED.plusMinutes(expectedMinutes), t.getNextRetryAt(),
|
|
|
|
|
- "退避以 created_time 为基准(不是 now),档位累计 5/15/60/120 分钟");
|
|
|
|
|
- verify(transferMapper).updateById(t);
|
|
|
|
|
- verify(openapiService, never()).notifyTransferResult(any(), any(), any());
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /** 两种写法都要走对卡分支 —— 老数据写 BANK_CARD、本仓写入口写 BANKCARD_ACCOUNT。 */
|
|
|
|
|
- @ParameterizedTest
|
|
|
|
|
- @ValueSource(strings = {"BANKCARD_ACCOUNT", "BANK_CARD"})
|
|
|
|
|
- void bothCardSpellings_routeToCardBranch(String payeeType) {
|
|
|
|
|
- TransferEntity t = dealing(payeeType, 0);
|
|
|
|
|
- givenPending(t);
|
|
|
|
|
- givenQueryReturns("DEALING");
|
|
|
|
|
-
|
|
|
|
|
- service.retryDealingTransfers();
|
|
|
|
|
-
|
|
|
|
|
- assertEquals(1, t.getRetryCount(), payeeType + " 必须按对卡转账退避,而不是立刻判失败");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @Test
|
|
|
|
|
- void cardStillDealing_atLastRetry_stampsFailWithTimeoutReason() {
|
|
|
|
|
- TransferEntity t = dealing("BANKCARD_ACCOUNT", 3);
|
|
|
|
|
- givenPending(t);
|
|
|
|
|
- givenQueryReturns("DEALING");
|
|
|
|
|
-
|
|
|
|
|
- service.retryDealingTransfers();
|
|
|
|
|
-
|
|
|
|
|
- assertEquals("FAIL", t.getStatus());
|
|
|
|
|
- assertEquals(4, t.getRetryCount());
|
|
|
|
|
- assertNull(t.getNextRetryAt());
|
|
|
|
|
- assertEquals("反查超时判定为失败", t.getErrorMsg());
|
|
|
|
|
- verify(openapiService).notifyTransferResult("ON1", "OB1", 1L);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ==================== 3. 仍 DEALING:支付宝账户转账只反查一次 ====================
|
|
|
|
|
-
|
|
|
|
|
- @Test
|
|
|
|
|
- void alipayAccountStillDealing_afterFirstQuery_stampsFail() {
|
|
|
|
|
- TransferEntity t = dealing("ALIPAY_USER_ID", 0);
|
|
|
|
|
- givenPending(t);
|
|
|
|
|
- givenQueryReturns("DEALING");
|
|
|
|
|
-
|
|
|
|
|
- service.retryDealingTransfers();
|
|
|
|
|
-
|
|
|
|
|
- assertEquals("FAIL", t.getStatus(), "非对卡转账没有二次反查机会");
|
|
|
|
|
- assertEquals(4, t.getRetryCount());
|
|
|
|
|
- assertNull(t.getNextRetryAt());
|
|
|
|
|
- assertEquals("支付宝转账仅重试1次仍DEALING", t.getErrorMsg());
|
|
|
|
|
- verify(openapiService).notifyTransferResult("ON1", "OB1", 1L);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ==================== 4. 查不通:本轮不动任何字段 ====================
|
|
|
|
|
-
|
|
|
|
|
- @Test
|
|
|
|
|
- void queryWithoutConclusion_skipsRow_advancesNothing() {
|
|
|
|
|
- TransferEntity t = dealing("BANKCARD_ACCOUNT", 0);
|
|
|
|
|
- givenPending(t);
|
|
|
|
|
- givenQueryReturns("UNKNOWN"); // 网络失败/上游报错 —— 不是「还没出结果」的证据
|
|
|
|
|
-
|
|
|
|
|
- service.retryDealingTransfers();
|
|
|
|
|
-
|
|
|
|
|
- assertEquals("DEALING", t.getStatus());
|
|
|
|
|
- assertEquals(0, t.getRetryCount(), "查不通不该消耗退避档位");
|
|
|
|
|
- assertEquals(CREATED.plusMinutes(5), t.getNextRetryAt());
|
|
|
|
|
- verify(transferMapper, never()).updateById(any());
|
|
|
|
|
- verify(openapiService, never()).notifyTransferResult(any(), any(), any());
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ==================== 5. 隔离与空集 ====================
|
|
|
|
|
-
|
|
|
|
|
- @Test
|
|
|
|
|
- void oneRowThrowing_doesNotStopTheRest() {
|
|
|
|
|
- TransferEntity bad = dealing("BANKCARD_ACCOUNT", 0);
|
|
|
|
|
- bad.setId(1L);
|
|
|
|
|
- TransferEntity good = dealing("BANKCARD_ACCOUNT", 0);
|
|
|
|
|
- good.setId(2L);
|
|
|
|
|
- good.setOutBizNo("OB2");
|
|
|
|
|
- givenPending(bad, good);
|
|
|
|
|
-
|
|
|
|
|
- doThrow(new RuntimeException("boom")).doReturn(Map.of("status", "SUCCESS"))
|
|
|
|
|
- .when(service).queryTransferStatus(any(), any(), any());
|
|
|
|
|
-
|
|
|
|
|
- assertDoesNotThrow(() -> service.retryDealingTransfers());
|
|
|
|
|
-
|
|
|
|
|
- assertEquals("DEALING", bad.getStatus());
|
|
|
|
|
- assertEquals("SUCCESS", good.getStatus(), "前一行的异常不能吃掉后一行");
|
|
|
|
|
- verify(transferMapper).updateById(good);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @Test
|
|
|
|
|
- void noPendingRows_touchesNothing() {
|
|
|
|
|
- when(transferMapper.selectList(any())).thenReturn(List.of());
|
|
|
|
|
-
|
|
|
|
|
- assertDoesNotThrow(() -> service.retryDealingTransfers());
|
|
|
|
|
-
|
|
|
|
|
- verify(transferMapper, never()).updateById(any());
|
|
|
|
|
- verifyNoInteractions(openapiService);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ==================== 6. 查询谓词(四个条件都在 SQL 段里) ====================
|
|
|
|
|
-
|
|
|
|
|
- @Test
|
|
|
|
|
- void pendingQuery_filtersOnAllFourConditions_inNextRetryOrder() {
|
|
|
|
|
- when(transferMapper.selectList(any())).thenReturn(List.of());
|
|
|
|
|
-
|
|
|
|
|
- service.retryDealingTransfers();
|
|
|
|
|
-
|
|
|
|
|
- @SuppressWarnings("unchecked")
|
|
|
|
|
- ArgumentCaptor<LambdaQueryWrapper<TransferEntity>> cap =
|
|
|
|
|
- ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
|
|
- verify(transferMapper).selectList(cap.capture());
|
|
|
|
|
- String sql = cap.getValue().getSqlSegment();
|
|
|
|
|
- assertAll(
|
|
|
|
|
- () -> assertTrue(sql.contains("status"), "缺 status: " + sql),
|
|
|
|
|
- () -> assertTrue(sql.contains("retry_count"), "缺 retry_count(否则会把已终态的行反复捞出来): " + sql),
|
|
|
|
|
- () -> assertTrue(sql.contains("next_retry_at"), "缺 next_retry_at: " + sql),
|
|
|
|
|
- () -> assertTrue(sql.contains("ORDER BY"), "缺排序: " + sql));
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|