From e3f412c5474311a471345ea508db4875c9ec648e Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 17 九月 2026 15:47:59 +0800
Subject: [PATCH] feat(payment): 支付按支付尝试收敛,取消与放弃以关单回执为准
---
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java | 214 +++++++++++++++++++++++++++++++++++++++++++++--------
1 files changed, 181 insertions(+), 33 deletions(-)
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java
index f418157..68863bc 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java
@@ -27,12 +27,25 @@
* {@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.
+ *
+ * <p>取消与放弃以通道的**关单回执**为准,而不是把查单当成关单的前置门:关单回执同时回答「关掉了没有」
+ * 和「原交易是不是已经成功」,比查单更强。查单只会说「处理中」,旧实现因此连关单都不试,用户手上那张
+ * 预支付单一直能付进来,订单也永远既付不了又退不出(判定见 {@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;
+ /** 关单快车道间隔(分钟)。
+ *
+ * <p>通道不允许关闭一分钟以内的订单,所以它同时也是最短的重试间隔:比这更密只会拿到同样的拒绝。
+ */
+ 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;
@@ -83,38 +96,65 @@
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public PendingPaymentConfirmation confirmPendingPayments(String orderNo) {
- return decidePendingPayments(orderNo, false);
+ return decidePendingPayments(orderNo, false, null);
}
/**
* 取消订单前的通道确认:除了确认这笔订单有没有收到钱,通道说没收款的预支付单还要在通道侧关掉。
*
* <p>只把本地流水标成已关闭是不够的:用户手上那张预支付单在通道侧还活着,照样能付进来,钱收了
- * 而订单已经取消。所以取消必须以通道侧的关单结论为准——关单回执说原交易已经成功就拒绝取消,
- * 说还在处理中就让用户稍后重试,只有确认关掉、或通道明确说这笔请求从未收到钱且后续也无法关单
- * (见 {@link #closePayment})时才继续取消。
+ * 而订单已经取消。所以取消必须以通道侧的关单结论为准——关单回执说原交易已经成功就落成已支付并拒绝
+ * 取消,说还在处理中就让这次尝试停在 {@code CLOSING} 并交给后台重试,只有确认关掉、或通道明确拒绝
+ * 关单(见 {@link #keepWatchableAfterRefusedClose})时才继续取消。
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public PendingPaymentConfirmation closePendingPayments(String orderNo) {
- return decidePendingPayments(orderNo, true);
+ return decidePendingPayments(orderNo, true, null);
}
- 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));
+ /**
+ * 「放弃支付」:客户端在收银台里点了关闭,请求服务端立刻把这笔尝试关掉。
+ *
+ * <p><b>只触发关单,绝不结算。</b>客户端关于钱的陈述不可信——它说「我放弃了」只能当作一个关单信号,
+ * 收款与否一律以关单回执为准。因此这里刻意不调用 {@link #confirmPayment} 的确认分支:即使回执说原
+ * 交易已经成功,也只记一条问题记录,让后续的查单与对账把它落成真实的资金事实。
+ *
+ * @param paymentNo 客户端手上那张预支付单;为空时处理该订单所有未定局的尝试。
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public PendingPaymentConfirmation abandonPendingPayments(String orderNo, String paymentNo) {
PendingPaymentConfirmation confirmation = new PendingPaymentConfirmation(PendingPaymentState.NONE, "");
- for (Map<String, Object> row : rows) {
+ for (Map<String, Object> 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<String, Object> 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<Map<String, Object>> 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));
}
/**
@@ -124,6 +164,10 @@
* 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.
+ *
+ * <p>通道**答了话**(查单说处理中,或说没收过钱)时一律再用关单回执下一次结论:关单回执比查单
+ * 强,它同时说明「关掉没有」和「原交易是不是已经成功」。只有查单根本没答上来(通道不可达)时才
+ * 停在未定,因为那种情况下关单也一样问不出东西。
*/
private PendingPaymentConfirmation confirmPayment(Map<String, Object> transaction, boolean closeWhenUncollected) {
String paymentNo = text(transaction.get("paymentNo"));
@@ -161,34 +205,43 @@
// 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);
+ if (closeWhenUncollected) return closePayment(transaction, true);
+ return new PendingPaymentConfirmation(
+ result.notCollected() ? PendingPaymentState.NONE : PendingPaymentState.UNCONFIRMED, paymentNo);
}
/**
- * 通道说这笔请求没收过钱:把预支付单在通道侧关掉,取消才算真的取消。
+ * 用关单回执给这次支付尝试下结论。
*
* <p>关不掉的预支付单仍然可能被支付,所以「关单没成功」不能当作「可以放心取消」:
* <ul>
* <li>确认关单成功:本地记为已关闭,可以取消。</li>
- * <li>关单回执说原交易已经成功:钱可能在,只是查单没查到——拒绝取消并记一条问题记录给人工。</li>
- * <li>关单处理中:拒绝取消,让用户稍后重试(下次会重新确认)。</li>
+ * <li>关单回执说原交易已经成功:钱可能在,只是查单没查到——{@code followUpOnSucceededOriginalTrade}
+ * 为真时再查一次,查得到金额就落成已支付(这一支的结论是「已支付并拒绝取消」);仍然查不到就让
+ * 这次尝试停在 {@code CLOSING} 等后台重试,并记一条问题记录给人工。</li>
+ * <li>关单处理中:这次尝试停在新状态 {@code CLOSING}(关单中)——它既不能当已关闭(通道还可能
+ * 收钱),也不该是终点,做成可等待、可重试的中间态。</li>
* <li>其余拒绝(未满一分钟不允许关单、原交易不支持关单、关单请求本身失败):允许取消,但流水记为
* {@code CANCEL_UNCONFIRMED} 而不是已关闭——对账任务仍会盯着这笔请求,万一用户后来真的付了,
* 会留下问题记录让人处理,而不是无声地变成一笔没有订单的悬账。</li>
* </ul>
+ *
+ * @param followUpOnSucceededOriginalTrade 关单回执说原交易已成功时是否接着查通道并落成已支付。
+ * 取消那条路为真;「放弃支付」那条路为假——它只被允许触发关单,绝不产生任何收款判定。
*/
- private PendingPaymentConfirmation closePayment(Map<String, Object> transaction) {
+ private PendingPaymentConfirmation closePayment(Map<String, Object> 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<String, Object> response;
try {
+ recordCloseAttempt(paymentNo);
response = paymentGateway.closeMiniProgramPayment(new HuifuPaymentCloseCommand(
BusinessIdGenerator.next("C"), paymentNo, huifuId, text(transaction.get("providerTransactionNo")),
requestDate(transaction.get("createdAt"))));
@@ -205,14 +258,62 @@
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.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);
+ }
+
+ /**
+ * 关单重试到上限:给这次尝试一个终止条件,同时不丢掉任何盯着钱的东西。
+ *
+ * <p>到上限时通道既没说关掉、也没说钱收下了,而这张预支付单还可能被支付。此时订单不能继续挂着
+ * 「待支付」——那样它既付不了(不允许在同一订单上再建一张预支付单)也永远不会被自动取消,变成一笔
+ * 僵尸订单。所以这里把订单落成已取消,并把这次尝试留成 {@code CANCEL_UNCONFIRMED}:
+ * 订单状态不由自动取消的排除表管理,而是由这次明确的「不再重试」结论结束;
+ * 预支付单继续被对账盯着,万一用户后来真的付了,会记成「取消后才收到钱」的问题记录,
+ * 交给悬账自动退款与人工处理,而不是无声地留下没有出口的悬账。
+ */
+ private void giveUpClosing(Map<String, Object> 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 set order_status = 2
+ where order_no = ? and deleted = 0 and order_status = 0
+ """, 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<String, Object> 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<String, Object> transaction, String code,
@@ -229,7 +330,7 @@
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')
+ where payment_no = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CREATE_FAILED', 'CANCEL_UNCONFIRMED', 'CLOSING')
""", status, code, message, paymentNo);
}
@@ -256,36 +357,83 @@
}
}
+ /**
+ * 取这一轮要处理的未定局流水。
+ *
+ * <p>按 {@code last_reconciled_at} 轮转(空值优先、其次最久没被处理的),取代旧实现的「最早 100 条」:
+ * 旧写法下未决流水一旦积压超过上限,最新出现的那笔——也就是用户刚卡住的那笔——永远轮不到,而死锁
+ * 唯一的自愈通道就是对账。配上每行的退避时间,已经处理过的行不会立刻又被选中。
+ *
+ * <p>{@code CLOSING} 与 {@code CANCEL_UNCONFIRMED} 走快车道({@value #CLOSE_RETRY_MINUTES} 分钟一轮,
+ * 与关单接口「一分钟以内不允许关单」的限制同源);其它未定局流水沿用调用方给的等待时长。
+ */
private int reconcilePayments(int pendingAfterMinutes) {
+ int pendingMinutes = Math.max(1, 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,
+ 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', 'CANCEL_UNCONFIRMED')
- and p.created_at <= date_sub(now(), interval ? minute)
- order by p.id asc limit 100
- """, PROVIDER, Math.max(1, pendingAfterMinutes)));
+ 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<String, Object> row : rows) {
- if (CANCEL_UNCONFIRMED_STATUS.equals(text(row.get("status")))) retryClose(row);
- else reconcilePayment(row, pendingAfterMinutes);
+ 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 CANCEL_UNCONFIRMED},以及通道答「处理中」而停在 {@code CLOSING} 的尝试,等过了限制再关一次
+ * ——不关掉,用户手上那张预支付单一直能付进来。
*
* <p>先确认、再关单:通道说这笔请求没收过钱就关掉,落成已关闭;通道说钱已经收下了则绝不再尝试关单,
* 交给结算与问题记录把它变成一笔看得见、可退的账。两种情况都说明这一轮有了结论,把上一轮留下的
* 关单问题记录结掉。
+ *
+ * <p>到重试上限之后不再发关单请求,只查一次:关单已经问不出更多结论,再打通道只是重复。
*/
private void retryClose(Map<String, Object> transaction) {
String paymentNo = text(transaction.get("paymentNo"));
+ if (number(transaction.get("closeAttempts")) >= MAX_CLOSE_ATTEMPTS) {
+ // 到上限:先给还停在「关单中」的尝试一个终止条件(落成已取消并结束订单),之后只查不关。
+ if (CLOSING_STATUS.equals(text(transaction.get("status")))) giveUpClosing(transaction);
+ else watchOnly(transaction);
+ return;
+ }
issues.resolve("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, "system");
confirmPayment(transaction, true);
}
+ /** 只向通道确认、不再关单:给已经放弃关单的尝试留一条发现「钱其实收下了」的路。 */
+ private void watchOnly(Map<String, Object> transaction) {
+ PendingPaymentConfirmation confirmation = confirmPayment(transaction, false);
+ if (confirmation.collected()) issues.resolve("PAYMENT_PENDING_TIMEOUT", text(transaction.get("paymentNo")), "system");
+ }
private void reconcilePayment(Map<String, Object> transaction, int pendingAfterMinutes) {
String paymentNo = text(transaction.get("paymentNo"));
--
Gitblit v1.7.1