yeg
2026-09-16 8fe2249e07ebe15b33e0231cd9dafa7c303486f6
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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
package com.talentcard.server.business.payment;
 
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.payment.huifu.HuifuPayload;
import com.talentcard.server.business.payment.huifu.HuifuPayload.TransStatus;
import com.talentcard.server.business.payment.PaymentSettlementService.ProviderPaymentFact;
import com.talentcard.server.business.payment.PaymentSettlementService.SettlementOutcome;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
import com.talentcard.server.business.payment.huifu.HuifuPaymentCloseCommand;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.business.payment.huifu.HuifuPaymentQueryCommand;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
 
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
 
/**
 * Second confirmation path for payments whose callback did not arrive or whose outcome was unknown.
 *
 * <p>A query result is validated exactly like a callback: it is described as a
 * {@link ProviderPaymentFact} and settled by {@link PaymentSettlementService}, so both paths produce
 * the same local result. A query that cannot confirm the payment leaves the transaction pending and
 * records an open issue instead of guessing.
 */
@Service
public class PaymentReconciliationService {
    private static final String PROVIDER = "HUIFU";
    /** 订单已取消、但通道侧的预支付单还没确认关掉的流水:对账任务要一直重试关单,直到有结论。 */
    private static final String CANCEL_UNCONFIRMED_STATUS = "CANCEL_UNCONFIRMED";
    private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
    private final JdbcTemplate jdbc;
    private final HuifuMiniProgramPaymentGateway paymentGateway;
    private final HuifuRefundService refundService;
    private final PaymentSettlementService settlementService;
    private final PaymentIssueRecorder issues;
 
    public PaymentReconciliationService(JdbcTemplate jdbc, HuifuMiniProgramPaymentGateway paymentGateway, HuifuRefundService refundService,
                                        PaymentSettlementService settlementService, PaymentIssueRecorder issues) {
        this.jdbc = jdbc;
        this.paymentGateway = paymentGateway;
        this.refundService = refundService;
        this.settlementService = settlementService;
        this.issues = issues;
    }
 
    public Map<String, Object> reconcile(int pendingAfterMinutes) {
        int paymentChecked = reconcilePayments(pendingAfterMinutes);
        int refundChecked = reconcileRefunds(pendingAfterMinutes);
        return Map.of("paymentChecked", paymentChecked, "refundChecked", refundChecked);
    }
 
    /** What the provider said about the payments of one order whose local state is still undecided. */
    public enum PendingPaymentState {
        /** The provider confirmed money was collected: local state now has the order paid. */
        COLLECTED,
        /** The provider reports no collected payment, so local state may move on. */
        NONE,
        /** The provider could not say whether money was collected, so the question stays open. */
        UNCONFIRMED
    }
 
    public record PendingPaymentConfirmation(PendingPaymentState state, String paymentNo) {
        public boolean collected() { return state == PendingPaymentState.COLLECTED; }
    }
 
