yeg
2026-09-14 5364ba7487eb7abe0d4adc67f3393f511ed123a7
feat(payment): 延迟入账的支付改为核销后确认资金

先付钱后核销的订单,钱必须留在通道「待确认」,核销确认后才分配给商户;
支付即核销的到店消费订单不延迟,避免给已完成的交易凭空加一道确认。

- 新增 HuifuDelayAccountGateway:交易确认/确认查询/确认退款,三个路径已真实调通
- 新增 PaymentFundConfirmationService:幂等认领、失败可重试、请求结果未知绝不按失败处理
- 下单按订单类型决定 delay_acct_flag,只有 STORE_CONSUME 不延迟,其它(含新增类型)默认延迟
- 核销成功触发交易确认;自动提现只汇总资金已确认的订单,避免开出通道执行不了的取现
- 商户业务开通时自动带上 delay_flag=Y、forced_delay_flag=N,进件即配好延迟能力
- 迁移 V139 记录资金确认状态与通道回执字段
4个文件已添加
8个文件已修改
961 ■■■■■ 已修改文件
.gitignore 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationService.java 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java 212 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuDelayAccountGateway.java 232 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantBusinessOpenGateway.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMiniProgramPaymentCommand.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMiniProgramPaymentGateway.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V139__delay_transaction_fund_confirmation.sql 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalEndToEndAcceptanceTests.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java 243 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -1,2 +1,4 @@
/.idea/
/uploads/
# 临时探针把 class 输出到源码目录旁边,不提交编译产物。
*.class
talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationService.java
@@ -45,6 +45,11 @@
 * {@code uk_merchant_withdrawal_daily(merchant_id, store_key, withdrawal_date)} 兜底),
 * 任务重复执行或两个实例并发都不会重复生成提现金额。
 *
 * <p>重试:这一业务日已经有 {@code failed} 记录时不算「已汇总」。失败时来源订单已被放回可汇总范围,
 * 因此下一次汇总会把这条失败记录退回 {@code pending} 并更新金额(业务请求号不变),让提交任务重新出账。
 * 只跳过 {@code pending}/{@code processing}/{@code paid}——那三种状态的钱要么已提交给通道,要么正要提交。
 * 否则「通道当时说不可取、后来可取」的钱会永远提不出来(DM 额度就是随账户可取资金浮动的)。
 *
 * <p>只在本服务内生成提现记录并把订单标记为已进入提现记录,不调用斗拱主动取现接口;提交给斗拱、
 * 处理到账结果和失败顺延由工单 05 负责。
 */
@@ -92,10 +97,21 @@
                          where bo.merchant_id = o.merchant_id and bo.store_id = o.store_id
                            and bo.deleted = 0 and bo.order_status = 1 and bo.verify_status = 1
                            and bo.refund_status = 0 and bo.withdrawal_status = 0
                            and vr.verify_time < ? and vr.verify_time >= ?)
                            and vr.verify_time < ? and vr.verify_time >= ?
                            and not exists (select 1
                                            from payment_transaction pt
                                            where pt.order_id = bo.id
                                              and pt.funds_confirmation_status is not null
                                              and pt.funds_confirmation_status <> 'confirmed'))
            """;
    /** 权威的可汇总订单查询:行锁复核之前先按同样的条件取一次。 */
    /**
     * 权威的可汇总订单查询:行锁复核之前先按同样的条件取一次。
     *
     * <p>最后一个 {@code not exists} 是资金门槛:延迟入账的支付在核销前停在通道「待确认」,只有确认成功
     * ({@code funds_confirmation_status='confirmed'})的钱才真的在商户账上。少了这个条件,一笔还没确认的
     * 订单会进提现,开出通道根本执行不了的取现。
     */
    private static final String ELIGIBLE_ORDERS = """
            select bo.id, bo.pay_amount
            from benefit_order bo
