yeg
2026-09-17 e3f412c5474311a471345ea508db4875c9ec648e
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";
        };
    }