    /**
     * Asks the provider about every payment of one order that local state has not decided yet.
     *
     * <p>Throwing an order away — cancelling it, or asking the user to pay again — needs one answer
     * first: did the provider already collect money? Local state cannot answer that when the
     * notification never arrived and only the provider knows, so the provider is asked instead. A
     * confirmed payment settles the order; a provider that cannot answer stays unconfirmed, because
     * a question about money is never answered by guessing.
     *
     * <p>Runs in its own transaction: the caller may roll back (a refused cancellation throws), and
     * the provider's answer is a fact that has to survive that rollback.
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public PendingPaymentConfirmation confirmPendingPayments(String orderNo) {
        return decidePendingPayments(orderNo, false);
    }
 
    /**
     * 取消订单前的通道确认:除了确认这笔订单有没有收到钱,通道说没收款的预支付单还要在通道侧关掉。
     *
     * <p>只把本地流水标成已关闭是不够的:用户手上那张预支付单在通道侧还活着,照样能付进来,钱收了
     * 而订单已经取消。所以取消必须以通道侧的关单结论为准——关单回执说原交易已经成功就拒绝取消,
     * 说还在处理中就让用户稍后重试,只有确认关掉、或通道明确说这笔请求从未收到钱且后续也无法关单
     * (见 {@link #closePayment})时才继续取消。
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public PendingPaymentConfirmation closePendingPayments(String orderNo) {
        return decidePendingPayments(orderNo, true);
    }
 
    private PendingPaymentConfirmation decidePendingPayments(String orderNo, boolean closeWhenUncollected) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select p.payment_no, p.order_no, p.amount, p.status, p.provider_transaction_no, p.created_at,
                       coalesce(p.provider_huifu_id, o.collect_huifu_id) as huifu_id
                from payment_transaction p
                join benefit_order o on o.id = p.order_id
                where p.provider = ? and p.order_no = ? and p.status not in ('SUCCEEDED', 'CLOSED')
                order by p.id asc
                """, PROVIDER, orderNo));
        PendingPaymentConfirmation confirmation = new PendingPaymentConfirmation(PendingPaymentState.NONE, "");
        for (Map<String, Object> row : rows) {
            PendingPaymentConfirmation current = confirmPayment(row, closeWhenUncollected);
            if (current.collected()) return current;
            if (current.state() == PendingPaymentState.UNCONFIRMED) confirmation = current;
        }
        return confirmation;
    }
 
    /**
     * One payment attempt, judged by what the provider says about it.
     *
     * <p>Only the states that cannot possibly hold money count as uncollected: a trade the provider
     * failed, one it registered but never started, and a request number it does not know at all.
     * Everything else — money in flight, an unreadable answer, a query that never got through —
     * leaves the money question open rather than closed.
     */
    private PendingPaymentConfirmation confirmPayment(Map<String, Object> transaction, boolean closeWhenUncollected) {
        String paymentNo = text(transaction.get("paymentNo"));
        String orderNo = text(transaction.get("orderNo"));
        long amount = number(transaction.get("amount"));
        String huifuId = text(transaction.get("huifuId"));
        if (huifuId.isBlank()) {
            // Without the collecting sub-merchant the provider cannot be asked about this payment.
            issues.issue(PaymentSettlementService.ISSUE_COLLECTOR_UNKNOWN, paymentNo, orderNo, "collecting sub-merchant", "", amount,
                    "Order has no collecting Huifu sub-merchant");
            return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
        }
        ProviderResult result;
        try {
            result = ProviderResult.from(paymentGateway.queryMiniProgramPayment(new HuifuPaymentQueryCommand(
                    paymentNo, huifuId, text(transaction.get("providerTransactionNo")), requestDate(transaction.get("createdAt")))));
        } catch (HuifuPaymentGatewayException exception) {
            issues.issue("PAYMENT_QUERY_FAILED", paymentNo, orderNo, "payment status", "query failed", amount,
                    exception.isOutcomeUnknown() ? "Provider query outcome unknown" : "Provider query rejected");
            return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
        } catch (RuntimeException exception) {
            issues.issue("PAYMENT_QUERY_FAILED", paymentNo, orderNo, "payment status", "local error", amount, "Payment confirmation failed");
            return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
        }
        if (result.success()) {
            SettlementOutcome outcome = settlementService.settle(new ProviderPaymentFact(
                    paymentNo, HuifuPayload.firstNonBlank(result.huifuId(), huifuId), result.amountInCent(),
                    result.providerNo(), result.code(), result.message(), "QUERY"));
            if (outcome.accepted()) return new PendingPaymentConfirmation(PendingPaymentState.COLLECTED, paymentNo);
            if (outcome == SettlementOutcome.UNMATCHED || outcome == SettlementOutcome.NOT_SETTLEABLE) {
                // Every other mismatch is already recorded with its own issue type by the settlement service.
                issues.issue("PAYMENT_QUERY_CONFLICT", paymentNo, orderNo, "settled payment", outcome.name(), amount,
                        "Provider query succeeded but the local payment could not be settled");
            }
            // Money was collected but local rules refused it: it is still money, so the question stays open.
            return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
        }
        if (result.notCollected()) {
            return closeWhenUncollected
                    ? closePayment(transaction)
                    : new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo);
        }
        return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
    }
 
    /**
     * 通道说这笔请求没收过钱:把预支付单在通道侧关掉,取消才算真的取消。
     *
     * <p>关不掉的预支付单仍然可能被支付,所以「关单没成功」不能当作「可以放心取消」:
     * <ul>
     *   <li>确认关单成功:本地记为已关闭,可以取消。</li>
     *   <li>关单回执说原交易已经成功:钱可能在,只是查单没查到——拒绝取消并记一条问题记录给人工。</li>
     *   <li>关单处理中:拒绝取消,让用户稍后重试(下次会重新确认)。</li>
     *   <li>其余拒绝(未满一分钟不允许关单、原交易不支持关单、关单请求本身失败):允许取消,但流水记为
     *       {@code CANCEL_UNCONFIRMED} 而不是已关闭——对账任务仍会盯着这笔请求,万一用户后来真的付了,
     *       会留下问题记录让人处理,而不是无声地变成一笔没有订单的悬账。</li>
     * </ul>
     */
    private PendingPaymentConfirmation closePayment(Map<String, Object> transaction) {
        String paymentNo = text(transaction.get("paymentNo"));
        String orderNo = text(transaction.get("orderNo"));
        long amount = number(transaction.get("amount"));
        String huifuId = text(transaction.get("huifuId"));
        Map<String, Object> response;
        try {
            response = paymentGateway.closeMiniProgramPayment(new HuifuPaymentCloseCommand(
                    BusinessIdGenerator.next("C"), paymentNo, huifuId, text(transaction.get("providerTransactionNo")),
                    requestDate(transaction.get("createdAt"))));
        } catch (RuntimeException exception) {
            return keepWatchableAfterRefusedClose(transaction, "CLOSE_FAILED", "Provider close request failed", amount);
        }
        CloseResult close = CloseResult.from(response);
        if (close.closed()) {
            markCancelledTransaction(paymentNo, "CLOSED", close.code(), close.message());
            issues.resolve("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, "system");
            return new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo);
        }
        if (close.originalTradeSucceeded()) {
            issues.issue("PAYMENT_CLOSE_CONFLICT", paymentNo, orderNo, "closed at provider",
                    "original trade already succeeded", amount,
                    "Provider refused to close the payment because the original trade already succeeded");
            return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
        }
        if (close.processing()) {
            issues.issue("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, orderNo, "closed at provider", "close processing", amount,
                    "Provider is still closing the payment");
            return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
        }
        return keepWatchableAfterRefusedClose(transaction, close.code(), close.message(), amount);
    }
 
    private PendingPaymentConfirmation keepWatchableAfterRefusedClose(Map<String, Object> transaction, String code,
                                                                     String message, long amount) {
        String paymentNo = text(transaction.get("paymentNo"));
        markCancelledTransaction(paymentNo, "CANCEL_UNCONFIRMED", code, message);
        issues.issue("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, text(transaction.get("orderNo")), "closed at provider",
                code, amount, "Payment could not be closed at the provider; the prepay may still be payable");
        return new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo);
    }
 
    private void markCancelledTransaction(String paymentNo, String status, String code, String message) {
        jdbc.update("""
                update payment_transaction
                set status = ?, closed_at = coalesce(closed_at, now()),
                    provider_response_code = ?, provider_response_message = ?
                where payment_no = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CREATE_FAILED', 'CANCEL_UNCONFIRMED')
                """, status, code, message, paymentNo);
    }
 
    /** 通道对关单请求的答复。只有「关掉了」和「原交易已成功」是明确结论,其余一律当作没确认下来。 */
    private record CloseResult(String code, String message, String status, String originalStatus) {
        private static CloseResult from(Map<String, Object> response) {
            Map<String, Object> data = response == null ? Map.of()
                    : (response.get("data") instanceof Map<?, ?> map ? (Map<String, Object>) map : response);
            return new CloseResult(HuifuPayload.code(data), HuifuPayload.message(data),
                    HuifuPayload.value(data, "trans_stat", "transStat"),
                    HuifuPayload.value(data, "org_trans_stat", "orgTransStat"));
        }
 
        boolean closed() {
            return "S".equalsIgnoreCase(status) && HuifuPayload.successCode(code);
        }
 
        boolean originalTradeSucceeded() {
            return "S".equalsIgnoreCase(originalStatus);
        }
 
        boolean processing() {
            return "P".equalsIgnoreCase(status) || "00000100".equals(code);
        }
    }
 
    private int reconcilePayments(int pendingAfterMinutes) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select p.payment_no, p.order_no, p.amount, p.status, p.provider_transaction_no, p.created_at,
                       coalesce(p.provider_huifu_id, o.collect_huifu_id) as huifu_id
                from payment_transaction p
                join benefit_order o on o.id = p.order_id
                where p.provider = ? and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED')
                  and p.created_at <= date_sub(now(), interval ? minute)
                order by p.id asc limit 100
                """, PROVIDER, Math.max(1, pendingAfterMinutes)));
        for (Map<String, Object> row : rows) {
            if (CANCEL_UNCONFIRMED_STATUS.equals(text(row.get("status")))) retryClose(row);
            else reconcilePayment(row, pendingAfterMinutes);
        }
        return rows.size();
    }
 
    /**
     * 关单补偿:取消订单时通道不让关(未满一分钟、通道临时拒绝、关单请求失败)而留下的
     * {@code CANCEL_UNCONFIRMED},等过了限制再关一次——不关掉,用户手上那张预支付单一直能付进来。
     *
     * <p>先确认、再关单:通道说这笔请求没收过钱就关掉,落成已关闭;通道说钱已经收下了则绝不再尝试关单,
     * 交给结算与问题记录把它变成一笔看得见、可退的账。两种情况都说明这一轮有了结论,把上一轮留下的
     * 关单问题记录结掉。
     */
    private void retryClose(Map<String, Object> transaction) {
        String paymentNo = text(transaction.get("paymentNo"));
        issues.resolve("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, "system");
        confirmPayment(transaction, true);
    }
 
    private void reconcilePayment(Map<String, Object> transaction, int pendingAfterMinutes) {
        String paymentNo = text(transaction.get("paymentNo"));
        String orderNo = text(transaction.get("orderNo"));
        long amount = number(transaction.get("amount"));
        String huifuId = text(transaction.get("huifuId"));
        if (huifuId.isBlank()) {
            // Without the collecting sub-merchant the provider cannot be asked about this payment.
            issues.issue(PaymentSettlementService.ISSUE_COLLECTOR_UNKNOWN, paymentNo, orderNo, "collecting sub-merchant", "", amount,
                    "Order has no collecting Huifu sub-merchant");
            return;
        }
        try {
            Map<String, Object> response = paymentGateway.queryMiniProgramPayment(new HuifuPaymentQueryCommand(
                    paymentNo, huifuId, text(transaction.get("providerTransactionNo")), requestDate(transaction.get("createdAt"))));
            ProviderResult result = ProviderResult.from(response);
            if (result.success()) {
                // The query was made with the order's sub-merchant, so that number is the expected one.
                SettlementOutcome outcome = settlementService.settle(new ProviderPaymentFact(
                        paymentNo, HuifuPayload.firstNonBlank(result.huifuId(), huifuId), result.amountInCent(),
                        result.providerNo(), result.code(), result.message(), "QUERY"));
                if (outcome.accepted()) {
                    issues.resolve("PAYMENT_PENDING_TIMEOUT", paymentNo, "system");
                } else if (outcome == SettlementOutcome.UNMATCHED || outcome == SettlementOutcome.NOT_SETTLEABLE) {
                    // Every other mismatch is already recorded with its own issue type by the settlement service.
                    issues.issue("PAYMENT_QUERY_CONFLICT", paymentNo, orderNo, "settled payment", outcome.name(), amount,
                            "Provider query succeeded but the local payment could not be settled");
                }
            } else if (result.failed()) {
                markCreateFailed(paymentNo, result.code(), result.message());
                issues.issue("PAYMENT_PROVIDER_FAILED", paymentNo, orderNo, "payment pending", result.status(), amount, result.message());
            } else {
                issues.issue("PAYMENT_PENDING_TIMEOUT", paymentNo, orderNo, "payment completed", result.status(), amount,
                        "Payment remains pending after " + pendingAfterMinutes + " minutes");
            }
        } catch (HuifuPaymentGatewayException exception) {
            issues.issue("PAYMENT_QUERY_FAILED", paymentNo, orderNo, "payment status", "query failed", amount,
                    exception.isOutcomeUnknown() ? "Provider query outcome unknown" : "Provider query rejected");
        } catch (RuntimeException exception) {
            issues.issue("PAYMENT_QUERY_FAILED", paymentNo, orderNo, "payment status", "local error", amount, "Payment reconciliation failed");
        }
    }
 
    private void markCreateFailed(String paymentNo, String code, String message) {
        jdbc.update("""
                update payment_transaction
                set status = 'CREATE_FAILED', provider_response_code = ?, provider_response_message = ?
                where payment_no = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN')
                """, code, message, paymentNo);
    }
 
    private int reconcileRefunds(int pendingAfterMinutes) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select refund_no, order_no, refund_amount, created_at from order_refund
                where deleted = 0 and provider = ? and refund_status = 1
                  and created_at <= date_sub(now(), interval ? minute)
                order by id asc limit 100
                """, PROVIDER, Math.max(1, pendingAfterMinutes)));
        for (Map<String, Object> row : rows) {
            String refundNo = text(row.get("refundNo"));
            try {
                Map<String, Object> result = refundService.query(refundNo);
                if (number(result.get("refundStatus")) == 1) issues.issue("REFUND_PENDING_TIMEOUT", refundNo, text(row.get("orderNo")), "refund completed", "refund pending", number(row.get("refundAmount")), "Refund remains pending after " + pendingAfterMinutes + " minutes");
                else issues.resolve("REFUND_PENDING_TIMEOUT", refundNo, "system");
            } catch (RuntimeException exception) {
                issues.issue("REFUND_QUERY_FAILED", refundNo, text(row.get("orderNo")), "refund status", "query failed", number(row.get("refundAmount")), "Refund reconciliation failed");
            }
        }
        return rows.size();
    }
 
    private String requestDate(Object createdAt) { return createdAt instanceof LocalDateTime time ? REQUEST_DATE.format(time) : REQUEST_DATE.format(LocalDateTime.now()); }
    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(); }
 
    @SuppressWarnings("unchecked")
    private record ProviderResult(boolean success, boolean failed, String status, String code, String message, String providerNo,
                                  String huifuId, Long amountInCent) {
        /** 汇付「交易不存在」:通道不认识这个请求流水号,也就不可能为它收过钱。 */
        private static final String TRADE_MISSING = "23000001";
 
        private static ProviderResult from(Map<String, Object> response) {
            Map<String, Object> data = response.get("data") instanceof Map<?, ?> map ? (Map<String, Object>) map : response;
            String status = HuifuPayload.value(data, "trans_stat", "transStat");
            String code = HuifuPayload.code(data);
            String message = HuifuPayload.message(data);
            // 支付查询回执里通道全局流水号的字段是 org_hf_seq_id;hf_seq_id 只出现在异步通知里。
            // 这里保留 hf_seq_id 兜底,避免通道换字段名后又被静默丢成 NULL。
            String providerNo = HuifuPayload.value(data, "org_hf_seq_id", "orgHfSeqId", "hf_seq_id", "hfSeqId");
            String huifuId = HuifuPayload.value(data, "huifu_id", "huifuId");
            TransStatus classification = HuifuPayload.status(status);
            boolean success = classification == TransStatus.SUCCESS && HuifuPayload.successCode(code);
            // An unknown or missing status means "cannot confirm yet", not "failed".
            boolean pending = classification == TransStatus.PENDING || classification == TransStatus.UNKNOWN;
            return new ProviderResult(success, !success && !pending, status, code, message, providerNo, huifuId,
                    HuifuPayload.amountInCent(HuifuPayload.value(data, "trans_amt", "transAmt")));
        }
 
        /**
         * 通道是否明确排除了「已经收到钱」。
         *
         * <p>失败的交易、只登记未开始的交易({@code I} 初始)和通道根本不认识的请求流水号,都不可能
         * 已经把钱收下;其余回执(处理中、读不出来的状态)都可能,因此不能算作已排除。
         */
        boolean notCollected() {
            return failed || "I".equalsIgnoreCase(status) || TRADE_MISSING.equals(code);
        }
    }
}