yeg
2026-09-16 8fe2249e07ebe15b33e0231cd9dafa7c303486f6
fix(payment): 取消与到期不再赌钱,先与通道确认并在通道侧关单

本地环境收不到汇付回调,所以「订单待支付」只说明本地不知道,不说明没收过钱。原来取消订单只看
本地流水:把流水直接写成 LOCAL_CLOSED、订单置为已取消,钱还在通道、订单却没了,人才端连退款
入口都没有(退款要求订单是已支付)。超时清理走的是同一套判断,只是条件换成时间,同样会把已经
收下钱的订单自动取消。真实数据:2026-09-16 14:22 的一笔 ¥0.02 订单就是这样被取消的。

改为「资金事实以通道为准」,三条入口一起收敛:

- 取消(PendingOrderPortalService.cancel):先问通道,逐笔判定
- 通道确认收款 → 按回调同一条路径落成已支付,并拒绝取消(人才端随即有退款入口);
- 通道处理中或问不到 → 拒绝取消,提示稍后重试(问不清楚绝不动订单);
- 通道确认未收款 → 在通道侧关掉预支付单(新接 V2TradePaymentScanpayCloseRequest),
关单成功才把本地流水记为 CLOSED。
- 关单的三种余下结论各有归宿:关单处理中拒绝取消;关单回执说原交易已成功则拒绝取消并记
PAYMENT_CLOSE_CONFLICT(查单拿不到金额,不能谎称已支付);关单被拒(未满一分钟不允许关单、
不支持关单)或关单请求失败则允许取消,但流水记为 CANCEL_UNCONFIRMED——不记成已关闭,对账
任务会继续重试关单,万一用户后来真付了会留下 CLOSED_LATE_SUCCESS 问题记录,而不是无声消失。
- 重申支付与查支付状态(HuifuPaymentService):先确认,已收款直接返回 PAID,不再把用户送进
「订单已经支付,请勿重复发起支付」的死胡同,也避免同一笔订单再发起一次支付。

两个状态机缺口一并补上(新状态 CANCEL_UNCONFIRMED 必须可结算、可关闭):结算时它要能落成
SUCCEEDED 才会进入「取消后才收到钱」的问题记录;关单成功后也要能从它写回 CLOSED。

到期清理(expireDueOrders / expireOrder)跳过带未定局支付尝试(CREATED / PREPAY_CREATED /
CREATE_UNKNOWN)的订单,交给对账任务先问清楚:通道说收款就落成已支付,说失败就把流水置为
CREATE_FAILED,下一轮清理自然收掉。代价是这类订单晚一个对账周期(分钟级)才释放优惠券。

打开对账补单任务(application.yml,此前默认关闭且无人配置):没有它,回调一丢就只剩「用户点了
取消或重申支付」这一个补救入口;测试 profile 里显式关掉,避免后台任务在共享测试库上改动别的
用例正在断言的流水。

人才端待支付页:订单已支付就跳支付成功页(不再给出「确认支付/取消订单」两个按钮),取消失败后
重新查一次支付状态而不是把用户留在原地反复点。

测试:新增 PendingPaymentConfirmationTests 14 项(分轮先红后绿;「到期不自动取消」那项退掉守卫
验证过会红——旧逻辑下订单确实被取消成 order_status=2)。MerchantVerificationServiceTests 里
「建了预支付单仍可取消」的用例按新契约补上关单回执;PaymentSettlementTests 的用例级隔离补上
CANCEL_UNCONFIRMED(新状态必须跟着进清理集合,否则用例之间会互相污染)。
全量 273 项,仅 docs/agents/testing.md 所列 3 项基线失败,无新增。
2个文件已添加
10个文件已修改
782 ■■■■■ 已修改文件
rencaika/pages/pending-payment/pending-payment.uvue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java 241 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentSettlementService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMiniProgramPaymentGateway.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentCloseCommand.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/application.yml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationServiceTests.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSettlementTests.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java 366 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/resources/application-test.yml 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/pending-payment/pending-payment.uvue
@@ -140,6 +140,11 @@
                uni.redirectTo({ url: '/pages/order-cancelled/order-cancelled?orderId=' + encodeURIComponent(orderId.value) + '&talentId=' + encodeURIComponent(talentId.value) })
                return
            }
            // 支付成功但回调没到达时,本地订单会停在待支付。已支付的订单不该再给出「确认支付」和「取消订单」。
            if (data != null && Number(data.orderStatus) == 1) {
                openPaymentSuccess()
                return
            }
            merchantName.value = data.merchantNameSnapshot != null ? data.merchantNameSnapshot as string : '人才权益服务'
            merchantAddress.value = data.merchantAddressSnapshot != null ? data.merchantAddressSnapshot as string : ''
            merchantMobile.value = data.merchantMobileSnapshot != null ? data.merchantMobileSnapshot as string : ''
