yeg
2026-09-17 e71919933b57c4284e19cad12b4791ef52a58c8a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
package com.talentcard.server.business.payment;
 
import java.util.List;
import java.util.Map;
 
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.payment.HuifuRefundService.StrandedRefundOutcome;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
 
/**
 * 悬账的自动出口:通道确认收到、但订单已经取消的钱,自动退回付款人。
 *
 * <p>这类钱是「回调没到达 + 用户后来又把预支付单付了」的产物:本地订单已取消,通道却把钱收下了。
 * 它只有一条正确出路——原路退回付款人,没有需要人判断的地方;留给人工只会让它一直挂在页面上。
 *
 * <p>消费的是 {@code PAYMENT_ORDER_CANCELLED} 问题记录(由结算服务在「取消后才确认收款」时写下):
 * 调窄口子退款入口,受理成功就把问题记录结掉;**通道明确拒绝**(例如商户基本户余额不足)则升级告警
 * 转人工,问题记录保持未结——绝不静默吞掉。
 *
 * <p>这不是新增「让状态能推进」的任务:钱的真相本来就由对账的查单带回来,这里只是把它落到退款上。
 */
@Component
public class StrandedPaymentRefundRetry {
 
    private static final Logger log = LoggerFactory.getLogger(StrandedPaymentRefundRetry.class);
    /** 结算服务为「取消后才确认收款」写下的问题类型。 */
    static final String ISSUE_ORDER_CANCELLED = "PAYMENT_ORDER_CANCELLED";
    /** 自动退款被通道拒绝时写下的问题类型:与上面那条分开,管理端一眼能看出「这笔要人工」。 */
    static final String ISSUE_REFUND_REJECTED = "STRANDED_REFUND_REJECTED";
    /** 每轮最多处理几笔,避免一次拉太多拖长任务。 */
    private static final int BATCH_SIZE = 20;
 
    private final JdbcTemplate jdbc;
    private final HuifuRefundService refundService;
    private final PaymentIssueRecorder issues;
 
    @Value("${app.payment.stranded-refund.enabled:true}")
    private boolean enabled = true;
 
    public StrandedPaymentRefundRetry(JdbcTemplate jdbc, HuifuRefundService refundService, PaymentIssueRecorder issues) {
        this.jdbc = jdbc;
        this.refundService = refundService;
        this.issues = issues;
    }
 
    /**
     * 定期把悬账退回付款人。默认 5 分钟一轮;用 {@code app.payment.stranded-refund.enabled} 关闭,
     * 用 {@code ...fixed-delay-ms} 调整频率。
     */
    @Scheduled(fixedDelayString = "${app.payment.stranded-refund.fixed-delay-ms:300000}")
    public void refundStrandedPayments() {
        if (!enabled) return;
        try {
            int refunded = retry(BATCH_SIZE);
            if (refunded > 0) log.info("Started {} stranded refund(s) for cancelled orders", refunded);
        } catch (RuntimeException exception) {
            log.warn("Stranded refund run failed: {}", exception.getClass().getSimpleName());
        }
    }
 
    /** 返回本轮真正发起过退款的笔数(供测试与运维使用)。 */
    public int retry(int limit) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select business_no, order_no, amount from payment_reconciliation_issue
                where issue_type = ? and provider = 'HUIFU' and status = 'open'
                  and order_no is not null and order_no <> ''
                order by detected_at asc, id asc
                limit ?
                """, ISSUE_ORDER_CANCELLED, Math.max(1, limit)));
        int refunded = 0;
        for (Map<String, Object> row : rows) {
            String paymentNo = text(row.get("businessNo"));
            String orderNo = text(row.get("orderNo"));
            long amount = number(row.get("amount"));
            StrandedRefundOutcome outcome = refundService.refundStranded(orderNo);
            switch (outcome) {
                case SUBMITTED -> {
                    // 退款已受理:这类钱有了唯一的出口,问题记录可以结了。退款自己的最终结果由退款
                    // 对账与读路径收敛(通道对退款也是先回「处理中」)。
                    issues.resolve(ISSUE_ORDER_CANCELLED, paymentNo, "system");
                    issues.resolve(ISSUE_REFUND_REJECTED, paymentNo, "system");
                    refunded++;
                    log.info("Stranded payment {} of cancelled order {} is being refunded to the payer", paymentNo, orderNo);
                }
                case REJECTED -> {
                    // 通道明确拒绝:钱退不回去,必须人工介入。问题记录保持未结,另记一条告警类型,
                    // 让管理端一眼看到「这笔悬账退不动」。
                    issues.issue(ISSUE_REFUND_REJECTED, paymentNo, orderNo, "refunded", "refund rejected", amount,
                            "The provider rejected the automatic refund of a cancelled order's payment; a human has to refund it");
                    log.error("Stranded payment {} of cancelled order {} could not be refunded automatically;"
                            + " the provider rejected the refund and it needs a human", paymentNo, orderNo);
                }
                default -> log.warn("Stranded payment {} of cancelled order {} has no refundable payment yet;"
                        + " the issue stays open for a human", paymentNo, orderNo);
            }
        }
        return refunded;
    }
 
    private long number(Object value) { return value instanceof Number number ? number.longValue() : 0; }
    private String text(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
}