|
@@ -0,0 +1,247 @@
|
|
|
|
|
+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));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|