Jelajahi Sumber

fix(统一支付): 退款查单金额/币种预检与数据损坏收口

- state=2 收敛前核对上游 refundAmount 必须存在且等于本地退款金额,
  currency 若返回则须与本地一致;不符只写 err_code=AMOUNT_MISMATCH、
  记 ERROR 并按退避续排,不放行 applyRefundState(spec §7.5)
- 订单缺失/merchantId 为空/商户配置 404 统一走 handleDataDamage:
  自动腿 ERROR+清空 next_query_time 且不抛;手动腿抛 BusinessException(500)
- 修正注释与测试中的 spec 引用:退款轮询 §7.4、退款单不存在 §14 #9
alphaH 1 Minggu lalu
induk
melakukan
a1effa6b51

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

@@ -11,7 +11,7 @@ import org.springframework.stereotype.Component;
 import java.util.List;
 
 /**
- * 退款轮询(spec §7.3):每 {@code unified.pay.refund-poll-interval-ms}(默认 30 秒)
+ * 退款轮询(spec §7.4):每 {@code unified.pay.refund-poll-interval-ms}(默认 30 秒)
  * 扫一批到期在途退款,逐笔向上游查退款。
  *
  * <p>两道异常边界都在本层:扫描失败放弃本轮;单笔失败只记日志、继续下一笔 ——

+ 116 - 13
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedRefundService.java

@@ -25,6 +25,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 
+import java.math.BigDecimal;
 import java.time.OffsetDateTime;
 import java.util.List;
 import java.util.Objects;
@@ -56,6 +57,12 @@ public class UnifiedRefundService {
     /** 轮询批量上限(防配置/入参把 SQL 撑成全表扫描)。 */
     private static final int POLL_LIMIT_MAX = 500;
 
+    /** 金额/币种核对失败的错误码(spec §7.3 / §7.5)。 */
+    private static final String AMOUNT_MISMATCH = "AMOUNT_MISMATCH";
+
+    /** 数据损坏(订单/商户配置不存在)时手动同步的失败应答。 */
+    private static final String DATA_DAMAGE_MESSAGE = "退款所属订单或商户配置不存在,无法同步上游";
+
     private final UnifiedRefundTxService txService;
     private final UnifiedRefundMapper refundMapper;
     private final UnifiedOrderService orderService;