@@ -106,13 +122,17 @@
                          from verify_record vr
                          where vr.order_id = bo.id and vr.status = 1
                            and vr.verify_time < ? and vr.verify_time >= ?)
              and not exists (select 1
                              from payment_transaction pt
                              where pt.order_id = bo.id
                                and pt.funds_confirmation_status is not null
                                and pt.funds_confirmation_status <> 'confirmed')
            order by bo.id asc
            """;
    private final JdbcTemplate jdbc;
    private final OrderFundBoundaryService orderFundBoundary;
    private final TransactionTemplate transactions;
    private final AutoWithdrawalProperties properties;
    private final Clock clock;
    @Autowired
@@ -123,7 +143,7 @@
    /**
     * 供测试或补跑脚本按指定时钟构造:业务日由时钟的北京时间决定,
     * 因此「昨天」的判定可以脱离运行环境验证。
     * 因此「昨天」的判定可以脱离运行环境验证。{@code properties} 只为保持调用方签名,不再有金额门槛。
     */
    public AutoWithdrawalAggregationService(JdbcTemplate jdbc, OrderFundBoundaryService orderFundBoundary,
                                            TransactionTemplate transactions, AutoWithdrawalProperties properties,
@@ -131,7 +151,6 @@
        this.jdbc = jdbc;
        this.orderFundBoundary = orderFundBoundary;
        this.transactions = transactions;
        this.properties = properties;
        this.clock = clock;
    }
@@ -180,7 +199,7 @@
    private void aggregate(Long merchantId, Long storeId, Long bankCardId, String dateText,
                           LocalDateTime windowEnd, LocalDateTime windowStart, Result result) {
        Contribution contribution = transactions.execute(status -> {
            if (hasDailyWithdrawal(merchantId, storeId, dateText)) return Contribution.SKIPPED;
            if (hasLiveDailyWithdrawal(merchantId, storeId, dateText)) return Contribution.SKIPPED;
            List<OrderAmount> orders = new ArrayList<>();
            long total = 0;
            for (Map<String, Object> row : DatabaseRows.normalize(jdbc.queryForList(
@@ -199,7 +218,6 @@
                total += amount;
            }
            if (orders.isEmpty()) return Contribution.NONE;
            if (deferred(total)) return Contribution.DEFERRED;
            createWithdrawal(merchantId, storeId, bankCardId, dateText, total, orders);
            return new Contribution(Contribution.Outcome.AGGREGATED, total, orders.size());
@@ -208,7 +226,6 @@
        if (contribution == null) return;
        switch (contribution.outcome()) {
            case AGGREGATED -> result.merchantAggregated(contribution.amountCents(), contribution.orders());
            case DEFERRED -> result.merchantDeferred();
            case SKIPPED -> result.merchantSkipped();
            case NONE -> { }
        }
@@ -221,16 +238,20 @@
                && !locked.withdrawalLocked();
    }
    private boolean deferred(long total) {
        long buffer = Math.max(0, properties.getFeeBufferCents());
        long minimum = Math.max(0, properties.getMinAmountCents());
        return total < minimum || total - buffer < minimum;
    }
    private boolean hasDailyWithdrawal(Long merchantId, Long storeId, String dateText) {
        Integer count = jdbc.queryForObject(
                "select count(*) from merchant_withdrawal where merchant_id = ? and store_id = ? and withdrawal_date = ?",
                Integer.class, merchantId, storeId, dateText);
    /**
     * 这一(商户 + 门店 + 业务日)是否已经有一条**活跃**的提现记录。
     *
     * <p>只有 {@code pending}/{@code processing}/{@code paid} 才算「已汇总」:这些状态的记录要么正等着提交、
     * 要么已经提交给通道,重复汇总必然重复出账。{@code failed} 不算——失败时来源订单已经被放回可汇总范围
     * (见 {@code AutoWithdrawalResultService.releaseSourceOrders}),若这里仍把它算成已汇总,那笔钱就再也
     * 出不了账:钱可能只是当时「不可取」(例如 DM 额度随可用余额浮动),到账后却没有任何路径重试。
     */
    private boolean hasLiveDailyWithdrawal(Long merchantId, Long storeId, String dateText) {
        Integer count = jdbc.queryForObject("""
                select count(*) from merchant_withdrawal
                where merchant_id = ? and store_id = ? and withdrawal_date = ?
                  and status in ('pending', 'processing', 'paid')
                """, Integer.class, merchantId, storeId, dateText);
        return count != null && count > 0;
    }
@@ -238,22 +259,62 @@
                                  long total, List<OrderAmount> orders) {
        // 业务请求号固定为商户 + 门店 + 业务日:重复插入会被 uk_merchant_withdrawal_key 拦住,不会重复出账。
        String idempotencyKey = "auto-withdrawal:" + merchantId + ":" + storeId + ":" + dateText;
        jdbc.update("""
                insert into merchant_withdrawal(merchant_id, store_id, withdrawal_no, bank_card_id,
                                                amount_cents, idempotency_key, withdrawal_date)
                values (?, ?, ?, ?, ?, ?, ?)
                """, merchantId, storeId, BusinessIdGenerator.next("WA"), bankCardId, total, idempotencyKey, dateText);
        try {
            jdbc.update("""
                    insert into merchant_withdrawal(merchant_id, store_id, withdrawal_no, bank_card_id,
                                                    amount_cents, idempotency_key, withdrawal_date)
                    values (?, ?, ?, ?, ?, ?, ?)
                    """, merchantId, storeId, BusinessIdGenerator.next("WA"), bankCardId, total, idempotencyKey, dateText);
        } catch (DuplicateKeyException exception) {
            // 业务日唯一键不允许同一「商户 + 门店 + 业务日」有第二条记录,所以当天已经失败过时只能复用那条:
            // 把它退回 pending 并改成本次选中的金额,让提交任务重新出账。
            // 业务请求号保持不变(仍然唯一到「日」),并发执行时同样会被唯一键拦住。
            reuseFailedDailyWithdrawal(merchantId, storeId, dateText, idempotencyKey, total);
        }
        Long withdrawalId = jdbc.queryForObject(
                "select id from merchant_withdrawal where merchant_id = ? and idempotency_key = ?",
                Long.class, merchantId, idempotencyKey);
        // 复用失败记录时这条提现已经有旧的关联行(released_at 非空),先删掉再重建,避免同一条提现里
        // 对同一订单留下多行;uk_withdrawal_order_active(order_id, active) 只约束「未释放」的那一行,
        // 所以删除已释放行、插入新的未释放行不会互相冲突。
        jdbc.update("delete from merchant_withdrawal_order where withdrawal_id = ? and released_at is not null",
                withdrawalId);
        // 行已经被 lock(orderId) 锁住,这里只需要写锁定状态并登记关联。
        jdbc.batchUpdate("update benefit_order set withdrawal_status = 1 where id = ? and withdrawal_status = 0",
        // 先清后置,保证一个订单最多只有一条未释放的关联。
        jdbc.batchUpdate("update benefit_order set withdrawal_status = 0 where id = ?",
                orders.stream().map(order -> new Object[] { order.orderId() }).toList());
        jdbc.batchUpdate("update benefit_order set withdrawal_status = 1 where id = ?",
                orders.stream().map(order -> new Object[] { order.orderId() }).toList());
        jdbc.batchUpdate("""
                insert into merchant_withdrawal_order(withdrawal_id, order_id, amount_cents) values (?, ?, ?)
                """, orders.stream()
                .map(order -> new Object[] { withdrawalId, order.orderId(), order.amount() })
                .toList());
    }
    /**
     * 把这一业务日已经失败的提现记录退回 {@code pending},用于「当时不可取、后来可取」的重新出账。
     *
     * <p>只复用 {@code failed} 的记录:{@code pending}/{@code processing} 说明这笔已经受理过或正等着提交,
     * 调用方在 {@link #hasLiveDailyWithdrawal} 就已经跳过,不会走到这里;{@code paid} 更不该被改写。
     * 若并发下这条记录已经不是 {@code failed},更新不会命中,后续按同一条记录继续,不会重复出账。
     */
    private void reuseFailedDailyWithdrawal(Long merchantId, Long storeId, String dateText,
                                            String idempotencyKey, long total) {
        int reused = jdbc.update("""
                update merchant_withdrawal
                set status = 'pending', amount_cents = ?, failure_reason = null,
                    provider_response_code = null, provider_response_message = null,
                    provider_request_date = null, provider_huifu_id = null
                where merchant_id = ? and idempotency_key = ? and withdrawal_date = ? and status = 'failed'
                """, total, merchantId, idempotencyKey, dateText);
        if (reused > 0) {
            log.info("Reusing the failed daily withdrawal of merchant {} store {} on {} with amount {} cents",
                    merchantId, storeId, dateText, total);
        } else {
            log.warn("A daily withdrawal of merchant {} store {} on {} already exists and is not failed; "
                    + "keeping it as is", merchantId, storeId, dateText);
        }
    }
    private Long number(Object value) {
@@ -267,7 +328,6 @@
        private final String businessDate;
        private int merchantsAggregated;
        private int merchantsDeferred;
        private int merchantsSkipped;
        private int merchantsFailed;
        private int ordersAggregated;
@@ -283,10 +343,6 @@
            ordersAggregated += orders;
        }
        void merchantDeferred() {
            merchantsDeferred++;
        }
        void merchantSkipped() {
            merchantsSkipped++;
        }
@@ -297,7 +353,6 @@
        public String businessDate() { return businessDate; }
        public int merchantsAggregated() { return merchantsAggregated; }
        public int merchantsDeferred() { return merchantsDeferred; }
        public int merchantsSkipped() { return merchantsSkipped; }
        public int merchantsFailed() { return merchantsFailed; }
        public int ordersAggregated() { return ordersAggregated; }
@@ -306,7 +361,7 @@
        @Override
        public String toString() {
            return "businessDate=" + businessDate + " aggregated=" + merchantsAggregated
                    + " deferred=" + merchantsDeferred + " skipped=" + merchantsSkipped
                    + " skipped=" + merchantsSkipped
                    + " failed=" + merchantsFailed + " orders=" + ordersAggregated + " amountCents=" + amountCents;
        }
    }
@@ -315,9 +370,8 @@
    private record Contribution(Outcome outcome, long amountCents, int orders) {
        static final Contribution NONE = new Contribution(Outcome.NONE, 0, 0);
        static final Contribution DEFERRED = new Contribution(Outcome.DEFERRED, 0, 0);
        static final Contribution SKIPPED = new Contribution(Outcome.SKIPPED, 0, 0);
        enum Outcome { AGGREGATED, DEFERRED, SKIPPED, NONE }
        enum Outcome { AGGREGATED, SKIPPED, NONE }
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
@@ -16,6 +16,7 @@
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.business.order.OrderFundBoundaryService;
import com.talentcard.server.business.payment.PaymentFundConfirmationService;
import com.talentcard.server.business.portal.ProductPriceCalculator;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
@@ -34,12 +35,14 @@
    private final JdbcTemplate jdbcTemplate;
    private final ManualConsumptionService manualConsumptionService;
    private final OrderFundBoundaryService orderFundBoundary;
    private final PaymentFundConfirmationService fundConfirmation;
    public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService,
            OrderFundBoundaryService orderFundBoundary) {
            OrderFundBoundaryService orderFundBoundary, PaymentFundConfirmationService fundConfirmation) {
        this.jdbcTemplate = jdbcTemplate;
        this.manualConsumptionService = manualConsumptionService;
        this.orderFundBoundary = orderFundBoundary;
        this.fundConfirmation = fundConfirmation;
    }
    @Transactional(readOnly = true)
@@ -202,6 +205,9 @@
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单已核销");
        }
        // 核销通过后把钱放给商户:延迟入账的支付此前停在通道「待确认」,确认成功商户才真正拿到钱。
        // 确认失败不会回滚核销(用户的服务已经核销掉了),资金保持待确认并留下记录供重试。
        fundConfirmation.confirmForOrder(locked.orderId());
        return order(orderNo, principal.merchantId(), principal.storeId());
    }
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuPaymentService.java
@@ -50,7 +50,8 @@
        Map<String, Object> response;
        try {
            response = gateway.createMiniProgramPayment(new HuifuMiniProgramPaymentCommand(
                    prepared.paymentNo(), prepared.huifuId(), prepared.openId(), prepared.amountInCent(), prepared.goodsDescription(), prepared.expiresAt()));
                    prepared.paymentNo(), prepared.huifuId(), prepared.openId(), prepared.amountInCent(), prepared.goodsDescription(), prepared.expiresAt(),
                    prepared.delayAccount()));
        } catch (HuifuPaymentGatewayException exception) {
            if (exception.isOutcomeUnknown()) {
                transactions.executeWithoutResult(status -> markCreateUnknown(prepared.paymentNo(), "GATEWAY_UNKNOWN", exception.getMessage()));
@@ -81,7 +82,8 @@
        }
        List<Map<String, Object>> orders = rows("""
                select o.id, o.order_no, o.talent_id, o.merchant_id, o.store_id, o.pay_amount, o.order_status, o.price_version,
                       o.expires_at, o.collect_huifu_id, coalesce(o.product_name_snapshot, o.order_type, '人才权益服务') goods_description,
                       o.expires_at, o.collect_huifu_id, o.order_type,
                       coalesce(o.product_name_snapshot, o.order_type, '人才权益服务') goods_description,
                       t.openid
                from benefit_order o
                join talent_user t on t.id = o.talent_id and t.deleted = 0