@@ -165,6 +170,8 @@
            } else {
                stopCountdown()
            }
            // 回调没到达时「待支付」只说明本地不知道。打开页面时问一次通道,钱已经收下就进支付成功页。
            if (data.canPay === true) checkPaymentStatus(false)
        }, (error: any) => { loading.value = false; loadFailed.value = true; loadErrorText.value = error.message })
    }
    const startCountdown = () => {
@@ -259,6 +266,9 @@
                }, (error: any) => {
                    isSubmitting.value = false
                    uni.showToast({ title: error.message, icon: 'none' })
                    // 取消失败最常见的原因就是这笔订单其实已经支付成功(回调没到达)。再问一次支付状态,
                    // 是已支付就跳到支付成功页,而不是把用户留在「待支付」页反复点取消。
                    checkPaymentStatus(false)
                })
            }
        })
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java
@@ -9,6 +9,8 @@
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.payment.PaymentReconciliationService.PendingPaymentConfirmation;
import com.talentcard.server.business.payment.PaymentReconciliationService.PendingPaymentState;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentCommand;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
@@ -29,18 +31,28 @@
    private final JdbcTemplate jdbc;
    private final TransactionTemplate transactions;
    private final HuifuMiniProgramPaymentGateway gateway;
    private final PaymentReconciliationService reconciliationService;
    public HuifuPaymentService(
            JdbcTemplate jdbc,
            TransactionTemplate transactions,
            HuifuMiniProgramPaymentGateway gateway
            HuifuMiniProgramPaymentGateway gateway,
            PaymentReconciliationService reconciliationService
    ) {
        this.jdbc = jdbc;
        this.transactions = transactions;
        this.gateway = gateway;
        this.reconciliationService = reconciliationService;
    }
    public Map<String, Object> beginMiniProgramPayment(String orderNo, Long talentId, int priceVersion, String idempotencyKey) {
        // 上一次支付可能已经被通道收下、回调却没到。那笔钱必须先落成本地已支付:否则这里会再发起一次
        // 支付(同一笔订单付第二次),或者把用户送进「订单已经支付,请勿重复发起支付」的死胡同。
        PendingPaymentConfirmation confirmation = reconciliationService.confirmPendingPayments(orderNo);
        if (confirmation.collected()) return completedResult(confirmation.paymentNo());
        if (confirmation.state() == PendingPaymentState.UNCONFIRMED) {
            throw new BusinessException(ErrorCode.CONFLICT, "支付结果确认中,请稍后再试");
        }
        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());
@@ -186,6 +198,10 @@
        Map<String, Object> order = orders.get(0);
        if (number(order.get("orderStatus")) == 1) return completedResult(paymentNo == null ? "" : paymentNo);
        // 回调没到、本地还停在待支付时,通道是唯一知道钱有没有收下的地方。问一次就能把状态收敛回来,
        // 人才端也就不会一直停在待支付页。
        PendingPaymentConfirmation confirmation = reconciliationService.confirmPendingPayments(orderNo);
        if (confirmation.collected()) return completedResult(confirmation.paymentNo());
        String filter = paymentNo == null || paymentNo.isBlank() ? "" : " and payment_no = ?";
        Object[] args = paymentNo == null || paymentNo.isBlank()