@@ -369,7 +376,7 @@ public class UnifiedRefundService {
     /**
      * 手动同步入口(后台「同步上游」,Task 16 控制器必须调用本方法)。
      *
-     * <p>与自动轮询的唯一区别:<b>入口不判停止条件</b> —— spec §7.3 承诺「轮询已停止」的
+     * <p>与自动轮询的唯一区别:<b>入口不判停止条件</b> —— spec §7.4 承诺「轮询已停止」的
      * 退款仍可手动同步一次。查单、计数、条件 UPDATE、事件/回调全部相同;停止态查完保持
      * {@code next_query_time = null},不重开自动轮询。
      */
@@ -380,19 +387,22 @@ public class UnifiedRefundService {
     /**
      * 查退款并收敛状态的共用实现。
      *
-     * <p>六个刻意保持的顺序/口径:
+     * <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>数据损坏单独收口</b>:订单/商户配置不存在时记 ERROR、清空排期;
+     *       自动腿返回(不中断调度批次),手动腿抛 {@link BusinessException}(不假成功)。</li>
      *   <li><b>{@code recordQueryAttempt} 在解析状态之前</b>:上游回了响应就消耗一次查询,
      *       未知状态同样计数,否则这类单会无限轮询。</li>
+     *   <li><b>退款成功前先核对金额/币种</b>(与通知腿同一钱安全规则):{@code refundAmount}
+     *       必须存在且等于本地金额,{@code currency} 若存在必须一致;不符只记错误码、不推进。</li>
      *   <li><b>状态只走 {@link #applyRefundState}</b>:订单联动、事件与回调都在那里。</li>
-     *   <li><b>业务拒绝保持 0</b>(设计 §14 #14 未定):退款单不存在时上游错误码语义待联调,
-     *       本期不置 3 —— 置 3 会提前释放额度,冒超退风险。</li>
-     *   <li><b>停止态查完一律保持 null</b>,不重开自动轮询。</li>
+     *   <li><b>业务拒绝保持 0</b>(设计 §14 #9 未定):退款单不存在时上游错误码语义待联调,
+     *       本期不置 3 —— 置 3 会提前释放额度,冒超退风险;停止态查完一律保持 null。</li>
      * </ol>
      *
      * <p>上游传输/验签异常直接抛出:调度器逐单兜住,手动同步由控制器转成失败应答。
@@ -407,15 +417,24 @@ public class UnifiedRefundService {
         }
 
         // 无认证上下文:order_no 全局唯一;退款行的 mch_no 只是快照,不能用来定位配置
-        UnifiedOrderEntity order = orderService.findByOrderNo(null, refund.getOrderNo());
+        UnifiedOrderEntity order;
+        try {
+            order = orderService.findByOrderNo(null, refund.getOrderNo());
+        } catch (BusinessException e) {
+            handleDataDamage(refund, manual, e.getMessage());
+            return;
+        }
         if (order == null || order.getMerchantId() == null) {
-            // 数据损坏:不改状态、不计数;清空排期避免每轮空转
-            log.error("[统一支付退款查询] 找不到所属订单或商户,停止轮询: refund_no={}, order_no={}",
-                    refund.getRefundNo(), UnifiedLogSanitizer.flatten(refund.getOrderNo()));
-            scheduleNextQuery(refund, null);
+            handleDataDamage(refund, manual, "所属订单或商户 id 不存在");
+            return;
+        }
+        UnifiedMerchantEntity cfg;
+        try {
+            cfg = merchantService.requireById(order.getMerchantId());
+        } catch (BusinessException e) {
+            handleDataDamage(refund, manual, e.getMessage());
             return;
         }
-        UnifiedMerchantEntity cfg = merchantService.requireById(order.getMerchantId());
 
         JsonNode data;
         try {
@@ -425,7 +444,7 @@ public class UnifiedRefundService {
                 // 传输/验签失败:那笔可能已经成功,不计数、不改状态
                 throw e;
             }
-            // 设计 §14 #14:退款单不存在等业务拒绝的错误码语义待联调确认。
+            // 设计 §14 #9:退款单不存在等业务拒绝的错误码语义待联调确认。
             // 本期保持 0(宁可锁额度也不冒超退风险),只记 WARN。
             log.warn("[统一支付退款查询] 上游业务拒绝,状态保持不变: refund_no={}, code={}, msg={}",
                     refund.getRefundNo(), e.getCode(),
@@ -440,6 +459,13 @@ public class UnifiedRefundService {
 
         String state = UnifiedResponseParser.pickState(data);
         if (UnifiedRefundStatus.canTransition(refund.getState(), state)) {
+            if (UnifiedRefundStatus.SUCCESS.equals(state)
+                    && !refundAmountMatches(refund, data)) {
+                // 钱安全:金额/币种不符不推进状态,留在轮询主路径里等人工/上游修正
+                markAmountMismatch(refund, upstreamRefundAmountDetail(refund, data));
+                scheduleAfterQuery(refund);
+                return;
+            }
             // 唯一收敛入口:订单联动/事件/回调都在这里(R25②)
             applyRefundState(refund.getRefundNo(), state, UnifiedUpstreamSnapshot.of(data),
                     payAmountOf(data), textOf(data, "errCode"), textOf(data, "errMsg"));
@@ -451,6 +477,22 @@ public class UnifiedRefundService {
         scheduleAfterQuery(refund);
     }
 
+    /**
+     * 数据损坏(订单不存在 / 商户配置行不存在):不改状态、不计数、清空排期。
+     *
+     * <p>自动腿必须返回而不是抛出 —— 一行坏数据不能中断整批轮询;手动腿必须抛出,
+     * 否则控制器会把「什么都没查」当成功。
+     */
+    private void handleDataDamage(UnifiedRefundEntity refund, boolean manual, String reason) {
+        log.error("[统一支付退款查询] 数据损坏,无法同步上游: refund_no={}, order_no={}, reason={}",
+                refund.getRefundNo(), UnifiedLogSanitizer.flatten(refund.getOrderNo()),
+                UnifiedLogSanitizer.flatten(reason));
+        scheduleNextQuery(refund, null);
+        if (manual) {
+            throw new BusinessException(500, DATA_DAMAGE_MESSAGE);
+        }
+    }
+
     /** 无状态变化/业务拒绝后的排期:到停止边界置空,否则按退避续排。 */
     private void scheduleAfterQuery(UnifiedRefundEntity refund) {
         if (shouldStopPolling(refund)) {
@@ -463,7 +505,7 @@ public class UnifiedRefundService {
     }
 
     /**
-     * 轮询停止条件(spec §7.3):满 60 次或创建超过 30 分钟。
+     * 轮询停止条件(spec §7.4):满 60 次或创建超过 30 分钟。
      *
      * <p>{@code created_time} 缺失时按「刚创建」处理 —— 不能把数据损坏当成「很老」
      * 而让这笔退款永久停轮。
@@ -528,6 +570,67 @@ public class UnifiedRefundService {
         return next;
     }
 
+    /**
+     * 退款成功前的钱安全预检(spec §7.5 通知腿同一规则):{@code refundAmount} 必须存在
+     * 且等于本地金额;{@code currency} 若在响应中存在必须与本地币种一致(大小写不敏感)。
+     */
+    private static boolean refundAmountMatches(UnifiedRefundEntity refund, JsonNode data) {
+        if (refund == null || data == null || !data.isObject()) {
+            return false;
+        }
+        JsonNode amountNode = data.get("refundAmount");
+        boolean amountOk = amountNode != null && !amountNode.isNull()
+                && !amountNode.isContainerNode() && refund.getAmount() != null;
+        if (amountOk) {
+            try {
+                amountOk = new BigDecimal(amountNode.asText().trim())
+                        .compareTo(BigDecimal.valueOf(refund.getAmount())) == 0;
+            } catch (NumberFormatException e) {
+                amountOk = false;
+            }
+        }
+        if (!amountOk) {
+            return false;
+        }
+        JsonNode currencyNode = data.get("currency");
+        boolean currencyAbsent = currencyNode == null || currencyNode.isNull()
+                || currencyNode.isContainerNode() || currencyNode.asText().isBlank();
+        if (currencyAbsent) {
+            return true;
+        }
+        return refund.getCurrency() != null
+                && currencyNode.asText().trim().equalsIgnoreCase(refund.getCurrency());
+    }
+
+    /** 核对失败明细:只取标量字段,进日志前由调用方 flatten。 */
+    private static String upstreamRefundAmountDetail(UnifiedRefundEntity refund, JsonNode data) {
+        JsonNode amount = data == null ? null : data.get("refundAmount");
+        JsonNode currency = data == null ? null : data.get("currency");
+        return "上游 refundAmount="
+                + (amount == null || amount.isContainerNode() ? null : amount.asText())
+                + ", currency="
+                + (currency == null || currency.isContainerNode() ? null : currency.asText())
+                + "; 本地 amount=" + refund.getAmount()
+                + ", currency=" + refund.getCurrency();
+    }
+
+    /**
+     * 金额/币种核对失败:只写错误码,不碰 {@code state}(不会报错但会错钱)。
+     */
+    private void markAmountMismatch(UnifiedRefundEntity refund, String detail) {
+        if (refund == null || refund.getRefundNo() == null) {
+            return;
+        }
+        refundMapper.update(null, new LambdaUpdateWrapper<UnifiedRefundEntity>()
+                .eq(UnifiedRefundEntity::getRefundNo, refund.getRefundNo())
+                .set(UnifiedRefundEntity::getErrCode, AMOUNT_MISMATCH)
+                .set(UnifiedRefundEntity::getErrMsg, detail));
+        refund.setErrCode(AMOUNT_MISMATCH);
+        refund.setErrMsg(detail);
+        log.error("[统一支付退款查询] 退款金额/币种核对失败,状态不推进: refund_no={}, {}",
+                refund.getRefundNo(), UnifiedLogSanitizer.flatten(detail));
+    }
+
     /** 上游 {@code payAmount}:只有数字才认,缺失/非法一律 null(未知不等于相等)。 */
     private static Long payAmountOf(JsonNode data) {
         if (data == null || !data.isObject()) {

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

@@ -44,7 +44,7 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 /**
- * 退款轮询调度器(spec §7.3):扫描到期在途退款、逐笔查上游、单笔失败不拖垮整批。
+ * 退款轮询调度器(spec §7.4):扫描到期在途退款、逐笔查上游、单笔失败不拖垮整批。
  *
  * <p>与订单调度器同一条跨层契约:本任务跑在 {@code @Scheduled} 线程上,而
  * {@code spring.task.scheduling.pool.size} 默认是 1 —— 任何逃逸异常都会把整族定时任务饿死。

+ 208 - 8
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedRefundSyncTest.java

@@ -10,6 +10,7 @@ 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.common.exception.BusinessException;
 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;
@@ -30,6 +31,7 @@ import org.slf4j.LoggerFactory;
 import java.time.Duration;
 import java.time.OffsetDateTime;
 
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -41,21 +43,24 @@ 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.lenient;
 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)。
+ * 退款查单同步与退款轮询(spec §7.4)。
  *
  * <p>本类盯住「不会报错但会锁死额度 / 让轮询空转」的地方:
  * <ol>
  *   <li>配置按订单的 {@code merchant_id} 取(不看启停),不按退款行上的 {@code mch_no};</li>
  *   <li>只有 {@code applyRefundState} 能写业务状态 —— 查单腿自己写状态会绕过订单联动;</li>
  *   <li>未知/缺失状态不得落库(R11);</li>
+ *   <li>退款成功前必须核对 {@code refundAmount}/{@code currency}(与通知腿同一钱安全规则);</li>
+ *   <li>数据损坏:自动腿不抛、手动腿必须抛 {@link BusinessException};</li>
  *   <li>60 次 / 30 分钟停止后必须把 {@code next_query_time} 置空;</li>
- *   <li>上游「退款单不存在」的业务拒绝本期不置 3(设计 §14 #14 未定),
+ *   <li>上游「退款单不存在」的业务拒绝本期不置 3(设计 §14 #9 未定),
  *       只能保持 0 并记 WARN —— 置 3 会提前释放额度,冒超退风险。</li>
  * </ol>
  */
@@ -180,6 +185,17 @@ class UnifiedRefundSyncTest {
         }
     }
 
+    /** 金额/币种核对失败必须在退款行留下 AMOUNT_MISMATCH。 */
+    private void assertAmountMismatchMarked() {
+        ArgumentCaptor<LambdaUpdateWrapper<UnifiedRefundEntity>> w = updateCaptor();
+        verify(refundMapper, atLeastOnce()).update(isNull(), w.capture());
+        assertTrue(w.getAllValues().stream()
+                        .map(LambdaUpdateWrapper::getSqlSet)
+                        .anyMatch(s -> s != null && s.contains("err_code")),
+                "核对失败必须落库 err_code,实测: "
+                        + w.getAllValues().stream().map(LambdaUpdateWrapper::getSqlSet).toList());
+    }
+
     // ────────────────────────────── 配置取用 ──────────────────────────────
 
     @Test
@@ -210,7 +226,8 @@ class UnifiedRefundSyncTest {
         stubOrderAndMerchant();
         // channelExtra 类敏感字段只允许进白名单快照,不得原样进快照/回调
         when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
-                "{\"state\":2,\"payAmount\":100,\"refundOrderId\":\"RF1\","
+                "{\"state\":2,\"payAmount\":100,\"refundAmount\":100,\"currency\":\"cny\","
+                        + "\"refundOrderId\":\"RF1\","
                         + "\"channelExtra\":{\"auth_code\":\"CODE\"},\"openid\":\"OPEN\"}"));
 
         UnifiedRefundEntity applied = refund("2");
@@ -297,6 +314,114 @@ class UnifiedRefundSyncTest {
         verifyNoInteractions(tx, publisher, callbackService);
     }
 
+    // ──────────────────────── 金额/币种核对(I1,与通知腿同规则) ────────────────────────
+
+    @Test
+    void stateTwo_refundAmountMismatch_doesNotAdvance() {
+        stubOrderAndMerchant();
+        when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
+                "{\"state\":2,\"payAmount\":100,\"refundAmount\":99,\"currency\":\"cny\"}"));
+        // 万一核对被删掉,走 apply 也不至于 NPE —— 本测试只允许「不调 apply」
+        lenient().when(tx.apply(any(), any(), any(), any(), any(), any()))
+                .thenReturn(new UnifiedRefundTxService.ApplyOutcome(
+                        false, false, false, refund("2"), null));
+
+        UnifiedRefundEntity refund = refund("1");
+        ListAppender<ILoggingEvent> appender = attachLog(UnifiedRefundService.class);
+        try {
+            service.syncFromUpstream(refund);
+        } finally {
+            ((Logger) LoggerFactory.getLogger(UnifiedRefundService.class))
+                    .detachAppender(appender);
+        }
+
+        assertEquals("1", refund.getState(), "退款金额不符不得推进状态");
+        verifyNoInteractions(tx, publisher, callbackService);
+        assertEquals(1, refund.getQueryCount(), "上游响应算一次查询");
+        assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
+        assertAmountMismatchMarked();
+        assertNoDirectStateWrite();
+        assertTrue(appender.list.stream().anyMatch(ev ->
+                        ev.getLevel() == Level.ERROR
+                                && ev.getFormattedMessage().contains("UR1")
+                                && ev.getFormattedMessage().contains("99")),
+                "必须记 ERROR(含退款号与上游金额),实测: "
+                        + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
+    }
+
+    @Test
+    void stateTwo_refundAmountMissing_doesNotAdvance() {
+        stubOrderAndMerchant();
+        when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
+                "{\"state\":2,\"payAmount\":100,\"currency\":\"cny\"}"));
+        lenient().when(tx.apply(any(), any(), any(), any(), any(), any()))
+                .thenReturn(new UnifiedRefundTxService.ApplyOutcome(
+                        false, false, false, refund("2"), null));
+
+        UnifiedRefundEntity refund = refund("1");
+        service.syncFromUpstream(refund);
+
+        assertEquals("1", refund.getState(), "refundAmount 缺失不得推进状态");
+        verifyNoInteractions(tx, publisher, callbackService);
+        assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
+        assertAmountMismatchMarked();
+        assertNoDirectStateWrite();
+    }
+
+    @Test
+    void stateTwo_currencyMismatch_doesNotAdvance() {
+        stubOrderAndMerchant();
+        when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
+                "{\"state\":2,\"payAmount\":100,\"refundAmount\":100,\"currency\":\"usd\"}"));
+        lenient().when(tx.apply(any(), any(), any(), any(), any(), any()))
+                .thenReturn(new UnifiedRefundTxService.ApplyOutcome(
+                        false, false, false, refund("2"), null));
+
+        UnifiedRefundEntity refund = refund("1");
+        service.syncFromUpstream(refund);
+
+        assertEquals("1", refund.getState(), "币种不符不得推进状态");
+        verifyNoInteractions(tx, publisher, callbackService);
+        assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
+        assertAmountMismatchMarked();
+        assertNoDirectStateWrite();
+    }
+
+    /** 正对照:金额/币种一致必须放行。 */
+    @Test
+    void stateTwo_amountAndCurrencyMatch_applies() {
+        stubOrderAndMerchant();
+        when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
+                "{\"state\":2,\"payAmount\":100,\"refundAmount\":100,\"currency\":\"cny\"}"));
+        when(tx.apply(eq("UR1"), eq("2"), any(JsonNode.class), eq(100L), isNull(), isNull()))
+                .thenReturn(new UnifiedRefundTxService.ApplyOutcome(
+                        false, false, false, refund("2"), null));
+
+        UnifiedRefundEntity refund = refund("1");
+        service.syncFromUpstream(refund);
+
+        verify(tx).apply(eq("UR1"), eq("2"), any(JsonNode.class), eq(100L), isNull(), isNull());
+        verifyNoInteractions(publisher, callbackService);
+        assertEquals(1, refund.getQueryCount());
+    }
+
+    /** currency 在响应中缺失不阻断(T14 brief:若存在才比对)。 */
+    @Test
+    void stateTwo_currencyMissing_applies() {
+        stubOrderAndMerchant();
+        when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
+                "{\"state\":2,\"payAmount\":100,\"refundAmount\":100}"));
+        when(tx.apply(eq("UR1"), eq("2"), any(JsonNode.class), eq(100L), isNull(), isNull()))
+                .thenReturn(new UnifiedRefundTxService.ApplyOutcome(
+                        false, false, false, refund("2"), null));
+
+        UnifiedRefundEntity refund = refund("1");
+        service.syncFromUpstream(refund);
+
+        verify(tx).apply(eq("UR1"), eq("2"), any(JsonNode.class), eq(100L), isNull(), isNull());
+        assertEquals(1, refund.getQueryCount());
+    }
+
     // ────────────────────────────── 计数与退避 ──────────────────────────────
 
     @Test
