| | |
| | | * {@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 负责。 |
| | | */ |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | |
| | | /** |
| | | * 供测试或补跑脚本按指定时钟构造:业务日由时钟的北京时间决定, |
| | | * 因此「昨天」的判定可以脱离运行环境验证。 |
| | | * 因此「昨天」的判定可以脱离运行环境验证。{@code properties} 只为保持调用方签名,不再有金额门槛。 |
| | | */ |
| | | public AutoWithdrawalAggregationService(JdbcTemplate jdbc, OrderFundBoundaryService orderFundBoundary, |
| | | TransactionTemplate transactions, AutoWithdrawalProperties properties, |
| | |
| | | this.jdbc = jdbc; |
| | | this.orderFundBoundary = orderFundBoundary; |
| | | this.transactions = transactions; |
| | | this.properties = properties; |
| | | this.clock = clock; |
| | | } |
| | | |
| | |
| | | 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( |
| | |
| | | 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()); |
| | |
| | | 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 -> { } |
| | | } |
| | |
| | | && !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; |
| | | } |
| | | |
| | |
| | | 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) { |
| | |
| | | |
| | | private final String businessDate; |
| | | private int merchantsAggregated; |
| | | private int merchantsDeferred; |
| | | private int merchantsSkipped; |
| | | private int merchantsFailed; |
| | | private int ordersAggregated; |
| | |
| | | ordersAggregated += orders; |
| | | } |
| | | |
| | | void merchantDeferred() { |
| | | merchantsDeferred++; |
| | | } |
| | | |
| | | void merchantSkipped() { |
| | | merchantsSkipped++; |
| | | } |
| | |
| | | |
| | | 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; } |
| | |
| | | @Override |
| | | public String toString() { |
| | | return "businessDate=" + businessDate + " aggregated=" + merchantsAggregated |
| | | + " deferred=" + merchantsDeferred + " skipped=" + merchantsSkipped |
| | | + " skipped=" + merchantsSkipped |
| | | + " failed=" + merchantsFailed + " orders=" + ordersAggregated + " amountCents=" + amountCents; |
| | | } |
| | | } |
| | |
| | | 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 } |
| | | } |
| | | } |