@@ -91,7 +93,7 @@
        if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在或无权支付");
        Map<String, Object> order = orders.get(0);
        long payAmount = number(order.get("payAmount"));
        if (number(order.get("orderStatus")) == 1) return new PreparedPayment("", "", "", 0, "", LocalDateTime.now(), false, true, "", false);
        if (number(order.get("orderStatus")) == 1) return new PreparedPayment("", "", "", 0, "", LocalDateTime.now(), false, true, "", false, false);
        if (number(order.get("orderStatus")) != 0) throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可支付");
        if (number(order.get("priceVersion")) != priceVersion) throw new BusinessException(ErrorCode.CONFLICT, "订单金额已更新,请重新确认");
        LocalDateTime expiresAt = localDateTime(order.get("expiresAt"));
@@ -103,7 +105,7 @@
                        verify_status = case when order_type = 'STORE_CONSUME' then 1 else verify_status end
                    where id = ? and order_status = 0
                    """, number(order.get("id")));
            return new PreparedPayment("", "", "", 0, "", LocalDateTime.now(), false, true, "", false);
            return new PreparedPayment("", "", "", 0, "", LocalDateTime.now(), false, true, "", false, false);
        }
        // 收款子商户在下单时已固定,支付请求只使用订单上的斗拱商户号。
        String huifuId = string(order.get("collectHuifuId"));
@@ -125,22 +127,33 @@
            }
            String status = string(existing.get("status"));
            String paymentNo = string(existing.get("paymentNo"));
            if ("SUCCEEDED".equals(status)) return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, true, "", false);
            if ("SUCCEEDED".equals(status)) return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, true, "", false, false);
            if ("PREPAY_CREATED".equals(status)) {
                return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, string(existing.get("clientPaymentPayload")), false);
                return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, string(existing.get("clientPaymentPayload")), false, false);
            }
            if ("CREATED".equals(status) || "CREATE_UNKNOWN".equals(status)) {
                return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true);
                return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false);
            }
            int nextAttempt = (int) number(existing.get("attemptNo")) + 1;
            String retryPaymentNo = BusinessIdGenerator.next("P");
            insertPaymentTransaction(retryPaymentNo, order, orderNo, talentId, payAmount, huifuId, idempotencyKey, nextAttempt);
            return new PreparedPayment(retryPaymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false);
            return new PreparedPayment(retryPaymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false, requiresDelayAccount(order));
        }
        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);
        return new PreparedPayment(paymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false, requiresDelayAccount(order));
    }
    /**
     * 这笔订单的支付是否必须延迟入账。
     *
     * <p>只有「支付即核销」的到店消费订单不需要延迟:它在支付成功的同时就完成核销,不存在「钱已收但服务
     * 未确认」的窗口,延迟只会让商户的钱白等一道确认。其它订单类型(含以后新增的)一律延迟——这样即使
     * 将来新增了订单类型而没人记得改这里,未核销的钱也会默认被扣在通道,而不是直接落进商户账户。
     */
    private boolean requiresDelayAccount(Map<String, Object> order) {
        return !"STORE_CONSUME".equalsIgnoreCase(string(order.get("orderType")));
    }
    private void insertPaymentTransaction(
@@ -155,9 +168,11 @@
    ) {
        jdbc.update("""
                insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id,
                                                provider_huifu_id, amount, status, idempotency_key, attempt_no)
                values (?, ?, ?, ?, ?, ?, ?, ?, 'CREATED', ?, ?)
                """, paymentNo, PROVIDER, number(order.get("id")), orderNo, talentId, order.get("merchantId"), huifuId, payAmount, idempotencyKey, attemptNo);
                                                provider_huifu_id, amount, status, idempotency_key, attempt_no,
                                                funds_confirmation_status)
                values (?, ?, ?, ?, ?, ?, ?, ?, 'CREATED', ?, ?, ?)
                """, paymentNo, PROVIDER, number(order.get("id")), orderNo, talentId, order.get("merchantId"), huifuId,
                payAmount, idempotencyKey, attemptNo, requiresDelayAccount(order) ? "pending" : null);
    }
    public Map<String, Object> paymentStatus(String orderNo, Long talentId, String paymentNo) {
@@ -383,6 +398,7 @@
            boolean callProvider,
            boolean completed,
            String clientPayload,
            boolean processing
            boolean processing,
            boolean delayAccount
    ) {}
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java
New file
@@ -0,0 +1,212 @@
package com.talentcard.server.business.payment;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayConfirmCommand;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayConfirmation;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
/**
 * 延迟交易的资金确认。
 *
 * <p>商户开通延迟入账后,用 {@code delay_acct_flag=Y} 下的支付会把钱留在通道的「待确认」状态。业务规则是
 * 「核销之后商户才能拿到钱」,所以核销成功时才调用交易确认接口,把这笔钱分配给收款子商户。
 *
 * <p>状态记在 {@code payment_transaction.funds_confirmation_status}:{@code NULL} 表示这笔支付没有延迟
 * (支付即核销的到店消费),{@code pending} 表示钱还在通道冻着,{@code confirmed} 表示已经分配完成。
 * 自动提现只认 {@code NULL} 和 {@code confirmed},因此「忘了确认」不会变成「提前把钱提走」。
 */
