Просмотр исходного кода

feat(统一支付): 退款查询与退款轮询

- syncFromUpstream 实现退款查单:按订单 merchant_id 取配置、上游响应即计一次查询、
  状态只经 applyRefundState 收敛(R25②),未知/缺失状态不推进
- 每次查询后按 UnifiedPollPolicy 退避续排;满 60 次或创建超 30 分钟清空
  next_query_time 停止自动轮询(spec §7.3)
- 业务拒绝(退款单不存在等)只记 WARN、状态保持 0、计一次查询
  (设计 §14 #14 未定);传输/验签异常原样上抛、不计数
- 新增 syncManually:手动入口跳过停止条件,停止态查完仍保持 null,不重开轮询
- 新增 UnifiedRefundPollScheduler:默认 30 秒一批、批量来自 poll-batch,
  扫描失败放弃本轮、单笔失败不拖垮整批
- 测试:UnifiedRefundSyncTest 19 例 + UnifiedRefundPollSchedulerTest 4 例
alphaH 1 неделя назад
Родитель
Сommit
4d508e24bb

+ 56 - 0
java/src/main/java/com/payment/platform/module/payment/unified/scheduler/UnifiedRefundPollScheduler.java

@@ -0,0 +1,56 @@
+package com.payment.platform.module.payment.unified.scheduler;
+
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
+import com.payment.platform.module.payment.unified.service.UnifiedRefundService;
+import com.payment.platform.module.payment.unified.util.UnifiedLogSanitizer;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * 退款轮询(spec §7.3):每 {@code unified.pay.refund-poll-interval-ms}(默认 30 秒)
+ * 扫一批到期在途退款,逐笔向上游查退款。
+ *
+ * <p>两道异常边界都在本层:扫描失败放弃本轮;单笔失败只记日志、继续下一笔 ——
+ * 本任务跑在 {@code @Scheduled} 线程上,而 {@code spring.task.scheduling.pool.size}
+ * 默认是 1,逃逸异常会把整族定时任务一起饿死。
+ */
+@Slf4j
+@Component
+public class UnifiedRefundPollScheduler {
+
+    private final UnifiedRefundService refundService;
+    private final UnifiedPayProperties properties;
+
+    public UnifiedRefundPollScheduler(UnifiedRefundService refundService,
+                                      UnifiedPayProperties properties) {
+        this.refundService = refundService;
+        this.properties = properties;
+    }
+
+    @Scheduled(fixedDelayString = "${unified.pay.refund-poll-interval-ms:30000}")
+    public void poll() {
+        List<UnifiedRefundEntity> refunds;
+        try {
+            refunds = refundService.findPendingForPoll(properties.getPollBatch());
+        } catch (Exception e) {
+            log.error("[统一支付退款轮询] 扫描失败: {}", UnifiedLogSanitizer.flatten(e.getMessage()), e);
+            return;
+        }
+        if (refunds == null || refunds.isEmpty()) {
+            return;
+        }
+        for (UnifiedRefundEntity refund : refunds) {
+            try {
+                refundService.syncFromUpstream(refund);
+            } catch (Exception e) {
+                // 单笔失败不能拖垮整批
+                log.warn("[统一支付退款轮询] 单笔查询失败: refund_no={}, error={}",
+                        refund.getRefundNo(), UnifiedLogSanitizer.flatten(e.getMessage()));
+            }
+        }
+    }
+}

+ 115 - 4
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedRefundService.java

@@ -17,6 +17,7 @@ import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
 import com.payment.platform.module.payment.unified.enums.UnifiedRefundStatus;
 import com.payment.platform.module.payment.unified.enums.UnifiedSource;
 import com.payment.platform.module.payment.unified.mapper.UnifiedRefundMapper;
+import com.payment.platform.module.payment.unified.util.UnifiedLogSanitizer;
 import com.payment.platform.module.payment.unified.util.UnifiedPollPolicy;
 import com.payment.platform.module.payment.unified.util.UnifiedUpstreamSnapshot;
 import lombok.RequiredArgsConstructor;
@@ -355,13 +356,123 @@ public class UnifiedRefundService {
     }
 
     /**
-     * 退款查单并收敛状态(Task 13 实现)。
+     * 自动轮询入口({@code UnifiedRefundPollScheduler} 消费):查退款并收敛状态。
      *
-     * <p>Task 12 只声明契约:查单腿依赖的任务(调度器、通知解析)尚未合入,先显式失败,
-     * 避免「调用了但什么都没做」这种更难发现的问题。
+     * <p>先判停止条件:已满 {@link UnifiedPollPolicy#MAX_QUERY_COUNT} 次或创建超过
+     * {@link UnifiedPollPolicy#MAX_AGE_MINUTES} 分钟 → 不打上游,直接把
+     * {@code next_query_time} 置空。手动入口见 {@link #syncManually(UnifiedRefundEntity)}。
      */
     public void syncFromUpstream(UnifiedRefundEntity refund) {
-        throw new UnsupportedOperationException("统一支付退款查单由 Task 13 实现");
+        sync(refund, false);
+    }
+
+    /**
+     * 手动同步入口(后台「同步上游」,Task 16 控制器必须调用本方法)。
+     *
+     * <p>与自动轮询的唯一区别:<b>入口不判停止条件</b> —— spec §7.3 承诺「轮询已停止」的
+     * 退款仍可手动同步一次。查单、计数、条件 UPDATE、事件/回调全部相同;停止态查完保持
+     * {@code next_query_time = null},不重开自动轮询。
+     */
+    public void syncManually(UnifiedRefundEntity refund) {
+        sync(refund, true);
+    }
+
+    /**
+     * 查退款并收敛状态的共用实现。
+     *
+     * <p>六个刻意保持的顺序/口径:
+     *
+     * <ol>
+     *   <li><b>自动入口先判停止条件</b>(手动入口跳过):到上限不打上游,直接清空排期。</li>
+     *   <li><b>订单按 {@code order_no} 全局找、配置按订单的 {@code merchant_id} 取</b>:
+     *       退款行上的 {@code mch_no} 只是快照;{@link UnifiedMerchantService#requireById}
+     *       不校验 {@code status} —— 停用商户的历史退款还要继续收敛。</li>
+     *   <li><b>{@code recordQueryAttempt} 在解析状态之前</b>:上游回了响应就消耗一次查询,
+     *       未知状态同样计数,否则这类单会无限轮询。</li>
+     *   <li><b>状态只走 {@link #applyRefundState}</b>:订单联动、事件与回调都在那里。</li>
+     *   <li><b>业务拒绝保持 0</b>(设计 §14 #14 未定):退款单不存在时上游错误码语义待联调,
+     *       本期不置 3 —— 置 3 会提前释放额度,冒超退风险。</li>
+     *   <li><b>停止态查完一律保持 null</b>,不重开自动轮询。</li>
+     * </ol>
+     *
+     * <p>上游传输/验签异常直接抛出:调度器逐单兜住,手动同步由控制器转成失败应答。
+     */
+    private void sync(UnifiedRefundEntity refund, boolean manual) {
+        if (refund == null || refund.getRefundNo() == null) {
+            return;
+        }
+        if (!manual && shouldStopPolling(refund)) {
+            scheduleNextQuery(refund, null);
+            return;
+        }
+
+        // 无认证上下文:order_no 全局唯一;退款行的 mch_no 只是快照,不能用来定位配置
+        UnifiedOrderEntity order = orderService.findByOrderNo(null, refund.getOrderNo());
+        if (order == null || order.getMerchantId() == null) {
+            // 数据损坏:不改状态、不计数;清空排期避免每轮空转
+            log.error("[统一支付退款查询] 找不到所属订单或商户,停止轮询: refund_no={}, order_no={}",
+                    refund.getRefundNo(), UnifiedLogSanitizer.flatten(refund.getOrderNo()));
+            scheduleNextQuery(refund, null);
+            return;
+        }
+        UnifiedMerchantEntity cfg = merchantService.requireById(order.getMerchantId());
+
+        JsonNode data;
+        try {
+            data = client.queryRefund(cfg, refund.getRefundNo());
+        } catch (UnifiedApiException e) {
+            if (!e.isBusinessRejection()) {
+                // 传输/验签失败:那笔可能已经成功,不计数、不改状态
+                throw e;
+            }
+            // 设计 §14 #14:退款单不存在等业务拒绝的错误码语义待联调确认。
+            // 本期保持 0(宁可锁额度也不冒超退风险),只记 WARN。
+            log.warn("[统一支付退款查询] 上游业务拒绝,状态保持不变: refund_no={}, code={}, msg={}",
+                    refund.getRefundNo(), e.getCode(),
+                    UnifiedLogSanitizer.flatten(e.getMessage()));
+            recordQueryAttempt(refund);
+            scheduleAfterQuery(refund);
+            return;
+        }
+
+        // 上游回了响应(无论状态是否可识别)就算一次查询;必须先于任何解析/分支
+        recordQueryAttempt(refund);
+
+        String state = UnifiedResponseParser.pickState(data);
+        if (UnifiedRefundStatus.canTransition(refund.getState(), state)) {
+            // 唯一收敛入口:订单联动/事件/回调都在这里(R25②)
+            applyRefundState(refund.getRefundNo(), state, UnifiedUpstreamSnapshot.of(data),
+                    payAmountOf(data), textOf(data, "errCode"), textOf(data, "errMsg"));
+        }
+        if (state != null && UnifiedRefundStatus.FINAL.contains(state)) {
+            // 事务层已写 next_query_time = null;条件 UPDATE 落空时另一边也已是终态
+            return;
+        }
+        scheduleAfterQuery(refund);
+    }
+
+    /** 无状态变化/业务拒绝后的排期:到停止边界置空,否则按退避续排。 */
+    private void scheduleAfterQuery(UnifiedRefundEntity refund) {
+        if (shouldStopPolling(refund)) {
+            scheduleNextQuery(refund, null);
+            return;
+        }
+        int queryCount = refund.getQueryCount() == null ? 0 : refund.getQueryCount();
+        scheduleNextQuery(refund,
+                nextQueryTimeWithinWindow(refund, OffsetDateTime.now(), queryCount));
+    }
+
+    /**
+     * 轮询停止条件(spec §7.3):满 60 次或创建超过 30 分钟。
+     *
+     * <p>{@code created_time} 缺失时按「刚创建」处理 —— 不能把数据损坏当成「很老」
+     * 而让这笔退款永久停轮。
+     */
+    private static boolean shouldStopPolling(UnifiedRefundEntity refund) {
+        int queryCount = refund.getQueryCount() == null ? 0 : refund.getQueryCount();
+        OffsetDateTime createdAt = refund.getCreatedTime() == null
+                ? OffsetDateTime.now() : refund.getCreatedTime();
+        return UnifiedPollPolicy.shouldStop(queryCount, createdAt, OffsetDateTime.now());
     }
 
     /** 查询计数 +1 并记时间;内存同步。 */

+ 191 - 0
java/src/test/java/com/payment/platform/module/payment/unified/scheduler/UnifiedRefundPollSchedulerTest.java

@@ -0,0 +1,191 @@
+package com.payment.platform.module.payment.unified.scheduler;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+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.config.UnifiedPayProperties;
+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.service.UnifiedCallbackService;
+import com.payment.platform.module.payment.unified.service.UnifiedMerchantService;
+import com.payment.platform.module.payment.unified.service.UnifiedOrderService;
+import com.payment.platform.module.payment.unified.service.UnifiedRefundService;
+import com.payment.platform.module.payment.unified.service.UnifiedRefundTxService;
+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.springframework.scheduling.annotation.Scheduled;
+
+import java.lang.reflect.Method;
+import java.time.OffsetDateTime;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 退款轮询调度器(spec §7.3):扫描到期在途退款、逐笔查上游、单笔失败不拖垮整批。
+ *
+ * <p>与订单调度器同一条跨层契约:本任务跑在 {@code @Scheduled} 线程上,而
+ * {@code spring.task.scheduling.pool.size} 默认是 1 —— 任何逃逸异常都会把整族定时任务饿死。
+ * 因此这里用**真实** {@link UnifiedRefundService}(mapper/client 仍是 mock),
+ * 让异常真正从 {@code syncFromUpstream} 逃逸出来验证调度器的 try/catch。
+ */
+@ExtendWith(MockitoExtension.class)
+class UnifiedRefundPollSchedulerTest {
+
+    private static final ObjectMapper JSON = new ObjectMapper();
+    private static final Long TENANT = 2L;
+    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 UnifiedPayProperties props;
+    private UnifiedRefundService refundService;
+    private UnifiedRefundPollScheduler scheduler;
+
+    @BeforeEach
+    void setUp() {
+        props = new UnifiedPayProperties();
+        refundService = new UnifiedRefundService(tx, refundMapper, orderService, merchantService,
+                client, callbackService, publisher);
+        scheduler = new UnifiedRefundPollScheduler(refundService, props);
+        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(5L);
+        o.setTenantId(TENANT);
+        o.setMerchantId(MERCHANT_ID);
+        o.setMchNo("M001");
+        o.setOrderNo("UP5");
+        o.setAmount(100L);
+        o.setCurrency("cny");
+        o.setTradeStatus("2");
+        return o;
+    }
+
+    private static UnifiedRefundEntity refund(String refundNo) {
+        UnifiedRefundEntity r = new UnifiedRefundEntity();
+        r.setId(9L);
+        r.setTenantId(TENANT);
+        r.setOrderId(5L);
+        r.setOrderNo("UP5");
+        r.setRefundNo(refundNo);
+        r.setOutRefundNo("R-" + refundNo);
+        r.setMchNo("M001");
+        r.setAmount(100L);
+        r.setCurrency("cny");
+        r.setRefundReason("测试退款");
+        r.setState("1");
+        r.setQueryCount(0);
+        r.setNotifyStatus("PENDING");
+        r.setCreatedTime(OffsetDateTime.now());
+        r.setNextQueryTime(OffsetDateTime.now().minusSeconds(5));
+        return r;
+    }
+
+    @Test
+    void poll_keepsProcessingAfterOneRefundFails() {
+        UnifiedRefundEntity first = refund("UR1");
+        UnifiedRefundEntity second = refund("UR2");
+        when(refundMapper.selectList(any())).thenReturn(List.of(first, second));
+
+        UnifiedMerchantEntity cfg = merchant();
+        when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(order());
+        when(merchantService.requireById(MERCHANT_ID)).thenReturn(cfg);
+        // 第一笔上游炸了:异常必须被逐笔 try/catch 吃掉,第二笔照常查
+        when(client.queryRefund(same(cfg), eq("UR1"))).thenThrow(
+                new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT, "connect timeout"));
+        when(client.queryRefund(same(cfg), eq("UR2"))).thenReturn(json("{\"state\":1}"));
+
+        scheduler.poll();
+
+        verify(client).queryRefund(same(cfg), eq("UR1"));
+        verify(client).queryRefund(same(cfg), eq("UR2"));
+        assertEquals(0, first.getQueryCount(), "上游失败不算查询次数");
+        assertEquals(1, second.getQueryCount(), "第二笔必须仍被处理");
+        assertNotNull(second.getNextQueryTime(), "第二笔应被安排下一次查询");
+    }
+
+    @Test
+    void poll_scansWithConfiguredBatch() {
+        when(refundMapper.selectList(any())).thenReturn(List.of());
+
+        scheduler.poll();
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<LambdaQueryWrapper<UnifiedRefundEntity>> w =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(refundMapper).selectList(w.capture());
+        assertTrue(w.getValue().getSqlSegment().contains("LIMIT " + props.getPollBatch()),
+                "扫描批量必须来自 unified.pay.poll-batch,实测: "
+                        + w.getValue().getSqlSegment());
+    }
+
+    @Test
+    void poll_scanFailureDoesNotEscape() {
+        when(refundMapper.selectList(any())).thenThrow(new RuntimeException("db down"));
+
+        assertDoesNotThrow(() -> scheduler.poll());
+    }
+
+    @Test
+    void poll_isScheduledWithConfigurableInterval() throws Exception {
+        Method method = UnifiedRefundPollScheduler.class.getMethod("poll");
+        Scheduled scheduled = method.getAnnotation(Scheduled.class);
+
+        assertNotNull(scheduled, "poll() 必须挂 @Scheduled");
+        assertEquals("${unified.pay.refund-poll-interval-ms:30000}",
+                scheduled.fixedDelayString(),
+                "退款轮询节拍来自统一支付配置,默认 30 秒");
+    }
+}

+ 502 - 0
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedRefundSyncTest.java

@@ -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(),
+                "未到停止边界时,无状态变化也必须续排(清空 = 该退款永不收敛)");
+    }
+}