|
@@ -0,0 +1,502 @@
|
|
|
|
|
+package com.payment.platform.module.payment.unified.service;
|
|
|
|
|
+
|
|
|
|
|
+import ch.qos.logback.classic.Level;
|
|
|
|
|
+import ch.qos.logback.classic.Logger;
|
|
|
|
|
+import ch.qos.logback.classic.spi.ILoggingEvent;
|
|
|
|
|
+import ch.qos.logback.core.read.ListAppender;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
|
|
+import com.fasterxml.jackson.core.JsonProcessingException;
|
|
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
|
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
|
|
|
|
|
+import com.payment.platform.module.payment.unified.client.UnifiedApiException;
|
|
|
|
|
+import com.payment.platform.module.payment.unified.client.UnifiedClient;
|
|
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
|
|
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
|
|
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
|
|
|
|
|
+import com.payment.platform.module.payment.unified.mapper.UnifiedRefundMapper;
|
|
|
|
|
+import com.payment.platform.module.payment.unified.util.UnifiedPollPolicy;
|
|
|
|
|
+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.mockito.ArgumentCaptor;
|
|
|
|
|
+import org.mockito.Mock;
|
|
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
|
|
+
|
|
|
|
|
+import java.time.Duration;
|
|
|
|
|
+import java.time.OffsetDateTime;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
|
|
+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.eq;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.isNull;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.same;
|
|
|
|
|
+import static org.mockito.Mockito.atLeastOnce;
|
|
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
|
|
+import static org.mockito.Mockito.verifyNoInteractions;
|
|
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 退款查单同步与退款轮询(spec §7.3 / §7.4)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>本类盯住「不会报错但会锁死额度 / 让轮询空转」的地方:
|
|
|
|
|
+ * <ol>
|
|
|
|
|
+ * <li>配置按订单的 {@code merchant_id} 取(不看启停),不按退款行上的 {@code mch_no};</li>
|
|
|
|
|
+ * <li>只有 {@code applyRefundState} 能写业务状态 —— 查单腿自己写状态会绕过订单联动;</li>
|
|
|
|
|
+ * <li>未知/缺失状态不得落库(R11);</li>
|
|
|
|
|
+ * <li>60 次 / 30 分钟停止后必须把 {@code next_query_time} 置空;</li>
|
|
|
|
|
+ * <li>上游「退款单不存在」的业务拒绝本期不置 3(设计 §14 #14 未定),
|
|
|
|
|
+ * 只能保持 0 并记 WARN —— 置 3 会提前释放额度,冒超退风险。</li>
|
|
|
|
|
+ * </ol>
|
|
|
|
|
+ */
|
|
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
|
|
+class UnifiedRefundSyncTest {
|
|
|
|
|
+
|
|
|
|
|
+ private static final ObjectMapper JSON = new ObjectMapper();
|
|
|
|
|
+ private static final Long TENANT = 2L;
|
|
|
|
|
+ private static final long ORDER_ID = 5L;
|
|
|
|
|
+ private static final long MERCHANT_ID = 11L;
|
|
|
|
|
+
|
|
|
|
|
+ @Mock private UnifiedRefundTxService tx;
|
|
|
|
|
+ @Mock private UnifiedRefundMapper refundMapper;
|
|
|
|
|
+ @Mock private UnifiedOrderService orderService;
|
|
|
|
|
+ @Mock private UnifiedMerchantService merchantService;
|
|
|
|
|
+ @Mock private UnifiedClient client;
|
|
|
|
|
+ @Mock private UnifiedCallbackService callbackService;
|
|
|
|
|
+ @Mock private OrderEventPublisher publisher;
|
|
|
|
|
+
|
|
|
|
|
+ private UnifiedRefundService service;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() {
|
|
|
|
|
+ service = new UnifiedRefundService(tx, refundMapper, orderService, merchantService,
|
|
|
|
|
+ client, callbackService, publisher);
|
|
|
|
|
+ MybatisConfiguration configuration = new MybatisConfiguration();
|
|
|
|
|
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
|
|
|
|
+ TableInfoHelper.initTableInfo(assistant, UnifiedRefundEntity.class);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────────────── 工具 ──────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ private static JsonNode json(String text) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return JSON.readTree(text);
|
|
|
|
|
+ } catch (JsonProcessingException e) {
|
|
|
|
|
+ throw new IllegalStateException("测试 JSON 写错了", e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static UnifiedMerchantEntity merchant() {
|
|
|
|
|
+ UnifiedMerchantEntity cfg = new UnifiedMerchantEntity();
|
|
|
|
|
+ cfg.setId(MERCHANT_ID);
|
|
|
|
|
+ cfg.setTenantId(TENANT);
|
|
|
|
|
+ cfg.setMchNo("M001");
|
|
|
|
|
+ cfg.setAppId("app-1");
|
|
|
|
|
+ cfg.setAppSecret("SECRET");
|
|
|
|
|
+ cfg.setStatus(UnifiedMerchantService.STATUS_ENABLED);
|
|
|
|
|
+ return cfg;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static UnifiedOrderEntity order() {
|
|
|
|
|
+ UnifiedOrderEntity o = new UnifiedOrderEntity();
|
|
|
|
|
+ o.setId(ORDER_ID);
|
|
|
|
|
+ o.setTenantId(TENANT);
|
|
|
|
|
+ o.setMerchantId(MERCHANT_ID);
|
|
|
|
|
+ o.setMchNo("M001");
|
|
|
|
|
+ o.setAppId("app-1");
|
|
|
|
|
+ o.setOrderNo("UP5");
|
|
|
|
|
+ o.setAmount(100L);
|
|
|
|
|
+ o.setCurrency("cny");
|
|
|
|
|
+ o.setTradeStatus("2");
|
|
|
|
|
+ return o;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static UnifiedRefundEntity refund(String state) {
|
|
|
|
|
+ UnifiedRefundEntity r = new UnifiedRefundEntity();
|
|
|
|
|
+ r.setId(9L);
|
|
|
|
|
+ r.setTenantId(TENANT);
|
|
|
|
|
+ r.setOrderId(ORDER_ID);
|
|
|
|
|
+ r.setOrderNo("UP5");
|
|
|
|
|
+ r.setRefundNo("UR1");
|
|
|
|
|
+ r.setOutRefundNo("R1");
|
|
|
|
|
+ r.setMchNo("M001");
|
|
|
|
|
+ r.setAppId("app-1");
|
|
|
|
|
+ r.setApiKey("KEY-1");
|
|
|
|
|
+ r.setSource("OPENAPI");
|
|
|
|
|
+ r.setAmount(100L);
|
|
|
|
|
+ r.setCurrency("cny");
|
|
|
|
|
+ r.setRefundReason("测试退款");
|
|
|
|
|
+ r.setState(state);
|
|
|
|
|
+ r.setQueryCount(0);
|
|
|
|
|
+ r.setNotifyStatus("PENDING");
|
|
|
|
|
+ r.setCreatedTime(OffsetDateTime.now());
|
|
|
|
|
+ r.setNextQueryTime(OffsetDateTime.now().minusSeconds(5));
|
|
|
|
|
+ return r;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void stubOrderAndMerchant() {
|
|
|
|
|
+ when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(order());
|
|
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static void assertScheduledIn(UnifiedRefundEntity refund, long expectedSeconds) {
|
|
|
|
|
+ assertNotNull(refund.getNextQueryTime(), "非终态退款必须安排下一次查询");
|
|
|
|
|
+ long actual = Duration.between(OffsetDateTime.now(), refund.getNextQueryTime()).toSeconds();
|
|
|
|
|
+ assertTrue(actual >= expectedSeconds - 1 && actual <= expectedSeconds + 2,
|
|
|
|
|
+ "期望约 " + expectedSeconds + "s 后查询,实测 " + actual + "s");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ private static ArgumentCaptor<LambdaUpdateWrapper<UnifiedRefundEntity>> updateCaptor() {
|
|
|
|
|
+ return ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static ListAppender<ILoggingEvent> attachLog(Class<?> type) {
|
|
|
|
|
+ Logger logger = (Logger) LoggerFactory.getLogger(type);
|
|
|
|
|
+ ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
|
|
|
|
+ appender.start();
|
|
|
|
|
+ logger.addAppender(appender);
|
|
|
|
|
+ return appender;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 查单腿不得自己写业务状态:所有 UPDATE 的 SET 子句都不许出现 state 列。 */
|
|
|
|
|
+ private void assertNoDirectStateWrite() {
|
|
|
|
|
+ ArgumentCaptor<LambdaUpdateWrapper<UnifiedRefundEntity>> w = updateCaptor();
|
|
|
|
|
+ verify(refundMapper, atLeastOnce()).update(isNull(), w.capture());
|
|
|
|
|
+ for (LambdaUpdateWrapper<UnifiedRefundEntity> each : w.getAllValues()) {
|
|
|
|
|
+ String set = each.getSqlSet();
|
|
|
|
|
+ assertFalse(set != null && set.contains("state"),
|
|
|
|
|
+ "业务状态只能由 applyRefundState 写,实测 SET: " + set);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────────────── 配置取用 ──────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void sync_usesOrderMerchantIdConfigAndIgnoresStatus() {
|
|
|
|
|
+ UnifiedMerchantEntity cfg = merchant();
|
|
|
|
|
+ cfg.setStatus("1"); // 停用:历史退款还要继续收敛,查单腿不看 status
|
|
|
|
|
+ when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(order());
|
|
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(cfg);
|
|
|
|
|
+ // 状态不变,避免触发事务层:本测试只钉配置取用路径
|
|
|
|
|
+ when(client.queryRefund(same(cfg), eq("UR1"))).thenReturn(json("{\"state\":0}"));
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("0");
|
|
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
|
|
+
|
|
|
|
|
+ verify(orderService).findByOrderNo(isNull(), eq("UP5"));
|
|
|
|
|
+ verify(merchantService).requireById(MERCHANT_ID);
|
|
|
|
|
+ verify(merchantService, never()).requireByMchNo(any());
|
|
|
|
|
+ verify(merchantService, never()).resolveForTenant(any(), any());
|
|
|
|
|
+ verify(merchantService, never()).requireEnabled(any());
|
|
|
|
|
+ verify(client).queryRefund(same(cfg), eq("UR1"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────────────── 状态收敛 ──────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void stateTwo_appliesWithPayAmountAndPublishes() {
|
|
|
|
|
+ UnifiedOrderEntity order = order();
|
|
|
|
|
+ stubOrderAndMerchant();
|
|
|
|
|
+ // channelExtra 类敏感字段只允许进白名单快照,不得原样进快照/回调
|
|
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
|
|
|
|
|
+ "{\"state\":2,\"payAmount\":100,\"refundOrderId\":\"RF1\","
|
|
|
|
|
+ + "\"channelExtra\":{\"auth_code\":\"CODE\"},\"openid\":\"OPEN\"}"));
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity applied = refund("2");
|
|
|
|
|
+ when(tx.apply(eq("UR1"), eq("2"), any(JsonNode.class), eq(100L), isNull(), isNull()))
|
|
|
|
|
+ .thenReturn(new UnifiedRefundTxService.ApplyOutcome(
|
|
|
|
|
+ true, false, false, applied, order));
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("1");
|
|
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
|
|
+
|
|
|
|
|
+ ArgumentCaptor<JsonNode> snapshot = ArgumentCaptor.forClass(JsonNode.class);
|
|
|
|
|
+ verify(tx).apply(eq("UR1"), eq("2"), snapshot.capture(), eq(100L), isNull(), isNull());
|
|
|
|
|
+ assertNull(snapshot.getValue().get("channelExtra"),
|
|
|
|
|
+ "快照必须走白名单,不得带 channelExtra");
|
|
|
|
|
+ assertNull(snapshot.getValue().get("openid"), "快照必须走白名单,不得带 openid");
|
|
|
|
|
+ assertEquals("RF1", snapshot.getValue().get("refundOrderId").asText());
|
|
|
|
|
+
|
|
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
|
|
|
|
|
+ // 退款回调的 upstream 也必须是白名单快照
|
|
|
|
|
+ ArgumentCaptor<JsonNode> upstream = ArgumentCaptor.forClass(JsonNode.class);
|
|
|
|
|
+ verify(callbackService).notifyRefund(same(applied), same(order), upstream.capture());
|
|
|
|
|
+ assertNull(upstream.getValue().get("openid"), "回调 upstream 不得带敏感字段");
|
|
|
|
|
+ assertEquals(1, refund.getQueryCount(), "上游响应算一次查询");
|
|
|
|
|
+ assertNoDirectStateWrite();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 上游 state 与本地相同:无迁移,只按退避续排。 */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void stateUnchanged_schedulesBackoff() {
|
|
|
|
|
+ stubOrderAndMerchant();
|
|
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json("{\"state\":1}"));
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("1");
|
|
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
|
|
+
|
|
|
|
|
+ verifyNoInteractions(tx, publisher, callbackService);
|
|
|
|
|
+ assertEquals("1", refund.getState());
|
|
|
|
|
+ assertEquals(1, refund.getQueryCount());
|
|
|
|
|
+ assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
|
|
|
|
|
+ assertNoDirectStateWrite();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void generatedToRefunding_appliesWithoutEvents() {
|
|
|
|
|
+ stubOrderAndMerchant();
|
|
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json("{\"state\":1}"));
|
|
|
|
|
+ when(tx.apply(eq("UR1"), eq("1"), any(JsonNode.class), isNull(), isNull(), isNull()))
|
|
|
|
|
+ .thenReturn(new UnifiedRefundTxService.ApplyOutcome(
|
|
|
|
|
+ false, false, false, refund("1"), null));
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("0");
|
|
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
|
|
+
|
|
|
|
|
+ verify(tx).apply(eq("UR1"), eq("1"), any(JsonNode.class), isNull(), isNull(), isNull());
|
|
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
|
|
+ assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void unknownState_doesNotAdvance() {
|
|
|
|
|
+ stubOrderAndMerchant();
|
|
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json("{\"state\":99}"));
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("0");
|
|
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("0", refund.getState(), "R11:未知状态不得落库");
|
|
|
|
|
+ assertEquals(1, refund.getQueryCount(), "未知状态也算一次查询");
|
|
|
|
|
+ assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
|
|
|
|
|
+ verifyNoInteractions(tx, publisher, callbackService);
|
|
|
|
|
+ assertNoDirectStateWrite();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void missingState_doesNotAdvance() {
|
|
|
|
|
+ stubOrderAndMerchant();
|
|
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json("{\"payAmount\":100}"));
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("1");
|
|
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("1", refund.getState());
|
|
|
|
|
+ assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
|
|
|
|
|
+ verifyNoInteractions(tx, publisher, callbackService);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────────────── 计数与退避 ──────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void eachSyncCountsOneQuery_andBackoffFollowsPolicy() {
|
|
|
|
|
+ // 退避索引取 queryCount - 1(recordQueryAttempt 先计数),首档 5s
|
|
|
|
|
+ long[] expectedSeconds = {5, 10, 20, 30, 60, 120, 300};
|
|
|
|
|
+ for (int initial = 0; initial <= 6; initial++) {
|
|
|
|
|
+ stubOrderAndMerchant();
|
|
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json("{\"state\":1}"));
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("1");
|
|
|
|
|
+ refund.setQueryCount(initial);
|
|
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(initial + 1, refund.getQueryCount(),
|
|
|
|
|
+ "每次同步必须 +1(初始 " + initial + ")");
|
|
|
|
|
+ assertScheduledIn(refund, expectedSeconds[initial]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void recordQueryAttempt_usesAtomicSqlIncrement() {
|
|
|
|
|
+ stubOrderAndMerchant();
|
|
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json("{\"state\":1}"));
|
|
|
|
|
+
|
|
|
|
|
+ service.syncFromUpstream(refund("1"));
|
|
|
|
|
+
|
|
|
|
|
+ ArgumentCaptor<LambdaUpdateWrapper<UnifiedRefundEntity>> w = updateCaptor();
|
|
|
|
|
+ verify(refundMapper, atLeastOnce()).update(isNull(), w.capture());
|
|
|
|
|
+ assertTrue(w.getAllValues().stream()
|
|
|
|
|
+ .map(LambdaUpdateWrapper::getSqlSet)
|
|
|
|
|
+ .anyMatch(s -> s != null
|
|
|
|
|
+ && s.replace(" ", "").contains("query_count=query_count+1")),
|
|
|
|
|
+ "计数必须走 SQL 侧自增,实测: "
|
|
|
|
|
+ + w.getAllValues().stream().map(LambdaUpdateWrapper::getSqlSet).toList());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void queryCountAtLimit_clearsNextQueryTimeAfterFinalQuery() {
|
|
|
|
|
+ stubOrderAndMerchant();
|
|
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json("{\"state\":1}"));
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("1");
|
|
|
|
|
+ refund.setQueryCount(UnifiedPollPolicy.MAX_QUERY_COUNT - 1);
|
|
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
|
|
+
|
|
|
|
|
+ verify(client).queryRefund(any(), eq("UR1"));
|
|
|
|
|
+ assertEquals(UnifiedPollPolicy.MAX_QUERY_COUNT, refund.getQueryCount());
|
|
|
|
|
+ assertNull(refund.getNextQueryTime(), "达到 60 次上限后置空 next_query_time");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void syncFromUpstream_atQueryLimit_skipsUpstreamAndClearsSchedule() {
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("1");
|
|
|
|
|
+ refund.setQueryCount(UnifiedPollPolicy.MAX_QUERY_COUNT);
|
|
|
|
|
+
|
|
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
|
|
+
|
|
|
|
|
+ verifyNoInteractions(client, merchantService, orderService);
|
|
|
|
|
+ assertNull(refund.getNextQueryTime(), "自动入口到上限只清空排期");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void createdBeforeThirtyMinutes_skipsUpstreamAndClearsSchedule() {
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("0");
|
|
|
|
|
+ refund.setCreatedTime(OffsetDateTime.now()
|
|
|
|
|
+ .minusMinutes(UnifiedPollPolicy.MAX_AGE_MINUTES + 1));
|
|
|
|
|
+
|
|
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
|
|
+
|
|
|
|
|
+ assertNull(refund.getNextQueryTime(), "超过 30 分钟置空 next_query_time");
|
|
|
|
|
+ verifyNoInteractions(client, merchantService, orderService);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ──────────────────────── 上游「退款单不存在」(设计 §14 #14) ────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void businessRejection_keepsStateAndOnlyWarns() {
|
|
|
|
|
+ stubOrderAndMerchant();
|
|
|
|
|
+ when(client.queryRefund(any(), eq("UR1")))
|
|
|
|
|
+ .thenThrow(new UnifiedApiException(5, "退款单不存在"));
|
|
|
|
|
+
|
|
|
|
|
+ ListAppender<ILoggingEvent> appender = attachLog(UnifiedRefundService.class);
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("0");
|
|
|
|
|
+ try {
|
|
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ ((Logger) LoggerFactory.getLogger(UnifiedRefundService.class))
|
|
|
|
|
+ .detachAppender(appender);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("0", refund.getState(),
|
|
|
|
|
+ "设计 §14 #14 未定:业务拒绝本期不得置 3(会提前释放额度)");
|
|
|
|
|
+ verifyNoInteractions(tx, publisher, callbackService);
|
|
|
|
|
+ assertEquals(1, refund.getQueryCount(), "上游回了响应也算一次查询");
|
|
|
|
|
+ assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
|
|
|
|
|
+ assertTrue(appender.list.stream().anyMatch(ev ->
|
|
|
|
|
+ ev.getLevel() == Level.WARN
|
|
|
|
|
+ && ev.getFormattedMessage().contains("UR1")
|
|
|
|
|
+ && ev.getFormattedMessage().contains("退款单不存在")),
|
|
|
|
|
+ "必须记 WARN(含退款号与上游消息),实测: "
|
|
|
|
|
+ + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
|
|
|
|
|
+ assertNoDirectStateWrite();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void transportFailure_propagatesWithoutCounting() {
|
|
|
|
|
+ stubOrderAndMerchant();
|
|
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenThrow(
|
|
|
|
|
+ new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT, "connect timeout"));
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("0");
|
|
|
|
|
+ OffsetDateTime originalNext = refund.getNextQueryTime();
|
|
|
|
|
+
|
|
|
|
|
+ assertThrows(UnifiedApiException.class, () -> service.syncFromUpstream(refund));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(0, refund.getQueryCount(), "传输失败不算查询次数");
|
|
|
|
|
+ assertEquals(originalNext, refund.getNextQueryTime(), "传输失败不排下一轮");
|
|
|
|
|
+ verifyNoInteractions(tx, refundMapper, publisher, callbackService);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void missingOrder_doesNotQueryAndClearsSchedule() {
|
|
|
|
|
+ when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(null);
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("0");
|
|
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
|
|
+
|
|
|
|
|
+ verifyNoInteractions(client, merchantService);
|
|
|
|
|
+ assertNull(refund.getNextQueryTime(), "数据损坏时清空排期,避免每轮空转");
|
|
|
|
|
+ assertEquals(0, refund.getQueryCount(), "不计数:数据修复前不白烧重试次数");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────────────── 手动同步(R39) ──────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void syncManually_queriesOverAgeRefundButDoesNotReopenPolling() {
|
|
|
|
|
+ stubOrderAndMerchant();
|
|
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json("{\"state\":1}"));
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("1");
|
|
|
|
|
+ refund.setCreatedTime(OffsetDateTime.now()
|
|
|
|
|
+ .minusMinutes(UnifiedPollPolicy.MAX_AGE_MINUTES + 1));
|
|
|
|
|
+ service.syncManually(refund);
|
|
|
|
|
+
|
|
|
|
|
+ verify(client).queryRefund(any(), eq("UR1"));
|
|
|
|
|
+ assertEquals(1, refund.getQueryCount(), "手动同步本身也是一次查询");
|
|
|
|
|
+ assertNull(refund.getNextQueryTime(), "停止态手动同步不得重开自动轮询");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void syncManually_atQueryLimitQueriesButKeepsStopped() {
|
|
|
|
|
+ stubOrderAndMerchant();
|
|
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json("{\"state\":1}"));
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("1");
|
|
|
|
|
+ refund.setQueryCount(UnifiedPollPolicy.MAX_QUERY_COUNT);
|
|
|
|
|
+ service.syncManually(refund);
|
|
|
|
|
+
|
|
|
|
|
+ verify(client).queryRefund(any(), eq("UR1"));
|
|
|
|
|
+ assertEquals(UnifiedPollPolicy.MAX_QUERY_COUNT + 1, refund.getQueryCount());
|
|
|
|
|
+ assertNull(refund.getNextQueryTime(), "停止态手动同步不得重开自动轮询");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void syncManually_whenNotStopped_schedulesNormally() {
|
|
|
|
|
+ stubOrderAndMerchant();
|
|
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json("{\"state\":1}"));
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("1");
|
|
|
|
|
+ service.syncManually(refund);
|
|
|
|
|
+
|
|
|
|
|
+ assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────────────── 退款身份校验 ──────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void nullRefundOrRefundNo_isNoOp() {
|
|
|
|
|
+ service.syncFromUpstream(null);
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity noNo = refund("0");
|
|
|
|
|
+ noNo.setRefundNo(null);
|
|
|
|
|
+ service.syncFromUpstream(noNo);
|
|
|
|
|
+
|
|
|
|
|
+ verifyNoInteractions(orderService, merchantService, client, refundMapper,
|
|
|
|
|
+ tx, publisher, callbackService);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 无状态变化也必须保留轮询主路径:清空 = 该退款永不收敛。 */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void noChange_neverClearsNextQueryTimeBeforeStopBoundary() {
|
|
|
|
|
+ stubOrderAndMerchant();
|
|
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json("{\"state\":0}"));
|
|
|
|
|
+
|
|
|
|
|
+ UnifiedRefundEntity refund = refund("0");
|
|
|
|
|
+ refund.setQueryCount(1);
|
|
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
|
|
+
|
|
|
|
|
+ assertNotNull(refund.getNextQueryTime(),
|
|
|
|
|
+ "未到停止边界时,无状态变化也必须续排(清空 = 该退款永不收敛)");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|