yeg
2026-09-14 5364ba7487eb7abe0d4adc67f3393f511ed123a7
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 }
    }
}