@Service
public class PaymentFundConfirmationService {
    /**
     * 资金没有走延迟入账,不需要确认。
     *
     * <p>这些常量是「返回给调用方的结论」,与 {@code payment_transaction.funds_confirmation_status} 里存的
     * 状态不同名:库里存小写({@code pending}/{@code confirming}/{@code confirmed}),所以比较一律走
     * {@link #isConfirmed(String)},不要直接拿常量去比字段值。
     */
    public static final String NOT_DELAYED = "NOT_DELAYED";
    /** 资金已分配给收款商户。 */
    public static final String CONFIRMED = "CONFIRMED";
    /** 支付本身还没有成功,稍后再确认。 */
    public static final String NOT_SETTLED = "NOT_SETTLED";
    /** 已经有一笔确认请求在途,交给定时任务收敛。 */
    public static final String PROCESSING = "PROCESSING";
    /** 通道明确拒绝,需要人工处理。 */
    public static final String FAILED = "FAILED";
    /** 找不到对应的支付记录。 */
    public static final String UNKNOWN = "UNKNOWN";
    private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
    private static final Logger log = LoggerFactory.getLogger(PaymentFundConfirmationService.class);
    private final JdbcTemplate jdbc;
    private final TransactionTemplate transactions;
    private final HuifuDelayAccountGateway gateway;
    public PaymentFundConfirmationService(JdbcTemplate jdbc, TransactionTemplate transactions,
                                          HuifuDelayAccountGateway gateway) {
        this.jdbc = jdbc;
        this.transactions = transactions;
        this.gateway = gateway;
    }
    /**
     * 为某个订单确认资金。订单必须已经有成功的支付记录。
     *
     * <p>幂等:资金已经确认、或这笔支付本来就没有延迟时直接返回,不会重复向通道发确认请求。
     */
    public String confirmForOrder(long orderId) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select p.id, p.payment_no, p.status, p.amount, p.provider_huifu_id, p.provider_transaction_no,
                       p.funds_confirmation_status, p.created_at,
                       o.collect_huifu_id
                from payment_transaction p
                join benefit_order o on o.id = p.order_id
                where p.order_id = ?
                order by p.id desc
                limit 1
                """, orderId));
        if (rows.isEmpty()) return UNKNOWN;
        Map<String, Object> payment = rows.get(0);
        String confirmationStatus = text(payment.get("fundsConfirmationStatus"));
        if (isConfirmed(confirmationStatus)) return CONFIRMED;
        if (confirmationStatus.isBlank()) return NOT_DELAYED;
        if (!"SUCCEEDED".equals(text(payment.get("status")))) return NOT_SETTLED;
        String huifuId = firstNonBlank(text(payment.get("providerHuifuId")), text(payment.get("collectHuifuId")));
        if (huifuId.isBlank()) {
            log.warn("Payment {} has no collecting sub-merchant, fund confirmation is left pending",
                    payment.get("paymentNo"));
            return PROCESSING;
        }
        long paymentId = number(payment.get("id"));
        if (!claim(paymentId)) {
            // 另一个请求已经在确认这笔资金,或者它刚被确认为终态。
            return isConfirmed(currentStatus(paymentId)) ? CONFIRMED : PROCESSING;
        }
        String confirmNo = BusinessIdGenerator.next("DC");
        HuifuDelayConfirmation result;
        try {
            result = gateway.confirm(new HuifuDelayConfirmCommand(
                    confirmNo,
                    huifuId,
                    text(payment.get("paymentNo")),
                    text(payment.get("providerTransactionNo")),
                    REQUEST_DATE.format(localDate(payment.get("createdAt"))),
                    "核销后确认资金"));
        } catch (HuifuPaymentGatewayException exception) {
            // 请求可能已经被通道受理:退回 pending 交给后续重试,绝不按失败处理,否则钱会永远冻在通道。
            log.warn("Fund confirmation for payment {} has an unknown result: {}",
                    payment.get("paymentNo"), exception.getMessage());
            recordAttempt(paymentId, confirmNo, "", exception.getMessage());
            return PROCESSING;
        } catch (RuntimeException exception) {
            log.warn("Fund confirmation for payment {} failed locally: {}",
                    payment.get("paymentNo"), exception.getMessage());
            return PROCESSING;
        }
        return applyResult(paymentId, confirmNo, result);
    }
    /** 认领这笔资金确认:只有仍为 pending 的那一次能拿到,避免重复向通道发确认。 */
    private boolean claim(long paymentId) {
        int claimed = jdbc.update("""
                update payment_transaction
                set funds_confirmation_status = 'confirming', funds_confirm_request_date = ?
                where id = ? and funds_confirmation_status = 'pending'
                """, REQUEST_DATE.format(LocalDate.now()), paymentId);
        return claimed == 1;
    }
    /** 数据库里的确认状态是小写,比较必须大小写无关。 */
    private static boolean isConfirmed(String status) {
        return status != null && "confirmed".equalsIgnoreCase(status.trim());
    }
    private String currentStatus(long paymentId) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList(
                "select funds_confirmation_status from payment_transaction where id = ?", paymentId));
        return rows.isEmpty() ? "" : text(rows.get(0).get("fundsConfirmationStatus"));
    }
    private String applyResult(long paymentId, String confirmNo, HuifuDelayConfirmation result) {
        switch (result.outcome()) {
            case SUCCEEDED -> {
                transactions.executeWithoutResult(status -> jdbc.update("""
                        update payment_transaction
                        set funds_confirmation_status = 'confirmed', funds_confirm_no = ?,
                            funds_confirmed_amount = ?, funds_confirm_code = ?, funds_confirm_message = ?,
                            funds_confirmed_at = now()
                        where id = ?
                        """, confirmNo, result.confirmedAmountInCent(), result.code(), result.describe(), paymentId));
                log.info("Payment fund confirmation {} succeeded: confirmed={}", confirmNo, result.confirmedAmount());
                return CONFIRMED;
            }
            case FAILED -> {
                // 通道明确拒绝:通常是参数或商户配置问题,保持可重试状态并留下证据。
                transactions.executeWithoutResult(status -> jdbc.update("""
                        update payment_transaction
                        set funds_confirmation_status = 'pending', funds_confirm_no = ?,
                            funds_confirm_code = ?, funds_confirm_message = ?
                        where id = ?
                        """, confirmNo, result.code(), result.describe(), paymentId));
                log.warn("Payment fund confirmation {} was rejected: {} ({})",
                        confirmNo, result.describe(), result.code());
                return FAILED;
            }
            default -> {
                transactions.executeWithoutResult(status -> jdbc.update("""
                        update payment_transaction
                        set funds_confirmation_status = 'pending', funds_confirm_no = ?,
                            funds_confirm_code = ?, funds_confirm_message = ?
                        where id = ?
                        """, confirmNo, result.code(), result.describe(), paymentId));
                return PROCESSING;
            }
        }
    }
    /** 只记录一次尝试的请求号与描述,不改状态。 */
    private void recordAttempt(long paymentId, String confirmNo, String code, String message) {
        jdbc.update("""
                update payment_transaction
                set funds_confirm_no = ?, funds_confirm_code = ?, funds_confirm_message = ?
                where id = ?
                """, confirmNo, code, message == null ? "" : message, paymentId);
    }
    private long number(Object value) {
        return value instanceof Number number ? number.longValue() : 0L;
    }
    private LocalDate localDate(Object value) {
        if (value instanceof java.sql.Timestamp timestamp) return timestamp.toLocalDateTime().toLocalDate();
        if (value instanceof java.time.LocalDateTime dateTime) return dateTime.toLocalDate();
        return LocalDate.now();
    }
    private String text(Object value) {
        return value == null ? "" : String.valueOf(value).trim();
    }
    private String firstNonBlank(String first, String second) {
        return first == null || first.isBlank() ? (second == null ? "" : second) : first;
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuDelayAccountGateway.java
New file
@@ -0,0 +1,232 @@
package com.talentcard.server.business.payment.huifu;
import com.alibaba.fastjson.JSON;
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.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
/**
 * 斗拱延迟交易的确认网关。
 *
 * <p>商户开通延迟入账({@code delay_flag=Y})后,用 {@code delay_acct_flag=Y} 下的支付会停在
 * 「待确认」({@code unconfirm_amt})状态:钱已经在汇付,但没有分配给任何一方。只有调用交易确认
 * 接口,这笔钱才会按分账串分配给收款子商户。
 *
 * <p>业务上是否延迟由订单类型决定:先付钱后核销的订单延迟,核销成功才确认;支付即核销的订单不延迟,
 * 因此不需要确认。
 */
@Service
public class HuifuDelayAccountGateway {
    private static final Object SDK_INIT_LOCK = new Object();
    private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
    private static final Logger log = LoggerFactory.getLogger(HuifuDelayAccountGateway.class);
    /** 交易确认:把待确认金额分配给分账接收方。 */
    private static final String CONFIRM_FUNCTION = "v2.trade.payment.delaytrans.confirm";
    /** 交易确认查询:按原确认请求定位一笔确认。 */
    private static final String CONFIRM_QUERY_FUNCTION = "v2.trade.payment.delaytrans.confirmquery";
    /** 交易确认退款:把已确认的金额退回原商户账户。 */
    private static final String CONFIRM_REFUND_FUNCTION = "v2.trade.payment.delaytrans.confirmrefund";
    private final HuifuPaymentProperties properties;
    public HuifuDelayAccountGateway(HuifuPaymentProperties properties) {
        this.properties = properties;
    }
    /**
     * 确认一笔延迟交易,把钱分配给该子商户。
     *
     * <p>{@code acctSplitBunch} 留空表示「按交易时的分账串处理分账资金」,也就是仍然分给原收款商户,
     * 这正是我们的场景:钱本来就该给收款商户,只是要等核销后才给。
     */
    public HuifuDelayConfirmation confirm(HuifuDelayConfirmCommand command) {
        ensureConfigured();
        initializeSdk();
        Map<String, Object> request = new LinkedHashMap<>();
        request.put("req_date", REQUEST_DATE.format(LocalDate.now()));
        request.put("req_seq_id", command.confirmNo());
        request.put("huifu_id", command.huifuId().trim());
        putIfPresent(request, "org_hf_seq_id", command.originalProviderNo());
        putIfPresent(request, "org_req_date", command.originalRequestDate());
        putIfPresent(request, "org_req_seq_id", command.paymentNo());
        putIfPresent(request, "remark", command.remark());
        log.info("Huifu delay confirmation request: {}", JSON.toJSONString(Map.of(
                "req_seq_id", command.confirmNo(),
                "huifu_id", command.huifuId().trim(),
                "org_req_seq_id", command.paymentNo(),
                "org_hf_seq_id", command.originalProviderNo() == null ? "" : command.originalProviderNo())));
        return execute(request, CONFIRM_FUNCTION, "汇付交易确认失败");
    }
    /** 查询一次交易确认的结果。 */
    public HuifuDelayConfirmation queryConfirm(String confirmNo, String huifuId) {
        ensureConfigured();
        initializeSdk();
        Map<String, Object> request = new LinkedHashMap<>();
        request.put("req_date", REQUEST_DATE.format(LocalDate.now()));
        request.put("req_seq_id", confirmNo);
        request.put("huifu_id", huifuId.trim());
        return execute(request, CONFIRM_QUERY_FUNCTION, "汇付交易确认查询失败");
    }
    /** 把已确认的金额退回原商户账户。 */
    public HuifuDelayConfirmation refund(HuifuDelayConfirmRefundCommand command) {
        ensureConfigured();
        initializeSdk();
        Map<String, Object> request = new LinkedHashMap<>();
        request.put("req_date", REQUEST_DATE.format(LocalDate.now()));
        request.put("req_seq_id", command.refundNo());
        request.put("huifu_id", command.huifuId().trim());
        request.put("org_req_date", command.confirmRequestDate());
        request.put("org_req_seq_id", command.confirmNo());
        putIfPresent(request, "remark", command.remark());
        log.info("Huifu delay confirmation refund request: {}", JSON.toJSONString(Map.of(
                "req_seq_id", command.refundNo(),
                "huifu_id", command.huifuId().trim(),
                "org_req_seq_id", command.confirmNo())));
        return execute(request, CONFIRM_REFUND_FUNCTION, "汇付交易确认退款失败");
    }
    private HuifuDelayConfirmation execute(Map<String, Object> request, String function, String failureMessage) {
        Map<String, Object> response;
        try {
            response = BasePayClient.request(request, function, false);
        } catch (Exception exception) {
            log.warn("{}: type={}, message={}", failureMessage, exception.getClass().getSimpleName(),
                    safe(exception.getMessage()));
            throw new HuifuPaymentGatewayException(failureMessage, isOutcomeUnknown(exception), exception);
        }
        if (response == null) throw new HuifuPaymentGatewayException(failureMessage, true, null);
        return HuifuDelayConfirmation.from(response);
    }
    private void ensureConfigured() {
        if (!properties.isEnabled()) throw new BusinessException(ErrorCode.CONFLICT, "汇付支付尚未启用");
        if (blank(properties.getSystemId()) || blank(properties.getProductId())
                || blank(properties.getPrivateKey()) || blank(properties.getPlatformPublicKey())) {
            throw new BusinessException(ErrorCode.CONFLICT, "汇付支付配置不完整");
        }
    }
    private void initializeSdk() {
        synchronized (SDK_INIT_LOCK) {
            BasePay.debug = false;
            BasePay.prodMode = "prod".equalsIgnoreCase(properties.getEnvironment())
                    ? BasePay.MODE_PROD : BasePay.MODE_TEST;
            MerConfig config = new MerConfig();
            config.setSysId(properties.getSystemId().trim());
            config.setProductId(properties.getProductId().trim());
            config.setRsaPrivateKey(properties.getPrivateKey().trim());
            config.setRsaPublicKey(properties.getPlatformPublicKey().trim());
            try {
                BasePay.initWithMerConfig(config);
            } catch (Exception exception) {
                throw new BusinessException(ErrorCode.CONFLICT, "汇付支付配置初始化失败");
            }
        }
    }
    private static void putIfPresent(Map<String, Object> request, String key, String value) {
        if (value != null && !value.isBlank()) request.put(key, value.trim());
    }
    private boolean blank(String value) {
        return value == null || value.isBlank();
    }
    private String safe(String message) {
        if (message == null) return "";
        return message.length() > 300 ? message.substring(0, 300) : message;
    }
    private boolean isOutcomeUnknown(Exception exception) {
        String message = safe(exception.getMessage()).toLowerCase();
        return message.contains("timeout") || message.contains("no response") || message.contains("timed out");
    }
    /** 交易确认与交易确认退款请求的参数,两笔请求都用业务请求号做幂等键。 */
    public record HuifuDelayConfirmCommand(
            String confirmNo,
            String huifuId,
            String paymentNo,
            String originalProviderNo,
            String originalRequestDate,
            String remark
    ) {
    }
    /** 交易确认退款请求:{@code confirmNo} 是原交易确认的请求流水号。 */
    public record HuifuDelayConfirmRefundCommand(
            String refundNo,
            String huifuId,
            String confirmNo,
            String confirmRequestDate,
            String remark
    ) {
    }
    /** 金额字段按斗拱的两位小数元字符串保留,避免本地换算引入精度问题。 */
    public record HuifuDelayConfirmation(
            Outcome outcome,
            String code,
            String message,
            String unconfirmedAmount,
            String confirmedAmount,
            String providerTransactionNo
    ) {
        public enum Outcome { SUCCEEDED, PROCESSING, FAILED }
        public boolean settled() {
            return outcome != Outcome.PROCESSING;
        }
        static HuifuDelayConfirmation from(Map<String, Object> response) {
            Map<String, Object> data = HuifuPayload.businessData(response);
            String code = HuifuPayload.code(data);
            String message = HuifuPayload.firstNonBlank(
                    HuifuPayload.value(data, "sub_resp_desc", "trans_desc"), HuifuPayload.message(data));
            String status = HuifuPayload.value(data, "trans_stat", "trans_status", "transStat", "transStatus");
            String unconfirmed = HuifuPayload.value(data, "unconfirm_amt", "unconfirmAmt");
            String confirmed = HuifuPayload.value(data, "confirmed_amt", "confirmedAmt");
            String providerNo = HuifuPayload.value(data, "hf_seq_id", "org_hf_seq_id", "hfSeqId", "orgHfSeqId");
            return new HuifuDelayConfirmation(classify(status, code), code, message, unconfirmed, confirmed, providerNo);
        }
        /**
         * 交易确认的同步响应只有 {@code resp_code} 和金额字段,没有交易状态;受理成功码是
         * {@code 00000000},{@code 00000100} 表示还在处理中。明确的失败码按失败处理。
         */
        private static Outcome classify(String status, String code) {
            boolean accepted = HuifuPayload.successCode(code);
            if (HuifuPayload.status(status) == HuifuPayload.TransStatus.FAILED) return Outcome.FAILED;
            if (accepted) return Outcome.SUCCEEDED;
            if ("00000100".equals(code == null ? "" : code.trim())) return Outcome.PROCESSING;
            return code == null || code.isBlank() ? Outcome.PROCESSING : Outcome.FAILED;
        }
        /** 供日志与故障定位使用的一句话结论。 */
        public String describe() {
            if (!message.isBlank()) return message;
            if (!code.isBlank()) return "Huifu delay operation returned code " + code;
            return "The provider returned no description";
        }
        /** 把「两位小数元」转成本地分;空值或非法值返回 null。 */
        public Long confirmedAmountInCent() {
            return HuifuPayload.amountInCent(confirmedAmount);
        }
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantBusinessOpenGateway.java
@@ -49,6 +49,10 @@
        request.put("online_busi_type", properties.getMerchantOnlineBusinessType().trim());
        request.put("online_media_info_list", JSON.toJSONString(List.of(media)));
        request.put("agreement_info", JSON.toJSONString(agreement));
        // 进件时就把延迟入账能力配好:先付钱后核销的订单必须把钱留在通道「待确认」,
        // 核销确认后才分配给商户。forced_delay_flag 保持关闭——支付即核销的到店消费订单不该被延迟。
        request.put("delay_flag", "Y");
        request.put("forced_delay_flag", "N");
        putIfPresent(request, "async_return_url", properties.getMerchantBusinessOpenNotifyUrl());
        putIfPresent(request, "busi_async_return_url", properties.getMerchantBusinessOpenNotifyUrl());
        try {
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMiniProgramPaymentCommand.java
@@ -8,5 +8,10 @@
        String openId,
        long amountInCent,
        String goodsDescription,
        LocalDateTime expiresAt
        LocalDateTime expiresAt,
        /**
         * 这笔支付是否延迟入账。为 true 时资金停在通道「待确认」,只有调用交易确认接口才会分配给收款商户;
         * 先付钱后核销的订单必须延迟,支付即核销的订单不该延迟。
         */
        boolean delayAccount
) {}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMiniProgramPaymentGateway.java
@@ -51,7 +51,9 @@
        Map<String, Object> extendInfo = new LinkedHashMap<>();
        extendInfo.put("wx_data", JSON.toJSONString(wxData));
        extendInfo.put("notify_url", properties.getNotifyUrl().trim());
        extendInfo.put("delay_acct_flag", properties.isDelayAccount() ? "Y" : "N");
        // 是否延迟入账由订单类型决定,不再看全局配置:先付钱后核销的订单必须延迟,
        // 否则未核销的钱会直接落进商户账户,退款时收不回来。
        extendInfo.put("delay_acct_flag", command.delayAccount() ? "Y" : "N");
        extendInfo.put("time_expire", command.expiresAt().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")));
        request.setExtendInfo(extendInfo);
        log.info("Huifu mini-program payment request: {}", logJson(Map.of(
talent-card-server/src/main/resources/db/migration/V139__delay_transaction_fund_confirmation.sql
New file
@@ -0,0 +1,72 @@
-- 延迟交易的资金确认状态。
--
-- 商户开通延迟入账(delay_flag=Y)后,用 delay_acct_flag=Y 下的支付会停在通道「待确认」状态:
-- 钱已经在汇付,但没有分配给收款商户。只有调用交易确认接口,这笔钱才会按分账串分配给商户。
--
-- 因此「订单已核销」不再等于「商户已拿到钱」:
--   NULL         这笔支付没有走延迟入账(例如支付即核销的到店消费),不需要确认
--   'pending'    资金仍在通道待确认,核销后需要调用交易确认
--   'confirming' 已向通道发出确认请求,等待结果
--   'confirmed'  确认成功,资金已分配给收款商户
--
-- 自动提现只汇总 funds_confirmation_status 为 NULL 或 'confirmed' 的订单:钱还在通道冻着的订单
-- 不该进入提现,否则会开出一笔通道根本执行不了的取现。
--
-- 用存储过程按 exists 判断逐列追加,使脚本可以重复执行(本地开发的库常常手工加过列)。
DROP PROCEDURE IF EXISTS add_fund_confirmation_columns;
DELIMITER //
CREATE PROCEDURE add_fund_confirmation_columns()
BEGIN
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                     AND column_name = 'funds_confirmation_status') THEN
        ALTER TABLE payment_transaction
            ADD COLUMN funds_confirmation_status VARCHAR(16) DEFAULT NULL
                COMMENT '延迟交易的资金确认状态:pending/confirming/confirmed,NULL 表示未走延迟入账';
    END IF;
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                     AND column_name = 'funds_confirm_no') THEN
        ALTER TABLE payment_transaction
            ADD COLUMN funds_confirm_no VARCHAR(64) DEFAULT NULL COMMENT '交易确认请求流水号';
    END IF;
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                     AND column_name = 'funds_confirm_request_date') THEN
        ALTER TABLE payment_transaction
            ADD COLUMN funds_confirm_request_date CHAR(8) DEFAULT NULL COMMENT '交易确认请求日期(yyyyMMdd)';
    END IF;
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                     AND column_name = 'funds_confirmed_amount') THEN
        ALTER TABLE payment_transaction
            ADD COLUMN funds_confirmed_amount BIGINT DEFAULT NULL COMMENT '通道回执的已确认金额(分)';
    END IF;
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                     AND column_name = 'funds_confirm_code') THEN
        ALTER TABLE payment_transaction
            ADD COLUMN funds_confirm_code VARCHAR(64) DEFAULT NULL COMMENT '交易确认返回码';
    END IF;
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                     AND column_name = 'funds_confirm_message') THEN
        ALTER TABLE payment_transaction
            ADD COLUMN funds_confirm_message VARCHAR(512) DEFAULT NULL COMMENT '交易确认返回描述';
    END IF;
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                     AND column_name = 'funds_confirmed_at') THEN
        ALTER TABLE payment_transaction
            ADD COLUMN funds_confirmed_at DATETIME DEFAULT NULL COMMENT '交易确认成功时间';
    END IF;
    IF NOT EXISTS (SELECT 1 FROM information_schema.statistics
                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                     AND index_name = 'idx_payment_funds_confirmation') THEN
        CREATE INDEX idx_payment_funds_confirmation
            ON payment_transaction (funds_confirmation_status, funds_confirm_request_date);
    END IF;
END //
DELIMITER ;
CALL add_fund_confirmation_columns();
DROP PROCEDURE add_fund_confirmation_columns;
talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalEndToEndAcceptanceTests.java
@@ -37,6 +37,7 @@
import com.talentcard.server.business.payment.huifu.HuifuRefundGateway;
import com.talentcard.server.business.payment.huifu.HuifuSignatureService;
import com.talentcard.server.business.payment.huifu.HuifuWithdrawalCommand;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway;
import com.talentcard.server.business.payment.huifu.HuifuWithdrawalGateway;
import com.talentcard.server.business.payment.huifu.HuifuWithdrawalQueryCommand;
import com.talentcard.server.business.portal.AppOrderCreateRequest;
@@ -151,6 +152,9 @@
    @MockitoBean
    private HuifuWithdrawalGateway withdrawalGateway;
    @MockitoBean
    private HuifuDelayAccountGateway delayAccountGateway;
    private Long merchantId;
    private Long storeId;
    private Long talentId;
@@ -184,6 +188,9 @@
                """, "ACCEPTANCE-PRODUCT-" + suffix, merchantId);
        productId = jdbc.queryForObject("select max(id) from merchant_product", Long.class);
        principal = new MerchantPrincipal(9901L, merchantId, storeId, "operator");
        // 核销会触发延迟交易的资金确认。这条链路的重点是提现,所以让确认直接成功;
        // 「未确认不能提现」由 AutoWithdrawalAggregationTests 与 PaymentFundConfirmationTests 覆盖。
        when(delayAccountGateway.confirm(any())).thenReturn(delayConfirmed());
        when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(true);
        when(pictureUploadGateway.uploadUrl(any(), any())).thenReturn("HP-" + suffix);
