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; import java.util.Set; /** * Second confirmation path for payments whose callback did not arrive or whose outcome was unknown. * *

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. * *

取消与放弃以通道的**关单回执**为准,而不是把查单当成关单的前置门:关单回执同时回答「关掉了没有」 * 和「原交易是不是已经成功」,比查单更强。查单只会说「处理中」,旧实现因此连关单都不试,用户手上那张 * 预支付单一直能付进来,订单也永远既付不了又退不出(判定见 {@code docs/adr/0003})。 */ @Service public class PaymentReconciliationService { private static final String PROVIDER = "HUIFU"; /** 订单已取消、但通道侧的预支付单还没确认关掉的流水:对账任务要一直重试关单,直到有结论。 */ private static final String CANCEL_UNCONFIRMED_STATUS = "CANCEL_UNCONFIRMED"; /** 已发出关单请求、通道只回「处理中」的支付尝试:可等待、可重试的中间态,不是终点。 */ private static final String CLOSING_STATUS = "CLOSING"; /** 关单重试上限:到上限停止重试,把这次尝试落成「取消未确认」并结束订单。 */ private static final int MAX_CLOSE_ATTEMPTS = 3; /** 关单快车道间隔(分钟)。 * *

通道不允许关闭一分钟以内的订单,所以它同时也是最短的重试间隔:比这更密只会拿到同样的拒绝。 */ private static final int CLOSE_RETRY_MINUTES = 1; 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 reconcile(int pendingAfterMinutes) { int paymentChecked = reconcilePayments(pendingAfterMinutes); int refundChecked = reconcileRefunds(pendingAfterMinutes); // 关单结论回来之后,替已经点过取消的用户把订单收掉。放在查单/关单之后:同一轮里先把这次尝试 // 问出结论,再决定这笔订单能不能结束。 int cancelledOnRequest = cancelOrdersAwaitingCloseConclusion(); return Map.of("paymentChecked", paymentChecked, "refundChecked", refundChecked, "cancelledOnRequest", cancelledOnRequest); } /** * 落地「用户点过取消、但当时关单还没有结论」的订单。 * *

取消在通道说「关单处理中」时只能先受理(见 {@code PendingOrderPortalService.cancel}),订单要等 * 每笔尝试都有了结论才能结束。那之后必须有人替用户把这一步走完:关单关掉了就取消,通道说钱收下了就 * 交给结算把它翻成已支付——否则用户点的取消会随着页面关掉而丢失,订单一直挂在「待支付」,他只能再点 * 一次取消,或者等到 15 分钟超时被清理收掉。 * *

条件与「取消」本身一致:不留未定局的尝试(它们还可能被支付),也不取消已经收到钱的订单。 */ private int cancelOrdersAwaitingCloseConclusion() { return jdbc.update(""" update benefit_order o set o.order_status = 2 where o.order_status = 0 and o.deleted = 0 and o.cancel_requested_at is not null and not exists ( select 1 from payment_transaction p where p.order_id = o.id and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED') ) and not exists ( select 1 from payment_transaction p where p.order_id = o.id and p.status = 'SUCCEEDED' ) """); } /** 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. * *

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. * *

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, null); } /** * 取消订单前的通道确认:除了确认这笔订单有没有收到钱,通道说没收款的预支付单还要在通道侧关掉。 * *

只把本地流水标成已关闭是不够的:用户手上那张预支付单在通道侧还活着,照样能付进来,钱收了 * 而订单已经取消。所以取消必须以通道侧的关单结论为准——关单回执说原交易已经成功就落成已支付并拒绝 * 取消,说还在处理中就让这次尝试停在 {@code CLOSING} 并交给后台重试,只有确认关掉、或通道明确拒绝 * 关单(见 {@link #keepWatchableAfterRefusedClose})时才继续取消。 */ @Transactional(propagation = Propagation.REQUIRES_NEW) public PendingPaymentConfirmation closePendingPayments(String orderNo) { return decidePendingPayments(orderNo, true, null); } /** * 「放弃支付」:客户端在收银台里点了关闭,请求服务端立刻把这笔尝试关掉。 * *

只触发关单,绝不结算。客户端关于钱的陈述不可信——它说「我放弃了」只能当作一个关单信号, * 收款与否一律以关单回执为准。因此这里刻意不调用 {@link #confirmPayment} 的确认分支:即使回执说原 * 交易已经成功,也只记一条问题记录,让后续的查单与对账把它落成真实的资金事实。 * * @param paymentNo 客户端手上那张预支付单;为空时处理该订单所有未定局的尝试。 */ @Transactional(propagation = Propagation.REQUIRES_NEW) public PendingPaymentConfirmation abandonPendingPayments(String orderNo, String paymentNo) { PendingPaymentConfirmation confirmation = new PendingPaymentConfirmation(PendingPaymentState.NONE, ""); for (Map row : undecidedPayments(orderNo, paymentNo)) { PendingPaymentConfirmation current = closePayment(row, false); if (current.state() == PendingPaymentState.UNCONFIRMED) confirmation = current; } return confirmation; } private PendingPaymentConfirmation decidePendingPayments(String orderNo, boolean closeWhenUncollected, String paymentNo) { PendingPaymentConfirmation confirmation = new PendingPaymentConfirmation(PendingPaymentState.NONE, ""); for (Map row : undecidedPayments(orderNo, paymentNo)) { PendingPaymentConfirmation current = confirmPayment(row, closeWhenUncollected); if (current.collected()) return current; if (current.state() == PendingPaymentState.UNCONFIRMED) confirmation = current; } return confirmation; } /** 一笔订单里本地还没定局的支付尝试(含 CLOSING 与 CANCEL_UNCONFIRMED,它们的预支付单仍可能被支付)。 */ private List> undecidedPayments(String orderNo, String paymentNo) { String filter = paymentNo == null || paymentNo.isBlank() ? "" : " and p.payment_no = ?"; Object[] args = paymentNo == null || paymentNo.isBlank() ? new Object[] { PROVIDER, orderNo } : new Object[] { PROVIDER, orderNo, paymentNo }; return DatabaseRows.normalize(jdbc.queryForList(""" select p.payment_no, p.order_no, p.amount, p.status, p.provider_transaction_no, p.created_at, p.close_attempts, 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') """ + filter + " order by p.id asc", args)); } /** * One payment attempt, judged by what the provider says about it. * *

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 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 (closeWhenUncollected) return closePayment(transaction, true); return new PendingPaymentConfirmation( result.notCollected() ? PendingPaymentState.NONE : PendingPaymentState.UNCONFIRMED, paymentNo); } /** * 用关单回执给这次支付尝试下结论。 * *

关不掉的预支付单仍然可能被支付,所以「关单没成功」不能当作「可以放心取消」: *

* * @param followUpOnSucceededOriginalTrade 关单回执说原交易已成功时是否接着查通道并落成已支付。 * 取消那条路为真;「放弃支付」那条路为假——它只被允许触发关单,绝不产生任何收款判定。 */ private PendingPaymentConfirmation closePayment(Map transaction, boolean followUpOnSucceededOriginalTrade) { String paymentNo = text(transaction.get("paymentNo")); String orderNo = text(transaction.get("orderNo")); long amount = number(transaction.get("amount")); if (number(transaction.get("closeAttempts")) >= MAX_CLOSE_ATTEMPTS) { // 关单重试已到上限:不再打通道(同一条坏数据不该被无限重试),直接给这次尝试一个终止条件。 giveUpClosing(transaction); return new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo); } String huifuId = text(transaction.get("huifuId")); Map response; try { recordCloseAttempt(paymentNo); 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"); if (followUpOnSucceededOriginalTrade) { // 关单回执说原交易已经成功,那就再问一次通道:问得到金额就把它落成已支付(这一支的结论是 // 「拒绝取消」),问不到就只能留在关单中,绝不能因为查单没查到就说钱不在。 PendingPaymentConfirmation settled = confirmPayment(transaction, false); if (settled.collected()) return settled; } markClosing(transaction, close.code(), close.message()); return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo); } if (close.originalTradeAlreadyTerminal()) { // 关单回执说的不是「现在不让关」,而是「原交易已经终结」。此时再关只会一直拿到同一句拒绝 // (旧实现就把它当成了「关不掉=可能还能付」),而一张死掉的预支付单被交回客户端时,微信 // 收银台会直接报「订单状态异常,支付请求无效」;订单也会挂成一个既付不了又取消不了的僵尸。 // 这里落成已关闭:取消那条路与关单补偿都是先查过单、通道说过这笔钱没收下才走到关单的, // 「放弃支付」那条路则是用户刚关掉收银台——钱真收下了会有异步通知把它结算掉(迟到的成功 // 记成「取消后才收到钱」,交给悬账退款与人工),不会无声地消失。 markCancelledTransaction(paymentNo, "CLOSED", close.code(), close.message()); issues.resolve("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, "system"); return new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo); } if (close.processing()) { issues.issue("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, orderNo, "closed at provider", "close processing", amount, "Provider is still closing the payment"); markClosing(transaction, close.code(), close.message()); return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo); } return keepWatchableAfterRefusedClose(transaction, close.code(), close.message(), amount); } /** * 关单重试到上限:给这次尝试一个终止条件,同时不丢掉任何盯着钱的东西。 * *

到上限时通道既没说关掉、也没说钱收下了,而这张预支付单还可能被支付。此时订单不能继续挂着 * 「待支付」——那样它既付不了(不允许在同一订单上再建一张预支付单)也永远不会被自动取消,变成一笔 * 僵尸订单:同一件商品再也下不了单。所以这里把订单落成已取消,并把这次尝试留成 * {@code CANCEL_UNCONFIRMED}:订单状态不由自动取消的排除表管理,而是由这次明确的「不再重试」结论 * 结束;预支付单继续被对账盯着,万一用户后来真的付了,会记成「取消后才收到钱」的问题记录, * 交给悬账自动退款与人工处理,而不是无声地留下没有出口的悬账。 * *

这个终止条件按「这次尝试」而不是按它的当前状态来下:流水可能从没经过 {@code CLOSING} * (关单第一次就被拒,直接落在 {@code CANCEL_UNCONFIRMED}),只认 {@code CLOSING} 的话这类订单 * 永远结束不了。 */ private void giveUpClosing(Map transaction) { String paymentNo = text(transaction.get("paymentNo")); String orderNo = text(transaction.get("orderNo")); long amount = number(transaction.get("amount")); if (CLOSING_STATUS.equals(text(transaction.get("status")))) { markCancelledTransaction(paymentNo, CANCEL_UNCONFIRMED_STATUS, "CLOSE_ATTEMPTS_EXHAUSTED", "Provider still reports the payment as processing after " + MAX_CLOSE_ATTEMPTS + " close attempts"); } issues.issue("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, orderNo, "closed at provider", "close attempts exhausted", amount, "Payment could not be closed after " + MAX_CLOSE_ATTEMPTS + " attempts; the prepay stays watchable"); jdbc.update(""" update benefit_order o set order_status = 2 where o.order_no = ? and o.deleted = 0 and o.order_status = 0 and not exists ( select 1 from payment_transaction p where p.order_id = o.id and p.status = 'SUCCEEDED' ) """, orderNo); } /** 关单请求已发出:计数要落库,否则重试上限永远到不了,一条坏数据会被无限重试。 */ private void recordCloseAttempt(String paymentNo) { jdbc.update("update payment_transaction set close_attempts = close_attempts + 1 where payment_no = ?", paymentNo); } private void markClosing(Map transaction, String code, String message) { jdbc.update(""" update payment_transaction set status = 'CLOSING', provider_response_code = ?, provider_response_message = ? where payment_no = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CREATE_FAILED', 'CANCEL_UNCONFIRMED', 'CLOSING') """, code, message, text(transaction.get("paymentNo"))); } private PendingPaymentConfirmation keepWatchableAfterRefusedClose(Map 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', 'CLOSING') """, status, code, message, paymentNo); } /** 通道对关单请求的答复。只要还没说「关掉了/原交易已成功/原交易已终结」,就当作没确认下来。 */ private record CloseResult(String code, String message, String status, String originalStatus) { /** 汇付「原订单已为终态」的两个返回码:原交易已终结(已关单或已完成),无法再关单。 */ private static final Set TERMINAL_CODES = Set.of("23000000", "10000016"); private static CloseResult from(Map response) { Map data = response == null ? Map.of() : (response.get("data") instanceof Map map ? (Map) 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 originalTradeAlreadyTerminal() { return TERMINAL_CODES.contains(code); } boolean processing() { return "P".equalsIgnoreCase(status) || "00000100".equals(code); } } /** * 取这一轮要处理的未定局流水。 * *

按 {@code last_reconciled_at} 轮转(空值优先、其次最久没被处理的),取代旧实现的「最早 100 条」: * 旧写法下未决流水一旦积压超过上限,最新出现的那笔——也就是用户刚卡住的那笔——永远轮不到,而死锁 * 唯一的自愈通道就是对账。配上每行的退避时间,已经处理过的行不会立刻又被选中。 * *

{@code CLOSING} 与 {@code CANCEL_UNCONFIRMED} 走快车道({@value #CLOSE_RETRY_MINUTES} 分钟一轮, * 与关单接口「一分钟以内不允许关单」的限制同源);其它未定局流水沿用调用方给的等待时长。 */ private int reconcilePayments(int pendingAfterMinutes) { int pendingMinutes = Math.max(1, pendingAfterMinutes); List> rows = DatabaseRows.normalize(jdbc.queryForList(""" select p.payment_no, p.order_no, p.amount, p.status, p.provider_transaction_no, p.created_at, p.close_attempts, 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', 'CLOSING', 'CANCEL_UNCONFIRMED') and ( (p.status in ('CLOSING', 'CANCEL_UNCONFIRMED') and p.created_at <= date_sub(now(), interval ? minute) and (p.last_reconciled_at is null or p.last_reconciled_at <= date_sub(now(), interval ? minute))) or (p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN') and p.created_at <= date_sub(now(), interval ? minute) and (p.last_reconciled_at is null or p.last_reconciled_at <= date_sub(now(), interval ? minute))) ) order by p.last_reconciled_at is null desc, p.last_reconciled_at asc, p.id asc limit 100 """, PROVIDER, CLOSE_RETRY_MINUTES, CLOSE_RETRY_MINUTES, pendingMinutes, pendingMinutes)); for (Map row : rows) { try { String status = text(row.get("status")); // 关不掉/关单中的尝试走关单补偿(它们需要的是「关掉」而不是「查状态」), // 其余未定局流水只查一次,查不到结论就继续等。 if (CLOSING_STATUS.equals(status) || CANCEL_UNCONFIRMED_STATUS.equals(status)) retryClose(row); else reconcilePayment(row, pendingAfterMinutes); } finally { // 处理完就盖上轮转时间戳:否则最久没被处理的一行会一直排在最前面,把后面的行饿死。 markReconciled(text(row.get("paymentNo"))); } } return rows.size(); } private void markReconciled(String paymentNo) { jdbc.update("update payment_transaction set last_reconciled_at = now() where payment_no = ?", paymentNo); } /** * 关单补偿:取消订单时通道不让关(未满一分钟、通道临时拒绝、关单请求失败)而留下的 * {@code CANCEL_UNCONFIRMED},以及通道答「处理中」而停在 {@code CLOSING} 的尝试,等过了限制再关一次 * ——不关掉,用户手上那张预支付单一直能付进来。 * *

先确认、再关单:通道说这笔请求没收过钱就关掉,落成已关闭;通道说钱已经收下了则绝不再尝试关单, * 交给结算与问题记录把它变成一笔看得见、可退的账。两种情况都说明这一轮有了结论,把上一轮留下的 * 关单问题记录结掉。 * *

到重试上限之后不再发关单请求,只查一次:关单已经问不出更多结论,再打通道只是重复。 */ private void retryClose(Map transaction) { String paymentNo = text(transaction.get("paymentNo")); if (number(transaction.get("closeAttempts")) >= MAX_CLOSE_ATTEMPTS) { // 到上限:关单已经问不出更多结论,再打通道只是重复。先查一次(钱真的来了要先落成已支付, // 那一支会把订单翻成已支付,下面的收尾就不会再取消它),再给这次尝试一个终止条件。 watchOnly(transaction); giveUpClosing(transaction); return; } issues.resolve("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, "system"); confirmPayment(transaction, true); } /** 只向通道确认、不再关单:给已经放弃关单的尝试留一条发现「钱其实收下了」的路。 */ private void watchOnly(Map transaction) { PendingPaymentConfirmation confirmation = confirmPayment(transaction, false); if (confirmation.collected()) issues.resolve("PAYMENT_PENDING_TIMEOUT", text(transaction.get("paymentNo")), "system"); } private void reconcilePayment(Map 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 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> 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 row : rows) { String refundNo = text(row.get("refundNo")); try { Map 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 response) { Map data = response.get("data") instanceof Map map ? (Map) 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"))); } /** * 通道是否明确排除了「已经收到钱」。 * *

失败的交易、只登记未开始的交易({@code I} 初始)和通道根本不认识的请求流水号,都不可能 * 已经把钱收下;其余回执(处理中、读不出来的状态)都可能,因此不能算作已排除。 */ boolean notCollected() { return failed || "I".equalsIgnoreCase(status) || TRADE_MISSING.equals(code); } } }