@@ -371,7 +496,7 @@ class UnifiedRefundSyncTest {
         verifyNoInteractions(client, merchantService, orderService);
     }
 
-    // ──────────────────────── 上游「退款单不存在」(设计 §14 #14) ────────────────────────
+    // ──────────────────────── 上游「退款单不存在」(设计 §14 #9) ────────────────────────
 
     @Test
     void businessRejection_keepsStateAndOnlyWarns() {
@@ -389,7 +514,7 @@ class UnifiedRefundSyncTest {
         }
 
         assertEquals("0", refund.getState(),
-                "设计 §14 #14 未定:业务拒绝本期不得置 3(会提前释放额度)");
+                "设计 §14 #9 未定:业务拒绝本期不得置 3(会提前释放额度)");
         verifyNoInteractions(tx, publisher, callbackService);
         assertEquals(1, refund.getQueryCount(), "上游回了响应也算一次查询");
         assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
@@ -418,16 +543,91 @@ class UnifiedRefundSyncTest {
         verifyNoInteractions(tx, refundMapper, publisher, callbackService);
     }
 
+    // ──────────────────────── 数据损坏:订单/商户配置不存在 ────────────────────────
+
+    private void assertDataDamageHandled(UnifiedRefundEntity refund) {
+        assertNull(refund.getNextQueryTime(), "数据损坏时清空排期,避免每轮空转");
+        assertEquals(0, refund.getQueryCount(), "不计数:数据修复前不白烧重试次数");
+    }
+
     @Test
     void missingOrder_doesNotQueryAndClearsSchedule() {
         when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(null);
 
         UnifiedRefundEntity refund = refund("0");
-        service.syncFromUpstream(refund);
+        assertDoesNotThrow(() -> service.syncFromUpstream(refund));
 
         verifyNoInteractions(client, merchantService);
-        assertNull(refund.getNextQueryTime(), "数据损坏时清空排期,避免每轮空转");
-        assertEquals(0, refund.getQueryCount(), "不计数:数据修复前不白烧重试次数");
+        assertDataDamageHandled(refund);
+    }
+
+    @Test
+    void missingMerchantId_autoClearsSchedule() {
+        UnifiedOrderEntity order = order();
+        order.setMerchantId(null);
+        when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(order);
+
+        UnifiedRefundEntity refund = refund("0");
+        assertDoesNotThrow(() -> service.syncFromUpstream(refund));
+
+        verifyNoInteractions(client, merchantService);
+        assertDataDamageHandled(refund);
+    }
+
+    @Test
+    void merchantConfigMissing_autoClearsSchedule() {
+        when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(order());
+        when(merchantService.requireById(MERCHANT_ID))
+                .thenThrow(new BusinessException(404, "商户配置不存在"));
+
+        UnifiedRefundEntity refund = refund("0");
+        assertDoesNotThrow(() -> service.syncFromUpstream(refund));
+
+        verifyNoInteractions(client);
+        assertDataDamageHandled(refund);
+    }
+
+    @Test
+    void missingOrder_manualThrowsBusinessException() {
+        when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(null);
+
+        UnifiedRefundEntity refund = refund("0");
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.syncManually(refund));
+
+        assertEquals(500, ex.getCode(), "手动腿数据损坏必须转失败应答");
+        verifyNoInteractions(client);
+        assertDataDamageHandled(refund);
+    }
+
+    @Test
+    void missingMerchantId_manualThrowsBusinessException() {
+        UnifiedOrderEntity order = order();
+        order.setMerchantId(null);
+        when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(order);
+
+        UnifiedRefundEntity refund = refund("0");
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.syncManually(refund));
+
+        assertEquals(500, ex.getCode(), "手动腿数据损坏必须转失败应答");
+        verifyNoInteractions(client, merchantService);
+        assertDataDamageHandled(refund);
+    }
+
+    @Test
+    void merchantConfigMissing_manualThrowsBusinessException() {
+        when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(order());
+        when(merchantService.requireById(MERCHANT_ID))
+                .thenThrow(new BusinessException(404, "商户配置不存在"));
+
+        UnifiedRefundEntity refund = refund("0");
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.syncManually(refund));
+
+        assertEquals(500, ex.getCode(), "手动腿数据损坏必须转失败应答");
+        verifyNoInteractions(client);
+        assertDataDamageHandled(refund);
     }
 
     // ────────────────────────────── 手动同步(R39) ──────────────────────────────