@@ -258,7 +274,7 @@
    private String statusText(String status) {
        return switch (status) {
            case "SUCCEEDED" -> "PAID";
            case "CLOSED" -> "CANCELLED";
            case "CLOSED", "CANCEL_UNCONFIRMED" -> "CANCELLED";
            case "CREATE_FAILED" -> "PAYMENT_CREATE_FAILED";
            case "CREATED", "PREPAY_CREATED" -> "PAYMENT_PENDING";
            case "CREATE_UNKNOWN" -> "PAYMENT_CONFIRMING";
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationService.java
@@ -1,15 +1,19 @@
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;
@@ -27,6 +31,8 @@
@Service
public class PaymentReconciliationService {
    private static final String PROVIDER = "HUIFU";
    /** 订单已取消、但通道侧的预支付单还没确认关掉的流水:对账任务要一直重试关单,直到有结论。 */
    private static final String CANCEL_UNCONFIRMED_STATUS = "CANCEL_UNCONFIRMED";
    private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
    private final JdbcTemplate jdbc;
    private final HuifuMiniProgramPaymentGateway paymentGateway;
@@ -49,18 +55,236 @@
        return Map.of("paymentChecked", paymentChecked, "refundChecked", refundChecked);
    }
    /** 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.
     *
     * <p>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.
     *
     * <p>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);
    }
    /**
     * 取消订单前的通道确认:除了确认这笔订单有没有收到钱,通道说没收款的预支付单还要在通道侧关掉。
     *
     * <p>只把本地流水标成已关闭是不够的:用户手上那张预支付单在通道侧还活着,照样能付进来,钱收了
     * 而订单已经取消。所以取消必须以通道侧的关单结论为准——关单回执说原交易已经成功就拒绝取消,
     * 说还在处理中就让用户稍后重试,只有确认关掉、或通道明确说这笔请求从未收到钱且后续也无法关单
     * (见 {@link #closePayment})时才继续取消。
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public PendingPaymentConfirmation closePendingPayments(String orderNo) {
        return decidePendingPayments(orderNo, true);
    }
    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));
        PendingPaymentConfirmation confirmation = new PendingPaymentConfirmation(PendingPaymentState.NONE, "");
        for (Map<String, Object> row : rows) {
            PendingPaymentConfirmation current = confirmPayment(row, closeWhenUncollected);
            if (current.collected()) return current;
            if (current.state() == PendingPaymentState.UNCONFIRMED) confirmation = current;
        }
        return confirmation;
    }
    /**
     * One payment attempt, judged by what the provider says about it.
     *
     * <p>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<String, Object> 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 (result.notCollected()) {
            return closeWhenUncollected
                    ? closePayment(transaction)
                    : new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo);
        }
        return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
    }
    /**
     * 通道说这笔请求没收过钱:把预支付单在通道侧关掉,取消才算真的取消。
     *
     * <p>关不掉的预支付单仍然可能被支付,所以「关单没成功」不能当作「可以放心取消」:
     * <ul>
     *   <li>确认关单成功:本地记为已关闭,可以取消。</li>
     *   <li>关单回执说原交易已经成功:钱可能在,只是查单没查到——拒绝取消并记一条问题记录给人工。</li>
     *   <li>关单处理中:拒绝取消,让用户稍后重试(下次会重新确认)。</li>
     *   <li>其余拒绝(未满一分钟不允许关单、原交易不支持关单、关单请求本身失败):允许取消,但流水记为
     *       {@code CANCEL_UNCONFIRMED} 而不是已关闭——对账任务仍会盯着这笔请求,万一用户后来真的付了,
     *       会留下问题记录让人处理,而不是无声地变成一笔没有订单的悬账。</li>
     * </ul>
     */
    private PendingPaymentConfirmation closePayment(Map<String, Object> transaction) {
        String paymentNo = text(transaction.get("paymentNo"));
        String orderNo = text(transaction.get("orderNo"));
        long amount = number(transaction.get("amount"));
        String huifuId = text(transaction.get("huifuId"));
        Map<String, Object> response;
        try {
            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");
            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");
            return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
        }
        return keepWatchableAfterRefusedClose(transaction, close.code(), close.message(), amount);
    }
    private PendingPaymentConfirmation keepWatchableAfterRefusedClose(Map<String, Object> 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')
                """, status, code, message, paymentNo);
    }
    /** 通道对关单请求的答复。只有「关掉了」和「原交易已成功」是明确结论,其余一律当作没确认下来。 */
    private record CloseResult(String code, String message, String status, String originalStatus) {
        private static CloseResult from(Map<String, Object> response) {
            Map<String, Object> data = response == null ? Map.of()
                    : (response.get("data") instanceof Map<?, ?> map ? (Map<String, Object>) 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 processing() {
            return "P".equalsIgnoreCase(status) || "00000100".equals(code);
        }
    }
    private int reconcilePayments(int 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,
                       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')
                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)));
        for (Map<String, Object> row : rows) reconcilePayment(row, pendingAfterMinutes);
        for (Map<String, Object> row : rows) {
            if (CANCEL_UNCONFIRMED_STATUS.equals(text(row.get("status")))) retryClose(row);
            else reconcilePayment(row, pendingAfterMinutes);
        }
        return rows.size();
    }
    /**
     * 关单补偿:取消订单时通道不让关(未满一分钟、通道临时拒绝、关单请求失败)而留下的
     * {@code CANCEL_UNCONFIRMED},等过了限制再关一次——不关掉,用户手上那张预支付单一直能付进来。
     *
     * <p>先确认、再关单:通道说这笔请求没收过钱就关掉,落成已关闭;通道说钱已经收下了则绝不再尝试关单,
     * 交给结算与问题记录把它变成一笔看得见、可退的账。两种情况都说明这一轮有了结论,把上一轮留下的
     * 关单问题记录结掉。
     */
    private void retryClose(Map<String, Object> transaction) {
        String paymentNo = text(transaction.get("paymentNo"));
        issues.resolve("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, "system");
        confirmPayment(transaction, true);
    }
    private void reconcilePayment(Map<String, Object> transaction, int pendingAfterMinutes) {
@@ -140,6 +364,9 @@
    @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<String, Object> response) {
            Map<String, Object> data = response.get("data") instanceof Map<?, ?> map ? (Map<String, Object>) map : response;
            String status = HuifuPayload.value(data, "trans_stat", "transStat");
@@ -156,5 +383,15 @@
            return new ProviderResult(success, !success && !pending, status, code, message, providerNo, huifuId,
                    HuifuPayload.amountInCent(HuifuPayload.value(data, "trans_amt", "transAmt")));
        }
        /**
         * 通道是否明确排除了「已经收到钱」。
         *
         * <p>失败的交易、只登记未开始的交易({@code I} 初始)和通道根本不认识的请求流水号,都不可能
         * 已经把钱收下;其余回执(处理中、读不出来的状态)都可能,因此不能算作已排除。
         */
        boolean notCollected() {
            return failed || "I".equalsIgnoreCase(status) || TRADE_MISSING.equals(code);
        }
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentSettlementService.java
@@ -96,11 +96,13 @@
        }
        if (!alreadySucceeded) {
            // CANCEL_UNCONFIRMED 也在这里:订单取消后用户又把预支付单付了,钱必须落成已支付的事实,
            // 再让下面的订单状态判断把它记成「取消后才收到钱」的问题记录。
            int paymentUpdated = jdbc.update("""
                    update payment_transaction
                    set status = 'SUCCEEDED', provider_transaction_no = coalesce(nullif(?, ''), provider_transaction_no),
                        provider_response_code = ?, provider_response_message = ?, paid_at = coalesce(paid_at, now())
                    where id = ? and status in ('CREATED', 'CREATE_FAILED', 'PREPAY_CREATED', 'CREATE_UNKNOWN')
                    where id = ? and status in ('CREATED', 'CREATE_FAILED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED')
                    """, fact.providerTransactionNo(), fact.responseCode(), fact.responseMessage(), paymentId);
            if (paymentUpdated == 0) return SettlementOutcome.NOT_SETTLEABLE;
        }
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMiniProgramPaymentGateway.java
@@ -11,6 +11,7 @@
import com.huifu.bspay.sdk.opps.client.BasePayClient;
import com.huifu.bspay.sdk.opps.core.BasePay;
import com.huifu.bspay.sdk.opps.core.config.MerConfig;
import com.huifu.bspay.sdk.opps.core.request.V2TradePaymentScanpayCloseRequest;
import com.huifu.bspay.sdk.opps.core.request.V3TradePaymentJspayRequest;
import com.huifu.bspay.sdk.opps.core.request.V3TradePaymentScanpayQueryRequest;
import com.talentcard.server.common.BusinessException;
@@ -101,6 +102,37 @@
        }
    }
    /**
     * 关掉一笔还没支付的预支付单。
     *
     * <p>取消订单必须落到通道侧:只在本地把流水标成已关闭,用户仍然可以拿手上那张预支付单付款,
     * 钱会收进来而订单已经取消。关单只对未支付的原交易有效,通道对一分钟以内的订单、已终态的原交易
     * 都会拒绝——拒绝原因由调用方结合 {@code org_trans_stat} 判断。
     */
    public Map<String, Object> closeMiniProgramPayment(HuifuPaymentCloseCommand command) {
        ensureConfigured();
        ensureCollectingMerchant(command.huifuId());
        initializeSdk();
        V2TradePaymentScanpayCloseRequest request = new V2TradePaymentScanpayCloseRequest();
        request.setReqDate(REQUEST_DATE.format(LocalDateTime.now()));
        request.setReqSeqId(command.requestNo());
        request.setHuifuId(command.huifuId().trim());
        request.setOrgReqDate(command.requestDate());
        Map<String, Object> extendInfo = new LinkedHashMap<>();
        extendInfo.put("org_req_seq_id", command.paymentNo());
        if (!blank(command.providerTransactionNo())) extendInfo.put("org_hf_seq_id", command.providerTransactionNo());
        request.setExtendInfo(extendInfo);
        try {
            Map<String, Object> response = BasePayClient.request(request, false);
            log.info("Huifu mini-program payment close response: paymentNo={}, response={}", command.paymentNo(), logJson(response));
            return response;
        } catch (Exception exception) {
            log.warn("Huifu mini-program payment close failed: paymentNo={}, type={}, message={}",
                    command.paymentNo(), exception.getClass().getSimpleName(), safeMessage(exception.getMessage()));
            throw new HuifuPaymentGatewayException("汇付关单失败,请稍后重试", isOutcomeUnknown(exception), exception);
        }
    }
    private void ensureConfigured() {
        if (!properties.isEnabled()) throw new BusinessException(ErrorCode.CONFLICT, "汇付支付尚未启用");
        if (blank(properties.getSystemId()) || blank(properties.getProductId()) || blank(properties.getMerchantId())
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentCloseCommand.java
New file
@@ -0,0 +1,9 @@
package com.talentcard.server.business.payment.huifu;
/**
 * 关闭一笔在通道侧还没支付的预支付单。
 *
 * <p>{@code requestNo} 是这次关单请求自己的流水号;原交易用 {@code paymentNo}(下单时的请求流水号)
 * 与下单日期定位,有通道全局流水号时一并带上。
 */
public record HuifuPaymentCloseCommand(String requestNo, String paymentNo, String huifuId, String providerTransactionNo, String requestDate) {}
talent-card-server/src/main/java/com/talentcard/server/business/portal/PendingOrderPortalService.java
@@ -8,6 +8,9 @@
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.MoneyUtils;
import com.talentcard.server.business.payment.PaymentReconciliationService;
import com.talentcard.server.business.payment.PaymentReconciliationService.PendingPaymentConfirmation;
import com.talentcard.server.business.payment.PaymentReconciliationService.PendingPaymentState;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -17,8 +20,12 @@
@Service
public class PendingOrderPortalService {
    private final JdbcTemplate jdbcTemplate;
    private final PaymentReconciliationService paymentReconciliationService;
    public PendingOrderPortalService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; }
    public PendingOrderPortalService(JdbcTemplate jdbcTemplate, PaymentReconciliationService paymentReconciliationService) {
        this.jdbcTemplate = jdbcTemplate;
        this.paymentReconciliationService = paymentReconciliationService;
    }
    @Transactional
    public Map<String, Object> detail(String orderNo, Long talentId) {
@@ -38,17 +45,26 @@
     * cleanup this is used on the purchase path, so an expired order can never
     * continue to occupy that user's coupon while the scheduler is waiting for
     * its next run.
     *
     * <p>订单带着未定局的支付尝试时一律不自动取消:那种「待支付」只说明本地不知道钱有没有收下,
     * 到期时间不等于没收到钱。这些订单交给对账任务先向通道问清(已收款就落成已支付,通道说失败就
     * 把流水置为已失败),下一轮清理自然会收掉它们。到期就自动取消会把已经收下的钱变成一笔没有
     * 订单、也无法退款的悬账。
     */
    @Transactional
    public int expireDueOrders(Long talentId) {
        String sql = """
            update benefit_order
            update benefit_order o
            set order_status = 2
            where deleted = 0 and order_status = 0
              and expires_at is not null and expires_at <= current_timestamp
            where o.deleted = 0 and o.order_status = 0
              and o.expires_at is not null and o.expires_at <= current_timestamp
              and not exists (
                select 1 from payment_transaction p
                where p.order_id = o.id and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN')
              )
            """;
        if (talentId == null) return jdbcTemplate.update(sql);
        return jdbcTemplate.update(sql + " and talent_id = ?", talentId);
        return jdbcTemplate.update(sql + " and o.talent_id = ?", talentId);
    }
    @Transactional
@@ -213,16 +229,29 @@
    @Transactional
    public Map<String, Object> cancel(String orderNo, Long talentId) {
        expireOrder(orderNo, talentId);
        Map<String, Object> order = find(orderNo, talentId);
        if (longNumber(order.get("orderStatus")) == 2) return view(order);
        if (longNumber(order.get("orderStatus")) != 0) throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可取消");
        // Preparing a payment does not mean it has succeeded.  Close any active
        // transaction first, so a late callback cannot revive the cancelled order.
        // 回调没到达时「待支付」只说明本地不知道,钱可能已经被通道收下。取消是不可逆的,所以先问通道:
        // 已经收下的钱必须落成已支付(人才端才有退款入口),问不清楚就先不取消;通道说没收款的预支付单
        // 还要在通道侧关掉,否则用户手上的预支付单仍然能付进来。这一问必须排在本地写状态之前——
        // 它在自己的事务里,本方法一旦先加锁,它就会等自己的锁。
        PendingPaymentConfirmation confirmation = paymentReconciliationService.closePendingPayments(orderNo);
        if (confirmation.collected()) throw new BusinessException(ErrorCode.CONFLICT, "订单已支付,不能取消");
        if (confirmation.state() == PendingPaymentState.UNCONFIRMED) {
            throw new BusinessException(ErrorCode.CONFLICT, "支付结果确认中,暂时不能取消,请稍后再试");
        }
        // 过了支付时限的订单在这里结束:条件写在 SQL 里,跨实例只有一个赢家。
        expireOrder(orderNo, talentId);
        order = find(orderNo, talentId);
        if (longNumber(order.get("orderStatus")) == 2) return view(order);
        // 走到这里,通道侧每笔尝试都已经有了结论(关掉、或明确没收过钱)。这里只剩下取消与确认之间
        // 新起的支付尝试:它们没经过通道确认,所以不能记成已关闭,而是留成待对账状态——预支付单可能还
        // 能被支付,对账任务会继续盯着并留下问题记录。
        jdbcTemplate.update("""
            update payment_transaction
            set status = 'CLOSED', closed_at = coalesce(closed_at, now()),
                provider_response_code = 'LOCAL_CLOSED', provider_response_message = 'Cancelled by talent user'
            set status = 'CANCEL_UNCONFIRMED', closed_at = coalesce(closed_at, now()),
                provider_response_code = 'LOCAL_CANCELLED', provider_response_message = 'Cancelled by talent user'
            where order_no = ? and talent_id = ?
              and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CREATE_FAILED')
            """, orderNo, talentId);
@@ -241,12 +270,17 @@
        return view(find(orderNo, talentId));
    }
    /** 单个订单的到期结束:条件写在 SQL 里,跨实例只有一个赢家。未定局的支付尝试照上面同样的理由排除。 */
    private void expireOrder(String orderNo, Long talentId) {
        jdbcTemplate.update("""
            update benefit_order
            update benefit_order o
            set order_status = 2
            where order_no = ? and talent_id = ? and deleted = 0 and order_status = 0
              and expires_at is not null and expires_at <= current_timestamp
            where o.order_no = ? and o.talent_id = ? and o.deleted = 0 and o.order_status = 0
              and o.expires_at is not null and o.expires_at <= current_timestamp
              and not exists (
                select 1 from payment_transaction p
                where p.order_id = o.id and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN')
              )
            """, orderNo, talentId);
    }
    private long longNumber(Object value) { return value instanceof Number number ? number.longValue() : 0; }
talent-card-server/src/main/resources/application.yml
@@ -81,6 +81,12 @@
      private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCSEpJD4Z62cb+g82jGY+d87noMrXkoHlw4CCCTtna1whTed5haEqGH3B6czSC7E2c9d/QdMP4Vt67uhS9OK0IgFdLG+r1uH6K4snV/CW4rMN7f9EZOJyzjfZ8gJeuwpGZHozfUeEoBwE6XTq9/2Us9moDQfRpEX3Q4MYF5dl/r35b8m0wdGndDq4aBugJf0+VXZ9bev8v6tQBm/VkGePpX9EeynQB7YxH7Bl3JW9LsHD+GJalO+Vmzo32o72YquryR9qmNmoC4mc9yFO4FSg6k6Bc2+CDBjwk4u//Y2jtccXHNNjJ/2JUA3EBNF4JpHl2lGt47DGXqgByASgHXF11NAgMBAAECggEAYpminauYhLK3lZ94pCIfyhb3z8LxO4Fy4meqBctuyjXd/GViM7rLyndtOo7Ef0MTt0xXPbUqbeJ1dI/2ZJ+SnTHd+26Zm9JC9sWPxlbJROJSL34mTu7mwBC17u9p7uyqFcU5FHS2EASNiFkW4gf9X/IA7brkli6QtrXEHgcnrIFVihAkLdI/MCrvD9RUa5QYHG0DqXT8RGwfVz4D/VHpjkqN3Tt8lEks56GRaZmNjWSZJmaeboKZC7jLDwUx07WSab7ZWAYU72X8NlY3t34NCBVPRAyrVW/UOIRGCHW/OibrMn4O7qOvHFgsNUWtd3ewSZkpZEt1oLc+cuXkt95bSQKBgQDKGHIxo3cllsCtrLedM5cAR75yy0MT+miL4TUvljTZ25JoUX+G1nmxeDrFhS0/pUJojjn+rSBqLkzsBOrisJ+7tmGKMWaUwikl0KOCHVXuKvzGWsPFdr7093Va9E/AgM2zFJf9S3VgF/FK4c0sBCra6NB5TXDjV92uXKjaDbRtqwKBgQC5CL8/l6hn2S+xMvf6NWmy1vGZXt0Dl8MfTwTBRFgiAy5nNdJLCbhypG/+d6sCe8nXddgr55LHLzf/gvA4SeGP4+WQvkVG3bCn/DyZOwgK0irKRcrUpHrFWssRYYQDEfRWPJYZE4yYjFT1pGhVF2ly3zwnHagjMUJoVwPY2uE45wKBgEJgVxlQnDB/XBdu0/vQ1vajmbywX/6uzymqNULE7eTkBik3I1CRHvUYDQ2U2TC/QZl/pTKtgCj/vgoC73a3Fi+cDltKMGwJfWRLK3jv06pe+hjX9NblUoC4yhWuiocqhuUbjXQHFa0TEBBNK4sab0zpDJfaxIoW8Nt02YyM1cbzAoGACykk6AAMz17BSq3pWTU5j+j5H6giKqJPyaapgcLOkhahCOatLYuDjZ6vhqjPLtIqpY1dIaDRMKR/ezD5rlt+eIBli2p+oTyj+C31tcRiwsxUemjlSBg3s4m7b+vy24CMvVrgLtkIBPeJ9FsbU+6alKw0Y6OdTaod+E8vUzg4l4cCgYEAsukZsL2AdMqjIZyeuitO0CPnvmRTCihuWi18M1Vun2RaCFSUavlLrmDt9i9v+eI5fRCGz8YgIMEyAp8T+xHX/tLTlXcWh9JS30RT+r3yQAOLnr4KG4LBBLgK64EOjhlo38gV6th7nvoi2R7rlPRBl3qW3bbgQyaAz2cZ0iHIoXo=
      platform-public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiuRE4WR7SQqRs+3xNHpneFSU0YkftkBFRec7x1KQP9xCtOoySt/9pyjk+OyKypHnAHWGijpMRyd3AnJGzwgHS7PQ0Fj1yawbb94zYaNjsHsVg0py15vsvZexxLUttrY6s3F17hcMXHTqTutRR1zk9bsYehNkoooj09r9+5x6c5hIyWDXB7UQ+frnLBxhG8SQkeYnm1DqsRs4yB4qvDTb++h3X24bN7AaYcujL2ju3HI+eh4PrgWw9DZKtGpjNc+4imayE+of+nebkTQ4W2gtt+5gECmYEL6jcc06ZGzd8pi4MkASqER+PYt9zIFdavkHGCT8PDMRCl7AiwDkP6VVUQIDAQAB
      notify-url: ${HUIFU_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/notify}
      # 回调到不了时(通道重发失败、本地或内网环境)靠查单把已经收下钱的订单捞回来:支付挂起超过
      # pending-after-minutes 就问一次通道,通道确认收款就落成本地已支付,对不上账的记一条问题记录。
      # 关掉它就只剩「有人点了取消/重申支付」这一个补救入口,回调一丢订单会一直停在待支付。
      reconciliation:
        enabled: ${HUIFU_RECONCILIATION_ENABLED:true}
        pending-after-minutes: ${HUIFU_RECONCILIATION_PENDING_AFTER_MINUTES:5}
      merchant-onboarding-notify-url: ${HUIFU_MERCHANT_ONBOARDING_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-onboarding-notify}
      merchant-wechat-config-notify-url: ${HUIFU_MERCHANT_WECHAT_CONFIG_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-wechat-config-notify}
      merchant-business-open-notify-url: ${HUIFU_MERCHANT_BUSINESS_OPEN_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-business-open-notify}
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationServiceTests.java
@@ -2,6 +2,8 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.sql.Timestamp;
@@ -9,6 +11,7 @@
import java.time.LocalDateTime;
import java.util.Map;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
import com.talentcard.server.business.portal.FreeBenefitPortalService;
import com.talentcard.server.business.portal.PendingOrderPaymentRequest;
import com.talentcard.server.business.portal.PendingOrderPortalService;
@@ -20,6 +23,7 @@
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@SpringBootTest
@ActiveProfiles("test")
@@ -48,6 +52,9 @@
    @Autowired
    private FreeBenefitPortalService freeBenefitPortalService;
    @MockitoBean
    private HuifuMiniProgramPaymentGateway paymentGateway;
    private Long merchantId;
    private Long storeId;
@@ -175,17 +182,26 @@
    void pendingOrderCanBeCancelledAfterPrepayIsCreated() {
        String suffix = String.valueOf(System.nanoTime());
        String orderNo = "PREPAY-CANCEL-" + suffix;
        // 取消前必须先问通道,所以订单要带上收款子商户,通道也要能答复这笔交易的状态。
        String huifuId = "H" + suffix.substring(Math.max(0, suffix.length() - 8));
        jdbc.update("""
                insert into benefit_order(order_no, talent_id, card_id, merchant_id, store_id, order_type,
                                          order_amount, subsidy_amount, pay_amount, order_status, verify_status, deleted)
                values (?, ?, ?, ?, ?, 'GROUP_BUY', 10000, 0, 10000, 0, 0, 0)
                """, orderNo, talentId, cardId, merchantId, storeId);
                                          order_amount, subsidy_amount, pay_amount, order_status, verify_status, deleted,
                                          collect_huifu_id)
                values (?, ?, ?, ?, ?, 'GROUP_BUY', 10000, 0, 10000, 0, 0, 0, ?)
                """, orderNo, talentId, cardId, merchantId, storeId, huifuId);
        Long orderId = jdbc.queryForObject("select max(id) from benefit_order", Long.class);
        jdbc.update("""
                insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id,
                                                amount, status, idempotency_key)
                values (?, 'HUIFU', ?, ?, ?, ?, 10000, 'PREPAY_CREATED', ?)
                """, "PREPAY-CANCEL-PAY-" + suffix, orderId, orderNo, talentId, merchantId, "prepay-cancel-" + suffix);
                                                provider_huifu_id, amount, status, idempotency_key)
                values (?, 'HUIFU', ?, ?, ?, ?, ?, 10000, 'PREPAY_CREATED', ?)
                """, "PREPAY-CANCEL-PAY-" + suffix, orderId, orderNo, talentId, merchantId, huifuId, "prepay-cancel-" + suffix);
        // 通道回答「交易只登记过、从未开始支付」:这笔请求不可能收过钱,取消成立;关单确认成功之后,
        // 本地流水才记为已关闭。
        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(Map.of("data", Map.of(
                "trans_stat", "I", "resp_code", "00000000", "trans_amt", "100.00", "huifu_id", huifuId)));
        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(Map.of("data", Map.of(
                "trans_stat", "S", "resp_code", "00000000", "resp_desc", "交易成功", "huifu_id", huifuId)));
        pendingOrderPortalService.cancel(orderNo, talentId);
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSettlementTests.java
@@ -79,7 +79,7 @@
        suffix = String.valueOf(System.nanoTime());
        when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(true);
        // Only the transaction a test creates itself may look old enough for reconciliation.
        jdbc.update("update payment_transaction set created_at = now() where status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN')");
        jdbc.update("update payment_transaction set created_at = now() where status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED')");
        jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", "payment-openid-" + suffix, "Payment user", "13900000001");
        talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
        jdbc.update("insert into talent_card(talent_id, card_no, card_type, card_level, status, deleted) values (?, ?, 'PERSONAL', ?, 1, 0)", talentId, "TC-PAY-" + suffix, "卓越人才卡");
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java
New file
@@ -0,0 +1,366 @@
package com.talentcard.server.business.payment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Map;
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.portal.PendingOrderPortalService;
import com.talentcard.server.common.BusinessException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
/**
 * Seam: the local state of a pending order can be behind the provider, because a payment
 * notification that never arrived leaves the order looking unpaid while the money was collected.
 *
 * <p>Cancelling such an order must therefore ask the provider first. A payment the provider
 * confirms settles the order and blocks the cancellation; a provider that says the money may still
 * be moving blocks it too, because a question about money is never answered by guessing.
 */
@SpringBootTest
@ActiveProfiles("test")
class PendingPaymentConfirmationTests {
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private PendingOrderPortalService pendingOrderService;
    @Autowired
    private PaymentReconciliationService reconciliationService;
    @Autowired
    private HuifuPaymentService paymentService;
    @MockitoBean
    private HuifuMiniProgramPaymentGateway paymentGateway;
    private Long talentId;
    private Long merchantId;
    private String huifuId;
    private String suffix;
    private long orderSequence;
    @BeforeEach
    void prepareData() {
        suffix = String.valueOf(System.nanoTime());
        // 共享测试库里可能留着历史遗留的挂起流水,只有本用例自己创建的才能落进对账窗口。
        jdbc.update("update payment_transaction set created_at = now() where status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED')");
        jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
                "pending-payment-openid-" + suffix, "Pending payment user", "13900000003");
        talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
        jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) values (?, ?, ?, 1, 0)",
                "Pending payment merchant " + suffix, "08120000002", "Test address");
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        huifuId = "H" + suffix.substring(Math.max(0, suffix.length() - 8));
        orderSequence = 0;
    }
    @Test
    void cancellingALocallyPendingOrderTheProviderCollectedIsRefusedAndTheOrderIsMarkedPaid() {
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L);
        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "trans_amt", "20.00",
                "huifu_id", huifuId, "org_hf_seq_id", "HF-CANCEL-" + suffix)));
        assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("已支付");
        assertThat(jdbc.queryForMap("select order_status, verify_status from benefit_order where order_no = ?", orderNo))
                .containsEntry("order_status", 1)
                .containsEntry("verify_status", 1);
        assertThat(jdbc.queryForMap("select status, paid_at from payment_transaction where payment_no = ?", paymentNo))
                .containsEntry("status", "SUCCEEDED");
        assertThat(count("select count(*) from payment_transaction where payment_no = ? and paid_at is not null", paymentNo)).isEqualTo(1);
    }
    @Test
    void cancellingAnOrderTheProviderHasNotCollectedAsksTheProviderAndClosesIt() {
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L);
        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "resp_desc", "交易成功", "org_trans_stat", "P")));
        Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId);
        assertThat(((Number) result.get("orderStatus")).intValue()).isEqualTo(2);
        verify(paymentGateway).queryMiniProgramPayment(any());
        ArgumentCaptor<HuifuPaymentCloseCommand> close = ArgumentCaptor.forClass(HuifuPaymentCloseCommand.class);
        verify(paymentGateway).closeMiniProgramPayment(close.capture());
        assertThat(close.getValue().paymentNo()).isEqualTo(paymentNo);
        assertThat(count("select count(*) from payment_transaction where payment_no = ? and status = 'CLOSED'", paymentNo)).isEqualTo(1);
    }
    @Test
    void cancellingIsRefusedAndRecordedWhenClosingRevealsTheTradeWasAlreadyPaid() {
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L);
        // 查单说「交易不存在」,关单却回「原订单已为终态」且原交易已成功:钱可能真的在,不能当成没收过款。
        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId)));
        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取",
                "org_trans_stat", "S", "huifu_id", huifuId)));
        // 查单拿不到金额,无法把订单落成已支付,所以不能谎称已支付;但订单也绝不能被取消。
        assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("确认中");
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
        assertThat(count("select count(*) from payment_transaction where payment_no = ? and status = 'PREPAY_CREATED'", paymentNo)).isEqualTo(1);
        assertThat(openIssueCount("PAYMENT_CLOSE_CONFLICT", paymentNo)).isEqualTo(1L);
    }
    @Test
    void cancellingProceedsButKeepsThePrepayWatchableWhenTheProviderRefusesToCloseIt() {
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L);
        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "F", "resp_code", "20000001", "resp_desc", "不允许关闭一分钟以内的订单", "huifu_id", huifuId)));
        pendingOrderService.cancel(orderNo, talentId);
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(2);
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
                .as("关不掉的预支付单还可能被支付,本地不能记成已关闭,要留给对账任务继续盯着")
                .isEqualTo("CANCEL_UNCONFIRMED");
        assertThat(openIssueCount("PAYMENT_CLOSE_UNCONFIRMED", paymentNo)).isEqualTo(1L);
    }
    @Test
    void cancellingIsRefusedWhileTheProviderIsStillClosingThePrepay() {
        String orderNo = pendingOrder();
        pendingPayment(orderNo, 2000L);
        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "P", "resp_code", "00000100", "resp_desc", "交易处理中", "huifu_id", huifuId)));
        assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("确认中");
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
    }
    @Test
    void cancellingIsRefusedWhenTheProviderCannotSayWhetherTheMoneyWasCollected() {
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L);
        when(paymentGateway.queryMiniProgramPayment(any()))
                .thenThrow(new HuifuPaymentGatewayException("Huifu payment query failed", true, null));
        assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("确认中");
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
                .as("an unanswerable payment question must not destroy the only record of the money")
                .isEqualTo("PREPAY_CREATED");
        assertThat(openIssueCount("PAYMENT_QUERY_FAILED", paymentNo)).isEqualTo(1L);
    }
    @Test
    void retryingAPaymentTheProviderCollectedReportsItAsPaidInsteadOfAskingTheUserToPayAgain() {
        String orderNo = pendingOrder();
        pendingPayment(orderNo, 2000L);
        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "trans_amt", "20.00",
                "huifu_id", huifuId, "org_hf_seq_id", "HF-RETRY-" + suffix)));
        when(paymentGateway.createMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "P", "resp_code", "00000000", "pay_info", "{\"appId\":\"wx-test\"}")));
        Map<String, Object> result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "retry-key-" + suffix);
        assertThat(result).containsEntry("paymentStatus", "PAID");
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(1);
        verify(paymentGateway, never()).createMiniProgramPayment(any());
    }
    @Test
    void retryingAPaymentIsRefusedWhenTheProviderCannotSayWhetherTheMoneyWasCollected() {
        String orderNo = pendingOrder();
        pendingPayment(orderNo, 2000L);
        when(paymentGateway.queryMiniProgramPayment(any()))
                .thenThrow(new HuifuPaymentGatewayException("Huifu payment query failed", true, null));
        assertThatThrownBy(() -> paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "retry-key-" + suffix))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("确认中");
        verify(paymentGateway, never()).createMiniProgramPayment(any());
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
    }
    @Test
    void askingForThePaymentStatusConfirmsWithTheProviderAndReportsTheCollectedPayment() {
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L);
        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "trans_amt", "20.00",
                "huifu_id", huifuId, "org_hf_seq_id", "HF-STATUS-" + suffix)));
        Map<String, Object> result = paymentService.paymentStatus(orderNo, talentId, paymentNo);
        assertThat(result).containsEntry("paymentStatus", "PAID");
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(1);
    }
    /** A pending order bound to a collecting sub-merchant, created directly so the payment seam is the only subject. */
    @Test
    void anExpiredOrderWithAnUndecidedPaymentIsNotCancelledByTheDeadlineSweep() {
        String orderNo = expiredOrder();
        String paymentNo = pendingPayment(orderNo, 2000L);
        pendingOrderService.expireDueOrders(talentId);
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
                .as("到期不等于没收到钱:未定局的支付尝试只能交给通道确认后再处理")
                .isZero();
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
                .isEqualTo("PREPAY_CREATED");
    }
    @Test
    void anExpiredOrderWhosePaymentNeverSucceededIsCancelledByTheDeadlineSweep() {
        String orderNo = expiredOrder();
        pendingPayment(orderNo, 2000L, "CREATE_FAILED");
        pendingOrderService.expireDueOrders(talentId);
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(2);
    }
    @Test
    void anExpiredOrderWithoutAnyPaymentIsCancelledByTheDeadlineSweep() {
        String orderNo = expiredOrder();
        pendingOrderService.expireDueOrders(talentId);
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(2);
    }
    @Test
    void theReconciliationClosesAPrepayThatCouldNotBeClosedWhenTheOrderWasCancelled() {
        String paymentNo = cancelledOrderWithUnconfirmedClose();
        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "resp_desc", "交易成功", "huifu_id", huifuId)));
        reconciliationService.reconcile(5);
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
                .as("过了关单限制之后必须把预支付单关掉,否则用户手上那张单一直能付")
                .isEqualTo("CLOSED");
        assertThat(openIssueCount("PAYMENT_CLOSE_UNCONFIRMED", paymentNo)).isZero();
    }
    @Test
    void theReconciliationNeverClosesAPrepayTheProviderAlreadyCollected() {
        String paymentNo = cancelledOrderWithUnconfirmedClose();
        clearInvocations(paymentGateway);
        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId,
                "org_hf_seq_id", "HF-LATE-" + suffix)));
        reconciliationService.reconcile(5);
        verify(paymentGateway, never()).closeMiniProgramPayment(any());
        assertThat(openIssueCount("PAYMENT_ORDER_CANCELLED", paymentNo))
                .as("取消之后才收到的钱要留在账目上让人处理,而不是当成没收过")
                .isEqualTo(1L);
        assertThat(openIssueCount("PAYMENT_CLOSE_UNCONFIRMED", paymentNo)).isZero();
    }
    /** 订单刚创建就取消:通道不允许关 1 分钟以内的订单,取消成立,但预支付单留着等下一轮关。 */
    private String cancelledOrderWithUnconfirmedClose() {
        String orderNo = pendingOrder();
        String paymentNo = pendingPayment(orderNo, 2000L);
        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "F", "resp_code", "20000001", "resp_desc", "不允许关闭一分钟以内的订单", "huifu_id", huifuId)));
        pendingOrderService.cancel(orderNo, talentId);
        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
                .isEqualTo("CANCEL_UNCONFIRMED");
        // 对账只处理挂起超过 N 分钟的流水,把它推到窗口内。
        jdbc.update("update payment_transaction set created_at = now() - interval 10 minute where payment_no = ?", paymentNo);
        return paymentNo;
    }
    private String pendingOrder() {
        return pendingOrder("date_add(now(), interval 30 minute)");
    }
    /** The same order, already past its payment deadline. */
    private String expiredOrder() {
        return pendingOrder("date_sub(now(), interval 1 minute)");
    }
    private String pendingOrder(String expiresAtSql) {
        String orderNo = "PENDING-" + suffix + "-" + (++orderSequence);
        jdbc.update("""
                insert into benefit_order(order_no, talent_id, merchant_id, order_type, order_amount, base_pay_amount,
                                          pay_amount, order_status, verify_status, expires_at, price_version, collect_huifu_id,
                                          product_name_snapshot)
                values (?, ?, ?, 'STORE_CONSUME', ?, ?, ?, 0, 0, %s, 1, ?, ?)
                """.formatted(expiresAtSql), orderNo, talentId, merchantId, 2000L, 2000L, 2000L, huifuId, "待支付测试订单");
        return orderNo;
    }
    private String pendingPayment(String orderNo, long amount) {
        return pendingPayment(orderNo, amount, "PREPAY_CREATED");
    }
    private String pendingPayment(String orderNo, long amount, String status) {
        String paymentNo = "P-PENDING-" + suffix + "-" + orderSequence;
        Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
        jdbc.update("""
                insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id,
                                                provider_huifu_id, amount, status, idempotency_key, created_at)
                values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, now())
                """, paymentNo, orderId, orderNo, talentId, merchantId, huifuId, amount, status, "pending-payment-" + paymentNo);
        return paymentNo;
    }
    private Map<String, Object> providerResponse(Map<String, Object> data) {
        return Map.of("data", data);
    }
    private Long openIssueCount(String type, String businessNo) {
        return jdbc.queryForObject("""
                select count(*) from payment_reconciliation_issue
                where issue_type = ? and business_no = ? and status = 'open'
                """, Long.class, type, businessNo);
    }
    private long count(String sql, Object... args) {
        Long value = jdbc.queryForObject(sql, Long.class, args);
        return value == null ? 0 : value;
    }
}
talent-card-server/src/test/resources/application-test.yml
@@ -19,6 +19,10 @@
      # enforces the local onboarding and bank-card preconditions.
      enabled: false
      merchant-capability-required: false
      # 对账补单的定时任务在测试里不触发:用例自己直接调用查询与收敛服务(见 PaymentSettlementTests、
      # PendingPaymentConfirmationTests),避免后台任务在共享测试库上改动别的用例正在断言的流水与问题记录。
      reconciliation:
        enabled: false
  merchant:
    auto-withdrawal:
      # 自动提现的定时任务在测试里不触发:用例自己直接调用汇总/结果处理服务,避免后台任务在共享测试库上