Bladeren bron

feat(汇安融): 下游回调失败自动重试 —— 对齐乐收赢:首推一次 + 每分钟一轮,上限 8 次

- HarCallbackService 每次尝试写 notify_count / last_notify_time;缺地址也落 FAILED(不再停 PENDING)
- HarOrderPollScheduler 每 60s 捞 FAILED 且未达上限的(饿最久优先、单轮 50 条)重新推送
- 轮询腿真流转(rows>0 且状态/退款时间真变)后补发回调 —— 上游通知窗口只有约 25 秒,丢了的靠这条收敛
- 退款抢占/回退两条腿也各补一帧;create 不推;上限可配 har.callback-max-retries(默认 8)
- 存量 FAILED 单不补推(SQL 封顶 count=8,用户 2026-10-09 裁定)
- 部署顺序:先跑 db/har-callback-retry.sql 再换 jar —— 列不存在时回调状态会永远停在 PENDING
alphaH 1 week geleden
bovenliggende
commit
6a0c72a6d7

+ 5 - 5
frontend/src/views/module_payment/apikey/docs/har-openapi.md

@@ -244,7 +244,7 @@ POST /payment/openapi/alipay/refund
 
 订单状态变化时,平台会向你推送一条回调。
 
-**回调地址取自你这把 API Key 上配置的 `return_url`**(未配置时退回该租户的默认回调地址)。**没配就不会推送**,此时只能靠主动查询(10.3)拿结果。
+**回调地址取自你这把 API Key 上配置的 `return_url`**(未配置时退回该租户的默认回调地址)。**没配时平台不会丢下这笔** —— 记一次推送失败并继续重试(见下),你补配地址后未达上限的订单仍会收到;但尽快补配,超过上限就不会再推了,届时只能靠主动查询(10.3)拿结果。
 
 ```
 POST <你的 return_url>
@@ -293,9 +293,9 @@ Content-Type: application/x-www-form-urlencoded
 
 1. **应答**:处理成功后返回 **HTTP 2xx**。返回非 2xx 视为投递失败。
 2. **去重**:同一笔订单可能收到多次通知。请用 `out_biz_no` + `status` 做幂等。
-3. **快速应答**:先应答再异步处理。平台最多尝试 3 次(失败后隔 1 秒、2 秒各重试一次),收到 4xx 时不再重试。
+3. **快速应答**:先应答再异步处理。平台**首推只发一次**,失败(你方返回非 2xx、超时或连接异常)由平台自动重试:约每分钟一轮,累计尝试最多 **8 次(含首推)**;始终失败则停止,该单 `notify_status` 停在 `FAILED`。
 
-> ⚠️ **平台只在收到上游通知时才会向你推送回调。** 若上游那条通知丢了,平台会靠自己的任务把本地状态收敛正确,但**不会**补推一条给你 —— 此时 10.3 能查到正确结果,你的回调却永远不会响。**请务必给长期停在 `"0"` 的订单做主动查询兜底。**
+> ⚠️ **推送的触发不止「上游通知到达」这一种。** 平台在**本地订单状态收敛后**推送:既包括收到上游通知,也包括平台自己的查询任务发现的状态变化(上游通知丢了的那条腿)。但重试有上限(见上)—— 超过上限就不会再推。**请仍给长期停在 `"0"` 的订单做主动查询兜底**,别把回调当作唯一渠道。
 
 > ⚠️ **退款的最终结果(`"4"` → `"2"` / `"1"`)走的是同一条回调**,`content.status` 里就是新状态,`refund_time` 也在里面。所以上面的兜底对退款**同样适用,而且更要紧**:退款停在 `"4"` 时你等的是钱有没有回来,回调不响就只能自己查。**给长期停在 `"4"` 的订单也做主动查询。**
 
@@ -324,9 +324,9 @@ Content-Type: application/x-www-form-urlencoded
 
 | 值 | 含义 |
 |---|---|
-| `PENDING` | 尚未推送成功(含「你没有配置回调地址」这种情况) |
+| `PENDING` | 尚未发起过推送(订单刚生成 / 状态刚变化,推送还没回写结果) |
 | `SUCCESS` | 你方已成功接收 |
-| `FAILED` | 推送失败(对方返回非 2xx,或 3 次尝试均失败) |
+| `FAILED` | 推送失败(你方返回非 2xx、超时异常,或你还没配置回调地址)。平台会自动重试,累计最多 8 次(含首推)后停止;补配地址后未达上限的订单仍会被重推 |
 
 ---
 

+ 8 - 0
java/src/main/java/com/payment/platform/module/payment/har/config/HarProperties.java

@@ -59,6 +59,14 @@ public class HarProperties {
     /** 异步回调企业商户的线程数上限 */
     private int callbackPoolSize = 4;
 
+    /**
+     * 下游回调最大尝试次数(含首次)。
+     *
+     * <p>推送失败 / 缺地址落 FAILED 后由调度器按此上限重试
+     * (对齐乐收赢 {@code leshouying.downstream-notify-max-retries} 的默认 8)。
+     */
+    private int callbackMaxRetries = 8;
+
     /** 连接超时(毫秒) */
     private int connectTimeoutMs = 5000;
 

+ 6 - 0
java/src/main/java/com/payment/platform/module/payment/har/entity/HarOrderEntity.java

@@ -100,6 +100,12 @@ public class HarOrderEntity extends PaymentEnterpriseBaseEntity {
     /** 回调企业商户的结果,见 HarNotifyStatus */
     private String notifyStatus;
 
+    /** 下游回调已尝试次数(含首次与「缺地址」的那次)—— 调度器重试的封顶计数 */
+    private Integer notifyCount;
+
+    /** 最近一次回调尝试时间 —— 重试扫描的排序键(饿最久的先推) */
+    private OffsetDateTime lastNotifyTime;
+
     @Version
     private Integer version;
 }

+ 1 - 1
java/src/main/java/com/payment/platform/module/payment/har/enums/HarNotifyStatus.java

@@ -3,7 +3,7 @@ package com.payment.platform.module.payment.har.enums;
 /** 回调企业商户的结果。 */
 public final class HarNotifyStatus {
 
-    /** 尚未回调(或租户未配置回调地址) */
+    /** 尚未回调(新单初始态;缺地址/推送失败都落 FAILED,不再停在这一档) */
     public static final String PENDING = "PENDING";
 
     public static final String SUCCESS = "SUCCESS";

+ 26 - 1
java/src/main/java/com/payment/platform/module/payment/har/scheduler/HarOrderPollScheduler.java

@@ -2,6 +2,7 @@ package com.payment.platform.module.payment.har.scheduler;
 
 import com.payment.platform.module.payment.har.config.HarProperties;
 import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import com.payment.platform.module.payment.har.service.HarCallbackService;
 import com.payment.platform.module.payment.har.service.HarOrderService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Scheduled;
@@ -25,10 +26,13 @@ public class HarOrderPollScheduler {
 
     private final HarOrderService orderService;
     private final HarProperties harProperties;
+    private final HarCallbackService callbackService;
 
-    public HarOrderPollScheduler(HarOrderService orderService, HarProperties harProperties) {
+    public HarOrderPollScheduler(HarOrderService orderService, HarProperties harProperties,
+                                 HarCallbackService callbackService) {
         this.orderService = orderService;
         this.harProperties = harProperties;
+        this.callbackService = callbackService;
     }
 
     @Scheduled(fixedDelay = 60 * 1000, initialDelay = 60 * 1000)
@@ -61,4 +65,25 @@ public class HarOrderPollScheduler {
         }
         log.info("[汇安融轮询] 完成: {}/{}", ok, orders.size());
     }
+
+    /**
+     * 失败回调重试(2026-10-09 补齐)。
+     *
+     * <p>独立任务而不是挂在 {@link #pollPendingOrders} 尾巴上:后者有空列表早退
+     * (没有待查订单是常态),挂在尾巴上会让重试连带被跳过。开闸条件与轮询一致
+     * ({@code har.enabled})—— 密钥没配好时连扫都不该扫。上限与排序在
+     * {@code HarCallbackService#retryFailed} 里(对齐乐收赢同款查询)。
+     */
+    @Scheduled(fixedDelay = 60 * 1000, initialDelay = 75 * 1000)
+    public void retryFailedCallbacks() {
+        if (!harProperties.isEnabled()) {
+            return;
+        }
+        try {
+            callbackService.retryFailed();
+        } catch (Exception e) {
+            // 重试整批炸了不能掀翻调度线程 —— 那上面还挂着轮询主任务(状态收敛的主路径)
+            log.error("[汇安融回调重试] 执行失败: {}", e.getMessage(), e);
+        }
+    }
 }

+ 97 - 67
java/src/main/java/com/payment/platform/module/payment/har/service/HarCallbackService.java

@@ -2,6 +2,7 @@ package com.payment.platform.module.payment.har.service;
 
 import cn.hutool.core.util.StrUtil;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.payment.platform.common.utils.SnowflakeIdGenerator;
 import com.payment.platform.module.payment.apikey.entity.TenantApiKeyEntity;
 import com.payment.platform.module.payment.apikey.mapper.TenantApiKeyMapper;
@@ -26,7 +27,9 @@ import org.springframework.util.LinkedMultiValueMap;
 import org.springframework.util.MultiValueMap;
 import org.springframework.web.client.RestTemplate;
 
+import java.time.OffsetDateTime;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
@@ -42,24 +45,24 @@ import java.util.concurrent.TimeUnit;
  * 回调地址解析也沿用 OpenapiService.resolveReturnUrl 的优先级:
  * sys_tenant_api_key.return_url → open_conf.return_url。
  *
- * 两点刻意的设计:
+ * 三点刻意的设计:
  *
  * 1. 用 CompletableFuture + 自有线程池而非 @Async —— 项目没有开 @EnableAsync,
  *    @Async 会静默地同步执行,把上游通知响应时间拖到 30 秒以上,直接超出上游
  *    25 秒的重试窗口。为了一个回调去开全局 @EnableAsync 又会改变其他地方的语义。
  *
- * 2. 回调失败只记日志并落 notify_status,不抛异常 —— 上游通知要求我们尽快返回
- *    success,不能被下游回调拖住。
+ * 2. 推送**单次**、失败只记录不抛 —— 上游通知要求我们尽快返回 success,不能被
+ *    下游回调拖住;线程内退避重试(旧实现睡 1s、2s 共 3 次)会占满回调池。
+ *    重试交给调度器 {@link #retryFailed}:有次数上限、不占回调线程。
+ *
+ * 3. 缺地址与推送失败**统一记 FAILED**(不再静默跳过)—— 重试扫描的入口条件
+ *    就是 notify_status='FAILED',只打日志的话,商户事后补配地址也永远等不到
+ *    这一笔(2026-10-09 补齐)。
  */
 @Slf4j
 @Service
 public class HarCallbackService {
 
-    private static final int MAX_ATTEMPTS = 3;
-
-    /** 退避秒数,与 OpenapiService.CALLBACK_BACKOFF_SECONDS 保持一致 */
-    private static final int[] BACKOFF_SECONDS = {1, 2};
-
     /** 回调报文的序列化器。显式注册 JavaTimeModule,保证 OffsetDateTime 输出 ISO 字符串 */
     private static final ObjectMapper MAPPER = new ObjectMapper()
             .registerModule(new JavaTimeModule())
@@ -71,6 +74,8 @@ public class HarCallbackService {
     private final RestTemplate restTemplate;
     /** 订单信号发布(订单实时推送)—— 回调状态(notify_status)落库后发。 */
     private final OrderEventPublisher publisher;
+    /** 回调尝试次数上限(含首次)—— 重试封顶对齐乐收赢 downstreamNotifyMaxRetries */
+    private final HarProperties harProperties;
     private final ExecutorService callbackPool;
 
     public HarCallbackService(TenantApiKeyMapper apiKeyMapper,
@@ -83,6 +88,7 @@ public class HarCallbackService {
         this.confMapper = confMapper;
         this.orderMapper = orderMapper;
         this.restTemplate = restTemplate;
+        this.harProperties = harProperties;
         this.publisher = publisher;
         int poolSize = Math.max(1, harProperties.getCallbackPoolSize());
         this.callbackPool = Executors.newFixedThreadPool(poolSize, r -> {
@@ -106,7 +112,9 @@ public class HarCallbackService {
     }
 
     /**
-     * 异步回调企业商户。方法立即返回,不阻塞上游通知的响应。
+     * 回调企业商户。有地址走异步推送(方法立即返回,不阻塞上游通知的响应);
+     * 没地址**记 FAILED 落库**(同步写,不占回调线程),让 {@link #retryFailed}
+     * 将来还能捞起它 —— 商户补配地址后照样能收到这一笔。
      */
     public void notifyEnterprise(HarOrderEntity order) {
         if (order == null) {
@@ -114,13 +122,14 @@ public class HarCallbackService {
         }
         String returnUrl = resolveReturnUrl(order.getTenantId(), order.getApiKey());
         if (StrUtil.isBlank(returnUrl)) {
-            log.info("[汇安融回调] 租户未配置回调地址,跳过: tenant_id={}, order_no={}",
+            log.warn("[汇安融回调] 租户未配置回调地址,记 FAILED 待重试: tenant_id={}, order_no={}",
                     order.getTenantId(), order.getOrderNo());
+            writeNotifyResult(order, false);
             return;
         }
 
         String orderNo = order.getOrderNo();
-        CompletableFuture.runAsync(() -> doNotify(order, returnUrl, orderNo), callbackPool)
+        CompletableFuture.runAsync(() -> writeNotifyResult(order, pushOnce(returnUrl, order)), callbackPool)
                 .exceptionally(ex -> {
                     log.error("[汇安融回调] 异步任务异常: order_no={}, error={}",
                             orderNo, ex.getMessage(), ex);
@@ -128,36 +137,37 @@ public class HarCallbackService {
                 });
     }
 
-    private void doNotify(HarOrderEntity order, String returnUrl, String orderNo) {
-        String notifyId = "n" + SnowflakeIdGenerator.nextIdStr();
-        long timestamp = System.currentTimeMillis();
-        String content = buildContent(order);
-
-        boolean ok = post(returnUrl, notifyId, timestamp, content);
-        writeNotifyStatus(orderNo, ok);
-    }
-
     /**
-     * 回写回调结果。
+     * 回写一次回调尝试的结果。
      *
-     * 重新查一次再更新,而不是直接 update 传入的实体 —— 传入的实体可能已被
-     * 通知线程后续的状态更新覆盖,用乐观锁直接更新会因 version 过期而失败。
+     * 【为什么是 UpdateWrapper 而不是「先查再 updateById」】
+     * ① 乐观锁竞态:本方法可能由回调线程在 {@code syncFromUpstream} 事务提交**前**
+     *    执行(轮询腿触发回调),先查拿到的是提交前的旧 version —— 事务提交后
+     *    updateById 的 {@code WHERE version=?} 被新版本挡掉,写库**静默丢**(0 行)。
+     *    免版本的 SET 更新直接排队等行锁,提交后落在新行上。
+     * ② notify_count 自增必须落在 SQL 侧:并发重试下 read-modify-write 会丢计数,
+     *    计数丢了次数上限就形同虚设。
+     * 代价是 updated_time 也要显式写({@code update(null, wrapper)} 不经自动填充,
+     * 同 {@code HarOrderService#doRefund} 先例)。
      */
-    private void writeNotifyStatus(String orderNo, boolean ok) {
+    private void writeNotifyResult(HarOrderEntity order, boolean ok) {
         try {
-            HarOrderEntity latest = orderMapper.selectOne(new LambdaQueryWrapper<HarOrderEntity>()
-                    .eq(HarOrderEntity::getOrderNo, orderNo)
-                    .last("LIMIT 1"));
-            if (latest == null) {
+            OffsetDateTime now = OffsetDateTime.now();
+            UpdateWrapper<HarOrderEntity> w = new UpdateWrapper<>();
+            w.set("notify_status", ok ? HarNotifyStatus.SUCCESS : HarNotifyStatus.FAILED)
+                    .setSql("notify_count = COALESCE(notify_count, 0) + 1")
+                    .set("last_notify_time", now)
+                    .set("updated_time", now)
+                    .eq("order_no", order.getOrderNo());
+            if (orderMapper.update(null, w) == 0) {
+                log.warn("[汇安融回调] 状态写入未命中订单: order_no={}", order.getOrderNo());
                 return;
             }
-            latest.setNotifyStatus(ok ? HarNotifyStatus.SUCCESS : HarNotifyStatus.FAILED);
-            orderMapper.updateById(latest);
             // 回调状态也是页面上的一格(HAR 详情弹窗),成功失败都要发。
-            // 本方法 private + 跑在回调线程池里(Task 7 唯一没被单测钉住的写点,Task 14 E2E 覆盖)。
-            publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, latest.getTenantId());
+            publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, order.getTenantId());
         } catch (Exception e) {
-            log.warn("[汇安融回调] 回写 notify_status 失败: order_no={}, error={}", orderNo, e.getMessage());
+            log.warn("[汇安融回调] 回写 notify_status 失败: order_no={}, error={}",
+                    order.getOrderNo(), e.getMessage());
         }
     }
 
@@ -212,44 +222,64 @@ public class HarCallbackService {
         }
     }
 
-    private boolean post(String url, String notifyId, long timestamp, String content) {
-        for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
-            try {
-                MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
-                formData.add("notify_id", notifyId);
-                formData.add("timestamp", String.valueOf(timestamp));
-                formData.add("content", content);
-
-                HttpHeaders headers = new HttpHeaders();
-                headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+    /**
+     * 单次推送 —— 线程内不再退避重试(重试见 {@link #retryFailed})。
+     *
+     * @return true = 商户侧返回 2xx;任何非 2xx / 异常都算一次失败尝试,记 FAILED 等调度器再试
+     */
+    private boolean pushOnce(String url, HarOrderEntity order) {
+        String notifyId = "n" + SnowflakeIdGenerator.nextIdStr();
+        MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
+        formData.add("notify_id", notifyId);
+        formData.add("timestamp", String.valueOf(System.currentTimeMillis()));
+        formData.add("content", buildContent(order));
 
-                ResponseEntity<String> response = restTemplate.postForEntity(
-                        url, new HttpEntity<>(formData, headers), String.class);
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        try {
+            ResponseEntity<String> response = restTemplate.postForEntity(
+                    url, new HttpEntity<>(formData, headers), String.class);
+            if (response.getStatusCode().is2xxSuccessful()) {
+                log.info("[汇安融回调] 成功: notify_id={}, url={}", notifyId, url);
+                return true;
+            }
+            log.warn("[汇安融回调] 返回非 2xx,记 FAILED 待调度器重试: notify_id={}, status={}, url={}",
+                    notifyId, response.getStatusCode(), url);
+            return false;
+        } catch (Exception e) {
+            log.warn("[汇安融回调] 推送异常,记 FAILED 待调度器重试: notify_id={}, url={}, error={}",
+                    notifyId, url, e.getMessage());
+            return false;
+        }
+    }
 
-                if (response.getStatusCode().is2xxSuccessful()) {
-                    log.info("[汇安融回调] 成功: notify_id={}, url={}", notifyId, url);
-                    return true;
-                }
-                // 4xx 是对方的问题,重试没有意义
-                if (response.getStatusCode().is4xxClientError()) {
-                    log.warn("[汇安融回调] 返回 {},不再重试: notify_id={}",
-                            response.getStatusCode(), notifyId);
-                    return false;
-                }
-                log.warn("[汇安融回调] 第 {} 次返回 {}", attempt + 1, response.getStatusCode());
+    /**
+     * 扫出推送失败且未达次数上限的订单再推一遍 —— 供轮询调度器周期调用。
+     *
+     * 对齐乐收赢 LsyCallbackService#retryFailed:同一查询形状(FAILED +
+     * count &lt; 上限 + 按 last_notify_time 升序饿最久的先推 + LIMIT 50)。
+     * 扫描异常只记不抛:调用方是调度线程,上面还挂着轮询主任务。
+     */
+    public void retryFailed() {
+        List<HarOrderEntity> orders;
+        try {
+            orders = orderMapper.selectList(new LambdaQueryWrapper<HarOrderEntity>()
+                    .eq(HarOrderEntity::getNotifyStatus, HarNotifyStatus.FAILED)
+                    .lt(HarOrderEntity::getNotifyCount, harProperties.getCallbackMaxRetries())
+                    .orderByAsc(HarOrderEntity::getLastNotifyTime)
+                    .last("LIMIT 50"));
+        } catch (Exception e) {
+            log.error("[汇安融回调] 扫描待重试订单失败: {}", e.getMessage(), e);
+            return;
+        }
+        for (HarOrderEntity order : orders) {
+            try {
+                notifyEnterprise(order);
             } catch (Exception e) {
-                log.warn("[汇安融回调] 第 {} 次失败: {}", attempt + 1, e.getMessage());
-            }
-            if (attempt < MAX_ATTEMPTS - 1) {
-                try {
-                    Thread.sleep(BACKOFF_SECONDS[attempt] * 1000L);
-                } catch (InterruptedException ie) {
-                    Thread.currentThread().interrupt();
-                    return false;
-                }
+                // notifyEnterprise 各段自身都有保护,这里是防将来改动引入的抛出拖垮整批
+                log.error("[汇安融回调] 重试推送异常: order_no={}, error={}",
+                        order.getOrderNo(), e.getMessage());
             }
         }
-        log.error("[汇安融回调] {} 次尝试后仍失败: url={}", MAX_ATTEMPTS, url);
-        return false;
     }
 }

+ 47 - 1
java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java

@@ -35,11 +35,13 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.math.BigDecimal;
+import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.OffsetDateTime;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * 汇安融订单服务。
@@ -111,6 +113,17 @@ public class HarOrderService {
      * 的回退 —— 清册以本文件里的发布调用点为准(按方法名找,别按行号)。
      */
     private final OrderEventPublisher publisher;
+    /**
+     * 商家回调(2026-10-09 补齐轮询腿)。
+     *
+     * <p>【触发纪律】只在**真的写了行、且商户可见状态真的变了**之后推:
+     * {@link #syncFromUpstream} 的查询成功尾(rows&gt;0 且状态/退款时间真流转)、
+     * {@link #doRefund} 的抢占、{@link #rollbackRefundPreempt} 的回退。
+     * {@link #create} 不推(调用方同步已拿到响应,后续流转由通知/轮询送);
+     * {@link #closeRejectedOrder} 不推(上游拒绝是下单腿的同步失败,调用方当场就知道)。
+     * 存量单不补推。
+     */
+    private final HarCallbackService callbackService;
 
     public PageResult<HarOrderVO> list(int pageNo, int pageSize, HarOrderQueryDTO q) {
         LambdaQueryWrapper<HarOrderEntity> w = new LambdaQueryWrapper<>();
@@ -292,6 +305,9 @@ public class HarOrderService {
         }
         // 抢占真的改了行(守卫命中)才发 —— 没命中的分支上面已经抛了,页面什么都没有变
         publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, order.getTenantId());
+        // 商家回调:受理(1 已支付 → 4 退款中)也是一次真实流转 —— 等上游回话期间,
+        // 商户侧就该是「退款中」(上游拒绝时下面的回退腿会再补一帧翻回去)。
+        callbackService.notifyEnterprise(order);
 
         HarRefundRequest req = new HarRefundRequest();
         // 优先用上游单号;拿不到时用我方单号走 third_order_no(同查询口径,
@@ -354,6 +370,8 @@ public class HarOrderService {
         // 回滚也要发:页面刚从「退款中」被点亮,现在要翻回「已支付」——
         // 不发这一帧,退款页/订单页会一直停在那个从没发生过的退款中。
         publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, order.getTenantId());
+        // 商家回调:先 4 后 1 两帧都要送 —— 缺了这一帧,商户会一直以为这笔在退款中。
+        callbackService.notifyEnterprise(order);
     }
 
     /**
@@ -543,6 +561,10 @@ public class HarOrderService {
 
         order.setLastQueryTime(OffsetDateTime.now());
         order.setQueryCount(order.getQueryCount() == null ? 1 : order.getQueryCount() + 1);
+        // 流转快照:只有本次同步真的改变了「商户看得见的状态/退款时间」,才在写库成功后
+        // 触发商家回调(见尾部)。必须在 applyQueryDetail 之前抓 —— 它直接改 order。
+        String statusBefore = order.getTradeStatus();
+        OffsetDateTime refundTimeBefore = order.getRefundTime();
 
         HarResponse response = harClient.query(req);
         if (!response.isSuccess()) {
@@ -576,14 +598,38 @@ public class HarOrderService {
             order.setNextQueryTime(OffsetDateTime.now()
                     .plusSeconds(nextDelaySeconds(order.getQueryCount(), false)));
         }
-        orderMapper.updateById(order);
+        int rows = orderMapper.updateById(order);
         // 查询成功这条尾(状态/金额/上游单号都可能变了)才发。
         // **上面那条查询失败的分支不发** —— 它只推 next_query_time 一个游标,
         // 订单列表页看不到这一格,每轮轮询发一帧就是纯噪音。
         publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, order.getTenantId());
+        // 商家回调:这是上游通知腿之外的**第二条触发面** —— 通知窗口只有约 25 秒,
+        // 丢了的通知只能靠这条轮询腿收敛,收敛了就必须补一次回调。
+        // 两个闸门:① rows>0 —— updateById 被乐观锁挡掉(0 行)时内存里的状态没写进库,
+        // 不许对外宣称;② 真流转 —— 每轮轮询的 no-op 不许重复打扰商户。
+        if (rows > 0 && transitioned(statusBefore, refundTimeBefore, order)) {
+            callbackService.notifyEnterprise(order);
+        }
         return HarOrderVO.of(order);
     }
 
+    /**
+     * 状态字符串或退款时间**时刻**变了 = 一次商户可见的流转。
+     *
+     * 退款时间按 {@link Instant} 比较而不是 OffsetDateTime.equals:库读回的表示可能
+     * 带别的时区偏移(pgjdbc 读 timestamp 挂 UTC 的历史事故),同一时刻的不同表示
+     * 不算流转 —— 否则退款成功后每一轮轮询都会重发回调。
+     */
+    private static boolean transitioned(String statusBefore, OffsetDateTime refundBefore,
+                                        HarOrderEntity after) {
+        if (!Objects.equals(statusBefore, after.getTradeStatus())) {
+            return true;
+        }
+        Instant before = refundBefore == null ? null : refundBefore.toInstant();
+        Instant current = after.getRefundTime() == null ? null : after.getRefundTime().toInstant();
+        return !Objects.equals(before, current);
+    }
+
     /**
      * 把上游 detail 映射到订单实体。
      *

+ 66 - 0
java/src/main/resources/db/har-callback-retry.sql

@@ -0,0 +1,66 @@
+-- pay_har_order 新增回调重试两列 + 重试扫描部分索引(2026-10-09)
+-- 手工执行: psycopg2 / psql 连接 payment_platform_java 库
+--
+-- 【本脚本尚未执行】执行由用户做。
+--
+-- ── 背景 ─────────────────────────────────────────────────────────────
+-- 本次交付补齐「下游回调失败自动重试」(对齐乐收赢同款):
+--   HarCallbackService#writeNotifyResult 每次回调尝试都写 notify_count+1 / last_notify_time;
+--   HarOrderPollScheduler#retryFailedCallbacks 每 60 秒扫一遍
+--       notify_status='FAILED' 且 notify_count < har.callback-max-retries(默认 8)
+--       按 last_notify_time 升序(饿最久的先推)、单轮 LIMIT 50,重新推送。
+-- 这两列此前没有(pay_har_order 建于 V1.8__create_pay_har_tables.sql,当时只有
+-- notify_status),而实体 HarOrderEntity 已映射 notifyCount / lastNotifyTime。
+--
+-- ── ⚠ 顺序:先执行本脚本,再换新 jar ──────────────────────────────────
+-- 新 jar 下每次回调尝试都要 UPDATE 这两列。列不存在 ⇒ 该 UPDATE 抛错、被
+-- writeNotifyResult 的 catch 吞成一行 warn —— 表现是「回调推了但状态永远停在
+-- PENDING」,且这段窗口事后补跑脚本也救不回来(PENDING 不在重试扫描的捞取范围内)。
+-- 同时 retryFailed 的 SELECT 每分钟抛一次错(只记日志,不掀调度线程)。
+--
+-- ── ⚠ 存量不补推(用户 2026-10-09 裁定)──────────────────────────────
+-- ALTER 给存量行的 notify_count 填默认 0,而扫描判据是 count < 上限 ⇒
+-- 执行那一刻已有的 FAILED 单会被当成「没试满」全部捞出来补推,每分钟最多 50 条。
+-- 这不是本次要的行为(要补推的是**部署之后**才失败的推送),所以下面把
+-- 执行那一刻的存量 FAILED 行一次性封顶到上限,让它们不进重试扫描。
+-- 新产生的失败(含存量单在部署后因状态收敛新触发的推送失败)从 1 开始正常重试。
+-- ★ 封顶值取默认上限 8。若生产把 har.callback-max-retries 配得比 8 **大**,
+--   把下面 UPDATE 与被封顶行的值改成那个数(配得比 8 小不用改,8 同样够不到)。
+--
+-- ── 执行 ─────────────────────────────────────────────────────────────
+ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS notify_count int NOT NULL DEFAULT 0;
+ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS last_notify_time timestamp;
+
+COMMENT ON COLUMN pay_har_order.notify_count IS
+    '下游回调已尝试次数(含首次与「缺地址」那次)—— 重试扫描的封顶计数';
+COMMENT ON COLUMN pay_har_order.last_notify_time IS
+    '最近一次回调尝试时间 —— 重试扫描的排序键(饿最久的先推)';
+
+-- 存量 FAILED 行封顶(只动 count=0 的:新 jar 写出的 FAILED 行 count 必然 ≥1,
+-- 所以本句重跑也不会把正常重试中的单踢出扫描)
+UPDATE pay_har_order SET notify_count = 8 WHERE notify_status = 'FAILED' AND notify_count = 0;
+
+-- 重试扫描的唯一热路径是 notify_status='FAILED' AND notify_count < 上限
+-- ORDER BY last_notify_time LIMIT 50。FAILED 只是全部订单里极小的一部分,
+-- 用部分索引只覆盖这部分行(谓词 + 排序键一个索引全包)。
+CREATE INDEX IF NOT EXISTS idx_har_order_callback_retry
+    ON pay_har_order (last_notify_time) WHERE notify_status = 'FAILED';
+
+-- ── 只读核对(执行后跑)──────────────────────────────────────────────
+-- ① 列在不在、形状对不对
+-- SELECT column_name, data_type, is_nullable, column_default
+--   FROM information_schema.columns
+--  WHERE table_name = 'pay_har_order' AND column_name IN ('notify_count','last_notify_time');
+-- 期望:last_notify_time | timestamp without time zone | YES | (空)
+--       notify_count     | integer                   | NO  | 0
+--
+-- ② 索引在不在
+-- SELECT indexname FROM pg_indexes
+--  WHERE tablename = 'pay_har_order' AND indexname = 'idx_har_order_callback_retry';
+-- 期望:一行
+--
+-- ③ ★ 存量是否已全部封顶(这条同时量出了「原本会被补推的存量单数」)
+-- SELECT count(*) FILTER (WHERE notify_count < 8) AS 还会进扫描的存量_FAILED,
+--        count(*)                                AS 存量_FAILED_总数
+--   FROM pay_har_order WHERE notify_status = 'FAILED';
+-- 期望:前者 0(后者是存量规模,记下来即可)

+ 37 - 1
java/src/test/java/com/payment/platform/module/payment/har/scheduler/HarOrderPollSchedulerTest.java

@@ -3,6 +3,7 @@ package com.payment.platform.module.payment.har.scheduler;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.module.payment.har.config.HarProperties;
 import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import com.payment.platform.module.payment.har.service.HarCallbackService;
 import com.payment.platform.module.payment.har.service.HarOrderService;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -20,6 +21,7 @@ import static org.mockito.Mockito.*;
 class HarOrderPollSchedulerTest {
 
     @Mock private HarOrderService orderService;
+    @Mock private HarCallbackService callbackService;
 
     private HarProperties properties;
     private HarOrderPollScheduler scheduler;
@@ -27,7 +29,7 @@ class HarOrderPollSchedulerTest {
     @BeforeEach
     void setUp() {
         properties = new HarProperties();
-        scheduler = new HarOrderPollScheduler(orderService, properties);
+        scheduler = new HarOrderPollScheduler(orderService, properties, callbackService);
     }
 
     private HarOrderEntity order(String orderNo) {
@@ -95,4 +97,38 @@ class HarOrderPollSchedulerTest {
         assertDoesNotThrow(scheduler::pollPendingOrders);
         verify(orderService, never()).syncFromUpstream(any());
     }
+
+    // ==================== 失败回调重试(2026-10-09 补齐) ====================
+
+    /**
+     * 重试任务的闸门与轮询一致(har.enabled)。关着的时候连扫都不该扫 ——
+     * 密钥没配好时它每轮都会白扫一张表。
+     */
+    @Test
+    void retryCallbacks_disabled_doesNotScan() {
+        properties.setEnabled(false);
+
+        scheduler.retryFailedCallbacks();
+
+        verify(callbackService, never()).retryFailed();
+    }
+
+    /** 正对照:开着的时候真的要扫(上面那条 never() 才有意义) */
+    @Test
+    void retryCallbacks_enabled_invokesCallbackRetry() {
+        properties.setEnabled(true);
+
+        scheduler.retryFailedCallbacks();
+
+        verify(callbackService).retryFailed();
+    }
+
+    /** 重试整批炸了不能掀翻调度线程 —— 它上面还挂着轮询主任务(状态收敛的主路径) */
+    @Test
+    void retryCallbacks_throws_doesNotPropagate() {
+        properties.setEnabled(true);
+        doThrow(new BusinessException("数据库连接失败")).when(callbackService).retryFailed();
+
+        assertDoesNotThrow(scheduler::retryFailedCallbacks);
+    }
 }

+ 324 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarCallbackServiceTest.java

@@ -0,0 +1,324 @@
+package com.payment.platform.module.payment.har.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.module.payment.apikey.entity.TenantApiKeyEntity;
+import com.payment.platform.module.payment.apikey.mapper.TenantApiKeyMapper;
+import com.payment.platform.module.payment.har.config.HarProperties;
+import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import com.payment.platform.module.payment.har.enums.HarNotifyStatus;
+import com.payment.platform.module.payment.har.enums.HarOrderStatus;
+import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
+import com.payment.platform.module.payment.openapi.mapper.OpenConfMapper;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.AfterEach;
+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.http.HttpEntity;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.client.RestTemplate;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 下游商家回调的推送与重试契约(2026-10-09 补齐)。
+ *
+ * 【三条主断言与它们各自防的回归】
+ * ① 缺地址**记 FAILED 落库**而不是打日志就跳过 —— 旧实现停在 PENDING,
+ *    而重试扫描的入口条件是 notify_status='FAILED',商户事后补配地址也永远
+ *    等不到这一笔(见 {@link HarCallbackServiceTest#notifyEnterprise_noUrl_recordsFailedForRetryInsteadOfSkipping})。
+ * ② 推送**单次**,线程内不再退避重试 —— 旧实现在回调线程里睡 1s、2s 共 3 次,
+ *    一个卡住的商户地址会占满回调池,而上游通知还等着我们尽快返回 success。
+ * ③ {@link HarCallbackService#retryFailed} 的扫描形状五要素(状态字面量、
+ *    计数上界、排序方向、LIMIT、取值来源)—— 任一写错都是「重试静默失效而套件全绿」。
+ *
+ * 【为什么断言 SET 段而不是实体】写库刻意**不走实体**(实体带 @Version:回调线程
+ * 可能在 syncFromUpstream 事务提交前读到旧行版本,updateById 会静默丢写)⇒ 只能走
+ * UpdateWrapper,断言也只能拆 SET 子句。取值断言用 setValueOf(只看 SET 段),
+ * WHERE 的绑定值用 whereBoundValues 单独看 —— 两张表混在一起时 contains 会假绿
+ * (同 HarOrderServiceTest 里那两段注释的理由)。
+ */
+@ExtendWith(MockitoExtension.class)
+class HarCallbackServiceTest {
+
+    private static final Long TENANT = 7L;
+
+    @Mock private TenantApiKeyMapper apiKeyMapper;
+    @Mock private OpenConfMapper confMapper;
+    @Mock private HarOrderMapper orderMapper;
+    @Mock private RestTemplate restTemplate;
+    @Mock private OrderEventPublisher publisher;
+
+    private HarProperties props;
+    private HarCallbackService service;
+
+    @BeforeEach
+    void setUp() {
+        props = new HarProperties();
+        props.setCallbackMaxRetries(8);
+        service = new HarCallbackService(apiKeyMapper, confMapper, orderMapper, restTemplate, props, publisher);
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
+        // (同款引导见 LsyCallbackServiceTest / HarOrderServiceTest。)
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), HarOrderEntity.class);
+    }
+
+    @AfterEach
+    void tearDown() {
+        // 回调线程池是 service 自有的(非 Spring 管理),单测里手动收,避免线程泄漏影响后续用例
+        service.shutdown();
+    }
+
+    /** 夹具订单:只给「哪里都没配回调地址」用得着的三格 */
+    private HarOrderEntity order(String apiKey) {
+        HarOrderEntity o = new HarOrderEntity();
+        o.setOrderNo("HAR1");
+        o.setTenantId(TENANT);
+        o.setTradeStatus(HarOrderStatus.PAID);
+        o.setApiKey(apiKey);
+        return o;
+    }
+
+    /** 把「Key 上有回调地址」这一前提收成一行 */
+    private void stubKeyWithUrl(String url) {
+        TenantApiKeyEntity key = new TenantApiKeyEntity();
+        key.setReturnUrl(url);
+        when(apiKeyMapper.selectOne(any())).thenReturn(key);
+    }
+
+    private void stubPushOk() {
+        when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
+                .thenReturn(new ResponseEntity<>("ok", HttpStatus.OK));
+    }
+
+    /**
+     * 缺地址不再静默跳过 —— 记 FAILED 落库,交给调度器重试。
+     *
+     * 【为什么这条是主断言】旧实现只 log 一句就 return(notify_status 停在 PENDING),
+     * 商户事后补配了地址也永远等不到这一笔:重试扫描的条件是 notify_status='FAILED'。
+     * 而 notify_count / last_notify_time 必须一起落:前者是重试的封顶计数,后者是
+     * 扫描的排序键 —— 少一个,重试扫描要么捞不到、要么排不对。
+     */
+    @Test
+    void notifyEnterprise_noUrl_recordsFailedForRetryInsteadOfSkipping() {
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        service.notifyEnterprise(order(null));   // apiKey 为空、租户也没配 ⇒ 哪里都没地址
+
+        verify(restTemplate, never()).postForEntity(anyString(), any(), eq(String.class));
+
+        ArgumentCaptor<UpdateWrapper> w = ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(orderMapper).update(isNull(), w.capture());
+        assertEquals(HarNotifyStatus.FAILED, setValueOf(w.getValue(), "notify_status"),
+                "缺地址也要落 FAILED(重试扫描的入口条件)。实测 SET: " + w.getValue().getSqlSet());
+        assertTrue(w.getValue().getSqlSet().contains("notify_count = COALESCE(notify_count, 0) + 1"),
+                "计数要在 SQL 侧自增(并发重试时 read-modify-write 会丢计数,计数丢了上限形同虚设)。实测 SET: "
+                        + w.getValue().getSqlSet());
+        assertNotNull(setValueOf(w.getValue(), "last_notify_time"),
+                "排序键不能为空。实测 SET: " + w.getValue().getSqlSet());
+        assertNotNull(setValueOf(w.getValue(), "updated_time"),
+                "update(null,wrapper) 不经自动填充,updated_time 要手写。实测 SET: " + w.getValue().getSqlSet());
+        assertTrue(whereBoundValues(w.getValue()).contains("HAR1"),
+                "WHERE 必须钉住这一单。实测: " + w.getValue().getSqlSegment());
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
+    }
+
+    /** 有地址、推送 2xx ⇒ 记 SUCCESS + 计数 + 订单信号 */
+    @Test
+    void notifyEnterprise_pushOk_recordsSuccessWithCountAndSignal() {
+        stubKeyWithUrl("https://merchant.example.com/cb");
+        stubPushOk();
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        service.notifyEnterprise(order("K1"));
+
+        ArgumentCaptor<UpdateWrapper> w = ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(orderMapper, timeout(3000)).update(isNull(), w.capture());
+        assertEquals(HarNotifyStatus.SUCCESS, setValueOf(w.getValue(), "notify_status"),
+                "2xx ⇒ SUCCESS。实测 SET: " + w.getValue().getSqlSet());
+        verify(restTemplate).postForEntity(eq("https://merchant.example.com/cb"),
+                any(HttpEntity.class), eq(String.class));
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
+    }
+
+    /**
+     * 推送抛异常 ⇒ 记 FAILED,且**恰好一次 POST** —— 线程内不重试。
+     *
+     * 旧实现在回调线程里睡 1s、2s 重试共 3 次;这条用例把「重试交给调度器」钉死,
+     * 它同时也挡住「顺手再加个退避循环」的回归(times(1) 会红)。
+     */
+    @Test
+    void notifyEnterprise_pushThrows_recordsFailedWithSingleAttemptInThread() {
+        stubKeyWithUrl("https://merchant.example.com/cb");
+        when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
+                .thenThrow(new RuntimeException("connection refused"));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        service.notifyEnterprise(order("K1"));
+
+        ArgumentCaptor<UpdateWrapper> w = ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(orderMapper, timeout(3000)).update(isNull(), w.capture());
+        assertEquals(HarNotifyStatus.FAILED, setValueOf(w.getValue(), "notify_status"),
+                "实测 SET: " + w.getValue().getSqlSet());
+        verify(restTemplate, times(1)).postForEntity(anyString(), any(HttpEntity.class), eq(String.class));
+    }
+
+    /**
+     * {@code retryFailed()} 的扫描形状 —— 与乐收赢 LsyCallbackService#retryFailed 对齐。
+     *
+     * 【为什么五个要素全钉】状态字面量、计数上界、排序方向、LIMIT 任一写错,症状都是
+     * 「失败回调永远不被重试而套件全绿」—— 没有别的任务会断言这个查询。
+     * 【取值为什么定 3 而不是默认 8】钉住上界来自 HarProperties 而不是写死的数字。
+     * 【断言为什么分两半】MyBatis-Plus 的 .eq()/.lt() 不把值写进 SQL 文本(塞进
+     * paramNameValuePairs)⇒ 结构看 getSqlSegment(),取值看参数表,取值用**恰好相等**。
+     */
+    @Test
+    void retryFailed_pinsScanQuery() {
+        props.setCallbackMaxRetries(3);
+        when(orderMapper.selectList(any())).thenReturn(List.of());
+
+        service.retryFailed();
+
+        ArgumentCaptor<LambdaQueryWrapper> scan = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(orderMapper).selectList(scan.capture());
+        LambdaQueryWrapper<?> w = scan.getValue();
+        String sql = w.getSqlSegment();
+
+        assertEquals(Set.of(HarNotifyStatus.FAILED, 3), new HashSet<>(w.getParamNameValuePairs().values()),
+                "取值必须恰好是 {FAILED, 上限},实测: " + w.getParamNameValuePairs());
+        assertTrue(sql.contains("notify_status = #{"),
+                "必须按 notify_status 等值过滤,实测: " + sql);
+        assertTrue(sql.contains("notify_count < #{"),
+                "必须按 notify_count 严格小于过滤(达上限的单不再推),实测: " + sql);
+        assertTrue(sql.contains("ORDER BY last_notify_time ASC"),
+                "饿最久的先推,实测: " + sql);
+        assertTrue(sql.contains("LIMIT 50"),
+                "必须带 LIMIT,实测: " + sql);
+    }
+
+    /** 扫出来的每一单都要真的再推一次(扫描不是只记日志) */
+    @Test
+    void retryFailed_dispatchesPushForEachScannedOrder() {
+        HarOrderEntity first = order("K1");
+        HarOrderEntity second = order("K1");
+        second.setOrderNo("HAR2");
+        when(orderMapper.selectList(any())).thenReturn(List.of(first, second));
+        stubKeyWithUrl("https://merchant.example.com/cb");
+        stubPushOk();
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        service.retryFailed();
+
+        verify(restTemplate, timeout(3000).times(2))
+                .postForEntity(eq("https://merchant.example.com/cb"), any(HttpEntity.class), eq(String.class));
+        verify(orderMapper, timeout(3000).times(2)).update(isNull(), any());
+    }
+
+    /**
+     * 重试时**仍然**没有地址 ⇒ 再记一次 FAILED(计数 +1)。
+     *
+     * 这次尝试也要计数:否则「一直没配地址」的单会被每轮调度无限重扫(上限永远够不着)。
+     */
+    @Test
+    void retryFailed_scannedOrderWithoutUrl_recordsAnotherFailedAttempt() {
+        when(orderMapper.selectList(any())).thenReturn(List.of(order(null)));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        service.retryFailed();
+
+        verify(restTemplate, never()).postForEntity(anyString(), any(), eq(String.class));
+        ArgumentCaptor<UpdateWrapper> w = ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(orderMapper).update(isNull(), w.capture());
+        assertEquals(HarNotifyStatus.FAILED, setValueOf(w.getValue(), "notify_status"),
+                "实测 SET: " + w.getValue().getSqlSet());
+    }
+
+    /** 扫描本身炸了(库抖动)不能把异常抛给调度线程 —— 那上面还挂着轮询主任务 */
+    @Test
+    void retryFailed_scanThrows_doesNotPropagate() {
+        when(orderMapper.selectList(any())).thenThrow(new RuntimeException("db down"));
+
+        assertDoesNotThrow(() -> service.retryFailed());
+        verify(orderMapper).selectList(any());
+    }
+
+    // ─────────────────────────── 交付脚本的 schema 前提 ───────────────────────────
+
+    /**
+     * 重试腿的两列只存在于**交付给用户的建表脚本**里(本工程未接 Flyway,脚本由用户
+     * 手工执行)—— 代码这边只有实体映射与查询。脚本缺一列 ⇒ 每次回调尝试的 UPDATE
+     * 都抛「column does not exist」,被 {@code writeNotifyResult} 的 catch 吞成一行
+     * warn(表现:回调推了但状态永远 PENDING),且那段窗口事后补跑脚本也救不回来
+     * (PENDING 不在重试扫描的捞取范围内)。所以这里不读代码,直接读那份脚本。
+     *
+     * <p>封顶那句(存量不补推)是**用户裁定**的落地处:执行那一刻已有的 FAILED 单
+     * 不能因为新列默认 0 被当成「没试满」全部补推。删掉它、或把上限值改得与
+     * {@code har.callback-max-retries} 不一致(默认 8,生产配得比 8 大时脚本要同步改),
+     * 部署时就会把存量积压重推一轮 —— 而套件除了本用例没有任何红点。
+     */
+    @Test
+    void schemaScript_addsRetryColumnsAndSealsExistingFailedRows() throws Exception {
+        Path sql = Path.of("src/main/resources/db/har-callback-retry.sql");
+        assertTrue(Files.exists(sql), "找不到建表脚本(本用例读的是相对 java/ 的仓库路径): " + sql.toAbsolutePath());
+        String text = new String(Files.readAllBytes(sql), StandardCharsets.UTF_8);
+
+        assertTrue(text.matches("(?s).*ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS notify_count int NOT NULL DEFAULT 0.*"),
+                "notify_count 列不见了 —— 每次回调尝试都写它(SQL 侧自增),缺列即每次写库抛错");
+        assertTrue(text.matches("(?s).*ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS last_notify_time timestamp.*"),
+                "last_notify_time 列不见了 —— 重试扫描的排序键,缺列即重试扫描每分钟抛一次错");
+        assertTrue(text.matches("(?s).*UPDATE pay_har_order SET notify_count = 8 WHERE notify_status = 'FAILED' AND notify_count = 0.*"),
+                "存量 FAILED 行的封顶句不见了/被改了 —— 「存量不补推」是 2026-10-09 的用户裁定:"
+                        + "新列默认 0 会让存量失败单立刻被当成「没试满」全部重推。值必须 = har.callback-max-retries(默认 8)");
+        assertTrue(text.matches("(?s).*CREATE INDEX IF NOT EXISTS idx_har_order_callback_retry\\s+ON pay_har_order \\(last_notify_time\\) WHERE notify_status = 'FAILED'.*"),
+                "重试扫描的部分索引不见了 —— 该查询唯一的热路径(谓词 + 排序键一个索引全包)");
+    }
+
+    /**
+     * 取 SET 子句里某一列**绑定**的值 —— 只看 SET,不看 WHERE。
+     *
+     * 为什么不直接倒 paramNameValuePairs 全集:那张表 SET 与 WHERE 共用,
+     * contains 可能被另一段满足(同 HarOrderServiceTest#setValueOf 的理由)。
+     */
+    private static Object setValueOf(UpdateWrapper<?> w, String column) {
+        for (String item : w.getSqlSet().split(",")) {
+            if (!item.startsWith(column + "=")) {
+                continue;
+            }
+            Matcher m = Pattern.compile("paramNameValuePairs\\.(\\w+)").matcher(item);
+            assertTrue(m.find(), "这一列没有绑定值,形状不认识: " + item);
+            return w.getParamNameValuePairs().get(m.group(1));
+        }
+        fail("SET 子句里没有这一列: " + column + ",实测 SET: " + w.getSqlSet());
+        return null;
+    }
+
+    /** 只取 WHERE 段引用到的绑定值,不倒参数表全集 —— 后者会把 SET 的绑定值也算进来 */
+    private static Set<Object> whereBoundValues(UpdateWrapper<?> w) {
+        Matcher m = Pattern.compile("paramNameValuePairs\\.([A-Za-z0-9_]+)")
+                .matcher(w.getSqlSegment());
+        Set<Object> out = new HashSet<>();
+        while (m.find()) {
+            out.add(w.getParamNameValuePairs().get(m.group(1)));
+        }
+        return out;
+    }
+}

+ 157 - 1
java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java

@@ -33,7 +33,12 @@ import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
 import java.util.LinkedHashSet;
+import java.util.List;
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -62,12 +67,13 @@ class HarOrderServiceTest {
     @Mock private HarShopService shopService;
     @Mock private HarClient harClient;
     @Mock private OrderEventPublisher publisher;
+    @Mock private HarCallbackService callbackService;
 
     private HarOrderService service;
 
     @BeforeEach
     void setUp() {
-        service = new HarOrderService(orderMapper, merchantService, shopService, harClient, publisher);
+        service = new HarOrderService(orderMapper, merchantService, shopService, harClient, publisher, callbackService);
         // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
         // (否则 getSqlSegment() 抛 MybatisPlusException: can not find lambda cache for this entity。
         //   同款引导见 LsyCallbackServiceTest / AlipayBatchPayServiceTest。)
@@ -548,6 +554,22 @@ class HarOrderServiceTest {
         verify(orderMapper, never()).insert(any());
     }
 
+    // ==================== 商家回调的触发面(2026-10-09 补齐轮询腿) ====================
+    //
+    // 上游通知窗口只有约 25 秒,丢了的通知全靠轮询收敛 —— 收敛出真流转就必须补一次
+    // 商家回调,否则商户永远收不到这一笔。两个闸门:写库成功(rows>0)+ 真流转。
+
+    /** 下单(0 等待支付)不推:调用方同步已拿到响应,后续流转由通知/轮询送 */
+    @Test
+    void create_doesNotNotifyEnterprise() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto(), "E1", "KEY1");
+
+        verifyNoInteractions(callbackService);
+    }
+
     // ==================== 主动查询 ====================
 
     /**
@@ -619,6 +641,93 @@ class HarOrderServiceTest {
         assertNotNull(o.getNextQueryTime());
     }
 
+    /** 状态真变了且写库成功 ⇒ 回调一次,且必须晚于写库(回调报文取写库后的这一行) */
+    @Test
+    void sync_statusTransition_notifiesEnterpriseAfterPersisting() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(harClient.query(any())).thenReturn(response("10000", "{\"status\":1,\"order_no\":\"UP-1\"}"));
+        when(orderMapper.updateById(any())).thenReturn(1);
+
+        service.syncFromUpstream(o);
+
+        InOrder inOrder = inOrder(orderMapper, callbackService);
+        inOrder.verify(orderMapper).updateById(any());
+        inOrder.verify(callbackService).notifyEnterprise(o);
+        assertEquals(HarOrderStatus.PAID, o.getTradeStatus(), "回调报文取的是写库后的这一行");
+    }
+
+    /**
+     * 状态没变(0→0 的常规轮询)⇒ 不回调。
+     *
+     * 每轮都推一帧会把商户的回调面变成噪音。publisher 那条 verify 是正对照:
+     * 这一支照常发订单信号(页面上仍会刷新),只是不碰商家回调。
+     */
+    @Test
+    void sync_noTransition_doesNotNotifyEnterprise() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(harClient.query(any())).thenReturn(response("10000", "{\"status\":0}"));
+        when(orderMapper.updateById(any())).thenReturn(1);
+
+        service.syncFromUpstream(o);
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
+        verifyNoInteractions(callbackService);
+    }
+
+    /**
+     * updateById 被乐观锁挡掉(返回 0 行)⇒ 不许回调。
+     *
+     * 这一刻内存里的 order 已经是 PAID,但库里没有这一行 —— 回调出去就是把一个
+     * 没写进库的状态宣称给商户。下一轮轮询会重新检出流转并把这次补上。
+     */
+    @Test
+    void sync_updateByIdMissed_doesNotNotifyEnterprise() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(harClient.query(any())).thenReturn(response("10000", "{\"status\":1}"));
+        when(orderMapper.updateById(any())).thenReturn(0);
+
+        service.syncFromUpstream(o);
+
+        verifyNoInteractions(callbackService);
+    }
+
+    /** 状态没变但退款时间第一次出现(2 已退款 + refund_time 从无到有)也是一次流转 */
+    @Test
+    void sync_refundTimeNewlyKnown_notifiesEvenWhenStatusUnchanged() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.REFUNDED);
+        when(harClient.query(any())).thenReturn(response("10000",
+                "{\"status\":2,\"refund_time\":\"2026-10-09 12:00:00\"}"));
+        when(orderMapper.updateById(any())).thenReturn(1);
+
+        service.syncFromUpstream(o);
+
+        assertNotNull(o.getRefundTime());
+        verify(callbackService).notifyEnterprise(o);
+    }
+
+    /**
+     * 同一时刻的不同时区表示**不算**流转(按 Instant 比较)。
+     *
+     * 库读回的 refund_time 可能带别的偏移(pgjdbc 读 timestamp 挂 UTC 的历史事故),
+     * 而新解析的是系统时区表示 ⇒ 若按 OffsetDateTime.equals 比,退款成功后每一轮
+     * 轮询都会判成「变了」并重发回调。这里用 withOffsetSameInstant 造出同刻异表示。
+     */
+    @Test
+    void sync_refundTimeSameInstantDifferentOffset_isNotATransition() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.REFUNDED);
+        o.setRefundTime(LocalDateTime.of(2026, 10, 9, 12, 0)
+                .atZone(ZoneId.systemDefault()).toOffsetDateTime()
+                .withOffsetSameInstant(ZoneOffset.UTC));
+        when(harClient.query(any())).thenReturn(response("10000",
+                "{\"status\":2,\"refund_time\":\"2026-10-09 12:00:00\"}"));
+        when(orderMapper.updateById(any())).thenReturn(1);
+
+        service.syncFromUpstream(o);
+
+        assertNotNull(o.getRefundTime());
+        verifyNoInteractions(callbackService);
+    }
+
     // ==================== 轮询排期 ====================
 
     @Test
@@ -1221,6 +1330,53 @@ class HarOrderServiceTest {
         verify(publisher, times(2)).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
     }
 
+    /** 受理(1 已支付 → 4 退款中)就是一次真实流转 ⇒ 商户要立刻收到 4 那一帧 */
+    @Test
+    void openRefund_preemptHit_notifiesEnterpriseWithRefundingFrame() {
+        when(orderMapper.selectOne(any())).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(1);
+        when(harClient.refund(any())).thenReturn(response("10000", null));
+        List<String> statusAtNotify = new ArrayList<>();
+        doAnswer(inv -> {
+            statusAtNotify.add(((HarOrderEntity) inv.getArgument(0)).getTradeStatus());
+            return null;
+        }).when(callbackService).notifyEnterprise(any());
+
+        service.openRefund(refundDto());
+
+        assertEquals(List.of(HarOrderStatus.REFUNDING), statusAtNotify,
+                "等上游回话期间,商户侧就该是「退款中」");
+    }
+
+    /** 抢占没命中(一个字节都没改)⇒ 不回调(同 publisher 的负例纪律) */
+    @Test
+    void openRefund_preemptMissed_doesNotNotifyEnterprise() {
+        when(orderMapper.selectOne(any())).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(0);
+
+        assertThrows(BusinessException.class, () -> service.openRefund(refundDto()));
+
+        verifyNoInteractions(callbackService);
+    }
+
+    /** 上游拒绝 ⇒ 先 4 后 1 两帧都要送:缺了回退帧,商户会一直以为这笔在退款中 */
+    @Test
+    void openRefund_upstreamRejected_notifiesBothRefundingAndRollbackFrames() {
+        when(orderMapper.selectOne(any())).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(1);
+        when(harClient.refund(any())).thenReturn(response("40004", null));
+        List<String> statusAtNotify = new ArrayList<>();
+        doAnswer(inv -> {
+            statusAtNotify.add(((HarOrderEntity) inv.getArgument(0)).getTradeStatus());
+            return null;
+        }).when(callbackService).notifyEnterprise(any());
+
+        assertThrows(BusinessException.class, () -> service.openRefund(refundDto()));
+
+        assertEquals(List.of(HarOrderStatus.REFUNDING, HarOrderStatus.PAID), statusAtNotify,
+                "顺序=先受理后回退;缺一帧商户侧就停在错的状态上");
+    }
+
     /** 优先用上游单号退款;拿不到时走 third_order_no(同查询口径) */
     @Test
     void openRefund_prefersUpstreamOrderNo() {