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