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/HuifuPaymentService.java | 87 ++++++++++++++++++++++++++++++++++++++++---
1 files changed, 81 insertions(+), 6 deletions(-)
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java
index 8f2225b..3be239c 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java
@@ -46,13 +46,23 @@
}
public Map<String, Object> beginMiniProgramPayment(String orderNo, Long talentId, int priceVersion, String idempotencyKey) {
- // 上一次支付可能已经被通道收下、回调却没到。那笔钱必须先落成本地已支付:否则这里会再发起一次
- // 支付(同一笔订单付第二次),或者把用户送进「订单已经支付,请勿重复发起支付」的死胡同。
+ // 复用优先:本地已经持有这张订单的预支付单的支付参数、订单又还待支付时,直接把它交回客户端。
+ // 这里刻意**不查通道**:查通道只会得到「处理中」这种没有结论的答复,而本地已有可用凭证——
+ // 拿这个没有结论的答复把整笔订单停摆,正是用户报的死锁(见 docs/adr/0003 的 D3)。
+ Map<String, Object> reusable = reusablePrepay(orderNo, talentId);
+ if (reusable != null) {
+ return pendingResult(string(reusable.get("paymentNo")), string(reusable.get("clientPaymentPayload")));
+ }
+
+ // 本地没有可复用的凭证时,通道是唯一知道钱有没有收下的地方。上一次支付可能已经被通道收下、
+ // 回调却没到:那笔钱必须先落成本地已支付,否则这里会再发起一次支付(同一笔订单付第二次),
+ // 或者把用户送进「订单已经支付,请勿重复发起支付」的死胡同。
PendingPaymentConfirmation confirmation = reconciliationService.confirmPendingPayments(orderNo);
if (confirmation.collected()) return completedResult(confirmation.paymentNo());
- if (confirmation.state() == PendingPaymentState.UNCONFIRMED) {
- throw new BusinessException(ErrorCode.CONFLICT, "支付结果确认中,请稍后再试");
- }
+ // 通道答不出「钱收没收下」时不再抛错:那个不确定只属于这一次支付尝试,不该把用户堵在一句
+ // 看不懂的报错上。返回可等待状态,页面渲染成「支付处理中,请稍候」。
+ if (confirmation.state() == PendingPaymentState.UNCONFIRMED) return processingResult(confirmation.paymentNo());
+
PreparedPayment prepared = transactions.execute(status -> prepare(orderNo, talentId, priceVersion, idempotencyKey));
if (prepared == null) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "支付初始化失败");
if (prepared.completed()) return completedResult(prepared.paymentNo());
@@ -86,6 +96,53 @@
String clientPayload = writeJson(clientPayload(response));
transactions.executeWithoutResult(status -> markPrepayCreated(prepared.paymentNo(), responseCode(response), responseText(response, ""), clientPayload));
return pendingResult(prepared.paymentNo(), clientPayload);
+ }
+
+ /**
+ * 本地是否还持有这张订单的预支付单的支付参数。
+ *
+ * <p>四个条件缺一不可:订单仍待支付且未过期、流水属于「本地尚未定局」的状态、支付参数还在本地、
+ * 流水金额与订单当前实付一致。金额必须相等是因为预支付单是按金额建的:改了优惠券之后再拿旧单
+ * 去收款会收错钱。流水上没有记 {@code price_version},金额相等是这里能拿到的最强判据。
+ *
+ * <p>{@code CLOSING}(关单中)与 {@code CANCEL_UNCONFIRMED}(关不掉的预支付单)同样在可复用之列:
+ * 它们正是「通道侧这张单还可能被支付」的状态,此时新建一张预支付单等于同一笔订单可能被付两次
+ * (D2),所以只能复用。
+ */
+ private Map<String, Object> reusablePrepay(String orderNo, Long talentId) {
+ List<Map<String, Object>> rows = rows("""
+ select p.payment_no, p.client_payment_payload
+ from payment_transaction p
+ join benefit_order o on o.id = p.order_id
+ where p.provider = ? and p.order_no = ? and o.talent_id = ?
+ and o.deleted = 0 and o.order_status = 0
+ and (o.expires_at is null or o.expires_at > now())
+ and p.status in ('PREPAY_CREATED', 'CLOSING', 'CANCEL_UNCONFIRMED')
+ and coalesce(p.client_payment_payload, '') <> ''
+ and p.amount = o.pay_amount
+ order by p.id desc
+ limit 1
+ """, PROVIDER, orderNo, talentId);
+ return rows.isEmpty() ? null : rows.get(0);
+ }
+
+ /**
+ * 这张订单上是否还有一张**可能被支付**的预支付单。
+ *
+ * <p>新建预支付单的前置不变量:上一张必须已经被证明不可能再被支付(已关闭,或通道明确说失败/
+ * 交易不存在)。{@code CLOSING} 与 {@code CANCEL_UNCONFIRMED} 恰恰是「关不掉的预支付单」——通道
+ * 既没说关掉、也没说钱收下了,它照样能被付进来。此时新建等于给同一笔订单留下两张可被支付的
+ * 预支付单,通道确认两笔都成功时没有多收款退回流程(审计 §9 已复现过两次预授权)。
+ */
+ private String blockingAttempt(long orderId) {
+ List<Map<String, Object>> rows = rows("""
+ select payment_no from payment_transaction
+ where order_id = ? and provider = ?
+ and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED')
+ order by id desc
+ limit 1
+ """, orderId, PROVIDER);
+ return rows.isEmpty() ? "" : string(rows.get(0).get("paymentNo"));
}
private PreparedPayment prepare(String orderNo, Long talentId, int priceVersion, String idempotencyKey) {
@@ -141,10 +198,23 @@
String paymentNo = string(existing.get("paymentNo"));
if ("SUCCEEDED".equals(status)) return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, true, "", false, false);
if ("PREPAY_CREATED".equals(status)) {
+ // 支付参数没留在本地就没法把它复用给客户端,而这张预支付单可能已经可以被支付:
+ // 既不新建也不报错,交给对账通道确认后再给结论。
+ if (string(existing.get("clientPaymentPayload")).isBlank()) {
+ return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false);
+ }
return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, string(existing.get("clientPaymentPayload")), false, false);
+ }
+ if ("CLOSING".equals(status) || "CANCEL_UNCONFIRMED".equals(status)) {
+ // 关单中/关不掉的预支付单:通道还可能为它收钱,绝不能再建一张。
+ return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false);
}
if ("CREATED".equals(status) || "CREATE_UNKNOWN".equals(status)) {
return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false);
+ }
+ String blocking = blockingAttempt(number(order.get("id")));
+ if (!blocking.isBlank()) {
+ return new PreparedPayment(blocking, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false);
}
int nextAttempt = (int) number(existing.get("attemptNo")) + 1;
String retryPaymentNo = BusinessIdGenerator.next("P");
@@ -152,6 +222,10 @@
return new PreparedPayment(retryPaymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false, requiresDelayAccount(order));
}
+ String blocking = blockingAttempt(number(order.get("id")));
+ if (!blocking.isBlank()) {
+ return new PreparedPayment(blocking, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false);
+ }
String paymentNo = BusinessIdGenerator.next("P");
insertPaymentTransaction(paymentNo, order, orderNo, talentId, payAmount, huifuId, idempotencyKey, 1);
return new PreparedPayment(paymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false, requiresDelayAccount(order));
@@ -277,7 +351,8 @@
case "CLOSED", "CANCEL_UNCONFIRMED" -> "CANCELLED";
case "CREATE_FAILED" -> "PAYMENT_CREATE_FAILED";
case "CREATED", "PREPAY_CREATED" -> "PAYMENT_PENDING";
- case "CREATE_UNKNOWN" -> "PAYMENT_CONFIRMING";
+ // 关单中与创建结果未定都是「还在确认」:客户端渲染成可等待的文案,而不是一个报错。
+ case "CLOSING", "CREATE_UNKNOWN" -> "PAYMENT_PROCESSING";
default -> "UNKNOWN";
};
}
--
Gitblit v1.7.1