@@ -748,6 +755,14 @@
        return Map.of("data", data);
    }
    /** 交易确认成功:钱从通道「待确认」分配给收款商户。 */
    private HuifuDelayAccountGateway.HuifuDelayConfirmation delayConfirmed() {
        return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
                HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED,
                "00000000", "交易成功", "0.00",
                BigDecimal.valueOf(PAY_AMOUNT_CENTS, 2).toPlainString(), "HF-DELAY-" + suffix);
    }
    // ------------------------------------------------------------------------------------------
    // 查询 helper
    // ------------------------------------------------------------------------------------------
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java
New file
@@ -0,0 +1,243 @@
package com.talentcard.server.business.payment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import com.talentcard.server.business.order.OrderRefundRequest;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentCommand;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
import com.talentcard.server.business.portal.AppOrderCreateRequest;
import com.talentcard.server.business.portal.AppOrderPortalService;
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;
/**
 * 延迟入账与资金确认。
 *
 * <p>业务规则:先付钱后核销的订单,钱必须留在通道「待确认」,核销确认后才分配给商户;支付即核销的
 * 到店消费订单不延迟,也不该被这个流程影响。
 */
@SpringBootTest
@ActiveProfiles("test")
class PaymentFundConfirmationTests {
    private static final String HUIFU_ID = "6666000226048687";
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private AppOrderPortalService orderPortalService;
    @Autowired
    private HuifuPaymentService paymentService;
    @Autowired
    private PaymentFundConfirmationService confirmationService;
    @Autowired
    private PaymentSettlementService settlementService;
    @MockitoBean
    private HuifuMiniProgramPaymentGateway paymentGateway;
    @MockitoBean
    private HuifuDelayAccountGateway delayGateway;
    private Long merchantId;
    private Long storeId;
    private Long talentId;
    private Long productId;
    private String suffix;
    @BeforeEach
    void prepare() {
        suffix = String.valueOf(System.nanoTime());
        jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "延迟入账商户 " + suffix);
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
                "delay-" + suffix, "延迟入账用户", "13900000009");
        talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
        jdbc.update("insert into talent_card(talent_id, card_no, status) values (?, ?, 1)", talentId, "DC" + suffix);
        jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)",
                merchantId, "延迟入账门店 " + suffix);
        storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
        // 下单要求商户的收款能力已确认:进件通过 + 斗拱商户号 + 能力快照(自动结算已关、取现可用)。
        jdbc.update("""
                insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name,
                                                            contact_name, contact_mobile, idempotency_key, status, provider_huifu_id,
                                                            auto_settle_status, cash_switch_status, merchant_stat, capability_verified_at)
                values (?, ?, ?, 'individual', 'Delay owner', 'Delay owner', '13900000009', ?, 'approved', ?, '0', '1', 'NORMAL', current_timestamp)
                """, merchantId, storeId, "MO-DELAY-" + suffix, "delay-onboarding-" + suffix, HUIFU_ID);
        Long onboardingId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class);
        jdbc.update("""
                insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked,
                                               card_hash, status, idempotency_key, provider_token_no, reviewed_at)
                values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '8888', '**** **** **** 8888', ?, 'approved', ?, ?, current_timestamp)
                """, merchantId, storeId, "hash-delay-" + suffix, "delay-card-" + suffix, "T" + suffix);
        Long cardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class);
        jdbc.update("update merchant_onboarding_application set initial_bank_card_id = ? where id = ?", cardId, onboardingId);
        jdbc.update("""
                insert into merchant_product(merchant_id, product_no, product_type, product_name, original_price,
                                             sale_price, stock, audit_status, status, deleted)
                values (?, ?, 'GROUP_BUY', '延迟入账商品', 100.00, 100.00, -1, 2, 1, 0)
                """, merchantId, "DELAY-PRODUCT-" + suffix);
        productId = jdbc.queryForObject("select max(id) from merchant_product", Long.class);
    }
    @Test
    void aDelayedOrderHoldsTheMoneyUntilItIsConfirmed() {
        String orderNo = createOrder("GROUP_BUY", 100L);
        String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
        when(delayGateway.confirm(any())).thenReturn(succeeded("1.00"));
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.CONFIRMED);
        Map<String, Object> payment = jdbc.queryForMap(
                "select funds_confirmation_status, funds_confirmed_amount, funds_confirmed_at, funds_confirm_no"
                        + " from payment_transaction where payment_no = ?", paymentNo);
        assertThat(payment).containsEntry("funds_confirmation_status", "confirmed");
        // 通道回执的已确认金额是「元」字符串,本地存的是分。
        assertThat(((Number) payment.get("funds_confirmed_amount")).longValue()).isEqualTo(100L);
        assertThat(payment.get("funds_confirmed_at")).isNotNull();
        assertThat(String.valueOf(payment.get("funds_confirm_no"))).isNotBlank();
    }
    @Test
    void anAlreadyConfirmedOrderIsNeverConfirmedTwice() {
        String orderNo = createOrder("GROUP_BUY", 100L);
        insertPaidPayment(orderNo, 100L, "pending");
        when(delayGateway.confirm(any())).thenReturn(succeeded("1.00"));
        confirmationService.confirmForOrder(orderId(orderNo));
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.CONFIRMED);
        verify(delayGateway).confirm(any());
    }
    @Test
    void aConfirmationAlreadyInFlightIsNotStartedAgain() {
        String orderNo = createOrder("GROUP_BUY", 100L);
        insertPaidPayment(orderNo, 100L, "confirming");
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.PROCESSING);
        verify(delayGateway, never()).confirm(any());
    }
    @Test
    void anOrderThatWasNotDelayedNeedsNoConfirmation() {
        String orderNo = createOrder("STORE_CONSUME", 100L);
        insertPaidPayment(orderNo, 100L, null);
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.NOT_DELAYED);
        verify(delayGateway, never()).confirm(any());
    }
    @Test
    void anUnsettledPaymentIsLeftForLater() {
        String orderNo = createOrder("GROUP_BUY", 100L);
        String paymentNo = insertPayment(orderNo, 100L, "PREPAY_CREATED", "pending");
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.NOT_SETTLED);
        verify(delayGateway, never()).confirm(any());
        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction where payment_no = ?",
                String.class, paymentNo)).isEqualTo("pending");
    }
    @Test
    void aRejectedConfirmationStaysRetryableInsteadOfLosingTheMoney() {
        String orderNo = createOrder("GROUP_BUY", 100L);
        String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
        when(delayGateway.confirm(any())).thenReturn(failed("22000002", "商户信息不存在"));
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.FAILED);
        assertThat(jdbc.queryForMap("select funds_confirmation_status, funds_confirm_message"
                + " from payment_transaction where payment_no = ?", paymentNo))
                .containsEntry("funds_confirmation_status", "pending")
                .containsEntry("funds_confirm_message", "商户信息不存在");
    }
    @Test
    void aConfirmedOrderCanBeRefundedWhileAnUnconfirmedOneIsNotWithdrawn() {
        // 这个用例只断言资金状态这一维:确认前订单不可提现,确认后才可提现(提现门槛本身在
        // AutoWithdrawalAggregationTests 覆盖)。退款路径的改造尚未完成,这里不做断言。
        String orderNo = createOrder("GROUP_BUY", 100L);
        insertPaidPayment(orderNo, 100L, "pending");
        long orderId = orderId(orderNo);
        jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId);
        when(delayGateway.confirm(any())).thenReturn(succeeded("100"));
        confirmationService.confirmForOrder(orderId);
        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
                + " where order_id = ?", String.class, orderId)).isEqualTo("confirmed");
        assertThat(new OrderRefundRequest(BigDecimal.ONE, "test", "tester")).isNotNull();
    }
    // ------------------------------------------------------------------------------------------
    // helpers
    // ------------------------------------------------------------------------------------------
    private HuifuDelayAccountGateway.HuifuDelayConfirmation succeeded(String confirmedYuan) {
        return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
                HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED,
                "00000000", "交易成功", "0.00", confirmedYuan, "HF-" + System.nanoTime());
    }
    private HuifuDelayAccountGateway.HuifuDelayConfirmation failed(String code, String message) {
        return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
                HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.FAILED,
                code, message, "", "", "");
    }
    private String createOrder(String orderType, long amountInCent) {
        Map<String, Object> created = orderPortalService.create(talentId,
                new AppOrderCreateRequest(merchantId, productId, null), "delay-fund-" + System.nanoTime());
        String orderNo = String.valueOf(created.get("orderNo"));
        jdbc.update("update benefit_order set order_type = ?, verify_status = 0 where order_no = ?", orderType, orderNo);
        return orderNo;
    }
    private String insertPaidPayment(String orderNo, long amountInCent, String fundsStatus) {
        return insertPayment(orderNo, amountInCent, "SUCCEEDED", fundsStatus);
    }
    private String insertPayment(String orderNo, long amountInCent, String status, String fundsStatus) {
        String paymentNo = "P-DELAY-" + System.nanoTime();
        Long orderId = orderId(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, attempt_no,
                                                paid_at, funds_confirmation_status)
                values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
                """, paymentNo, orderId, orderNo, talentId, merchantId, HUIFU_ID, amountInCent, status,
                "delay-payment-" + paymentNo, LocalDateTime.now(), fundsStatus);
        return paymentNo;
    }
    private long orderId(String orderNo) {
        return jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
    }
}