From 50784fc40d98faa46af89a0b2c2e4f22c06d6dcb Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 12 九月 2026 17:42:03 +0800
Subject: [PATCH] fix(merchant): 收紧自动提现汇总的结算配置与门店范围
---
talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationService.java | 186 ++++++++++++++++++++++++++--------------------
1 files changed, 106 insertions(+), 80 deletions(-)
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationService.java
index c15cc29..43ed10b 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationService.java
@@ -14,11 +14,10 @@
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.order.OrderFundBoundaryService;
import com.talentcard.server.common.BusinessException;
-import com.talentcard.server.common.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.springframework.dao.DuplicateKeyException;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
@@ -26,26 +25,28 @@
/**
* 每日自动提现汇总。
*
- * <p>按北京时间取前一个自然日,把每个商户「截至该日结束已核销、未退款、还没进入其它提现记录」的订单
- * 汇总成一条提现记录:金额写 {@code merchant_withdrawal},来源订单写内部关联表
+ * <p>按北京时间取前一个自然日,把每个商户结算账户下「已支付、已核销、未退款、还没进入其它提现记录」
+ * 的订单汇总成一条提现记录:金额写 {@code merchant_withdrawal},来源订单写内部关联表
* {@code merchant_withdrawal_order},提现记录的业务日写 {@code withdrawal_date}。
* 汇总后的订单通过 {@link OrderFundBoundaryService#lockIntoWithdrawal(long)} 标记为已进入提现记录,
- * 因此不能再撤销核销或退款,也不会被以后的任务重复计入。
+ * 因此不能再撤销核销或退款,也不会被以后的任务重复计入(关联表的 {@code order_id} 唯一索引兜底)。
*
- * <p>业务日窗口是「核销时间不晚于该业务日 24:00」,没有下界:任务在凌晨跑,因此当天白天核销的订单
- * 都落在窗口内;某个业务日没被汇总的订单(核销时退款还在途、或商户当时没有可用的结算银行卡)
- * 不会被丢掉,下一次任务仍在窗口内,会随新订单一起汇总。这个选择保证订单不会因为一次跳过而永久
- * 留在未提现状态,代价是一次任务会扫描商户全部未进入提现记录的已核销订单({@code verify_record.verify_time}
- * 和 {@code benefit_order.merchant_id} 都有索引)。
+ * <p>业务日窗口是「核销时间不晚于该业务日 24:00,且不早于该业务日往前 {@value #MAX_LOOKBACK_DAYS} 天」。
+ * 任务在凌晨跑,因此当天白天核销的订单都落在窗口内;某天被跳过的订单(核销时退款还在途、商户当时
+ * 没有可用的结算银行卡、当天金额不足)在下一次任务里仍在窗口内,会继续累积(顺延),不会因为一次
+ * 跳过就永久留在未提现状态。下界的作用是给首次上线兜底:不会把多年前的历史订单一次性汇总进来。
*
- * <p>可提金额低于最低提现金额、或扣掉手续费缓冲后不足最低金额时,这个商户当天不生成记录,
- * 金额留到下一次任务继续累积(顺延)。
+ * <p>只有「审批通过、登记了斗拱子商户号、且有审核通过的结算银行卡(带 token)」的商户才会生成记录,
+ * 否则这一天的金额顺延——生成一条提交不出去的记录会把订单永久锁死。
+ *
+ * <p>可提金额低于最低提现金额、或扣掉手续费缓冲后仍不足最低金额时,这一天的金额顺延到下一次任务。
*
* <p>幂等:同一商户同一业务日只可能有一条日汇总记录(先查后写,并有
- * {@code uk_merchant_withdrawal_daily} 兜底),任务重复执行不会重复生成提现金额。
+ * {@code uk_merchant_withdrawal_daily(merchant_id, store_key, withdrawal_date)} 兜底),
+ * 任务重复执行或两个实例并发都不会重复生成提现金额。
*
- * <p>只在本服务内生成提现记录并把订单标记为处理中,不调用斗拱主动取现接口;提交给斗拱、处理
- * 到账结果和失败顺延由后续工单负责。
+ * <p>只在本服务内生成提现记录并把订单标记为已进入提现记录,不调用斗拱主动取现接口;提交给斗拱、
+ * 处理到账结果和失败顺延由工单 05 负责。
*/
@Service
public class AutoWithdrawalAggregationService {
@@ -55,46 +56,56 @@
/** 日汇总和日期边界一律按北京时间计算,与商户看到的「日」一致。 */
public static final ZoneId BEIJING = ZoneId.of("Asia/Shanghai");
+ /** 业务日窗口的下界长度:订单最多在窗口里等这么久,给首次上线和长期跳过的订单兜底。 */
+ static final int MAX_LOOKBACK_DAYS = 30;
+
private static final DateTimeFormatter BUSINESS_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
+ /**
+ * 每个「商户 + 门店」在业务日窗口内有可汇总订单、且结算配置完整时的候选。
+ *
+ * <p>结算银行卡必须和 token 来自同一张卡:审核通过的首绑卡带 token 就用它,否则整张换成该门店
+ * 最新一张带 token 的审核通过卡,再取这张卡的 id。否则会出现「记录指向一张没有 token 的卡」
+ * 这种提交不出去的组合。
+ */
private static final String ELIGIBLE_MERCHANTS = """
- select o.merchant_id, o.store_id, coalesce(i.id, r.id) as bank_card_id,
- coalesce(i.provider_token_no, r2.provider_token_no) as token_no
+ select o.merchant_id, o.store_id, card.id as bank_card_id
from merchant_onboarding_application o
- join (select merchant_id, store_id, max(id) as onboarding_id
- from merchant_onboarding_application
- where status = 'approved'
- group by merchant_id, store_id) newest
- on newest.onboarding_id = o.id
left join merchant_bank_card i on i.id = o.initial_bank_card_id and i.status = 'approved'
left join (select merchant_id, store_id, max(id) as id
from merchant_bank_card
where status = 'approved' and provider_token_no is not null and provider_token_no <> ''
- group by merchant_id, store_id) r
- on r.merchant_id = o.merchant_id and r.store_id = o.store_id
- left join merchant_bank_card r2 on r2.id = r.id
- where coalesce(i.provider_token_no, r2.provider_token_no) is not null
- and coalesce(i.provider_token_no, r2.provider_token_no) <> ''
+ group by merchant_id, store_id) fallback
+ on fallback.merchant_id = o.merchant_id and fallback.store_id = o.store_id
+ join merchant_bank_card card
+ on card.id = if(i.provider_token_no is not null and i.provider_token_no <> '', i.id, fallback.id)
+ where card.status = 'approved'
+ and card.provider_token_no is not null and card.provider_token_no <> ''
+ and o.provider_huifu_id is not null and o.provider_huifu_id <> ''
+ and o.status = 'approved'
+ and o.id = (select max(newest.id) from merchant_onboarding_application newest
+ where newest.merchant_id = o.merchant_id and newest.store_id = o.store_id
+ and newest.status = 'approved')
and exists (select 1
from benefit_order bo
join verify_record vr on vr.order_id = bo.id and vr.status = 1
- where bo.merchant_id = o.merchant_id
+ 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 < ?)
- order by o.id desc
+ and vr.verify_time < ? and vr.verify_time >= ?)
""";
+ /** 权威的可汇总订单查询:行锁复核之前先按同样的条件取一次。 */
private static final String ELIGIBLE_ORDERS = """
select bo.id, bo.pay_amount
from benefit_order bo
- where bo.merchant_id = ?
+ where bo.merchant_id = ? and bo.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 exists (select 1
from verify_record vr
where vr.order_id = bo.id and vr.status = 1
- and vr.verify_time < ?)
+ and vr.verify_time < ? and vr.verify_time >= ?)
order by bo.id asc
""";
@@ -131,32 +142,30 @@
/** 某个业务日的汇总。业务日始终按北京时间解释,因此测试和补跑都可以指定具体日期。 */
public Result run(LocalDate businessDate) {
- // 窗口右端是该业务日 24:00(北京时间)。任务在凌晨执行,当天核销的订单都在窗口内;
- // 没被汇总的订单下一次仍在窗口内,会随新订单一起顺延。
+ // 窗口右端是该业务日 24:00:任务在凌晨执行,当天核销的订单都在窗口内。
LocalDateTime windowEnd = businessDate.plusDays(1).atStartOfDay();
+ // 窗口左端给首次上线兜底,避免把历史订单一次性全部汇总。
+ LocalDateTime windowStart = businessDate.minusDays(MAX_LOOKBACK_DAYS).atStartOfDay();
String dateText = businessDate.format(BUSINESS_DATE);
Result result = new Result(dateText);
for (Map<String, Object> candidate : DatabaseRows.normalize(jdbc.queryForList(
- ELIGIBLE_MERCHANTS, windowEnd))) {
+ ELIGIBLE_MERCHANTS, windowEnd, windowStart))) {
Long merchantId = number(candidate.get("merchantId"));
Long storeId = number(candidate.get("storeId"));
Long bankCardId = number(candidate.get("bankCardId"));
+ if (merchantId == null || storeId == null || bankCardId == null) continue;
try {
- aggregateMerchant(merchantId, storeId, bankCardId, dateText, windowEnd, result);
+ aggregate(merchantId, storeId, bankCardId, dateText, windowEnd, windowStart, result);
+ } catch (DuplicateKeyException exception) {
+ // 与另一个实例的汇总撞在一起:这一天的记录由对方生成,本实例没有出账。
+ result.merchantSkipped();
+ log.info("Auto withdrawal for merchant {} store {} on {} already exists, created by another run",
+ merchantId, storeId, dateText);
} catch (RuntimeException exception) {
- // 一个商户汇总失败不影响其它商户;失败的订单保持未锁定,下一次任务会重新汇总。
- // 与另一个实例撞在一起时唯一索引会抛 DuplicateKeyException:那一天的记录由对方生成,
- // 本实例没有出账,属于「已存在」而不是失败。
- if (exception instanceof DuplicateKeyException) {
- result.merchantSkipped();
- log.info("Auto withdrawal for merchant {} on {} already exists, created by another run",
- merchantId, dateText);
- } else {
- result.merchantFailed();
- log.warn("Auto withdrawal aggregation failed for merchant {} on {}: {}",
- merchantId, dateText, exception.getClass().getSimpleName());
- }
+ result.merchantFailed();
+ log.warn("Auto withdrawal aggregation failed for merchant {} store {} on {}: {}: {}",
+ merchantId, storeId, dateText, exception.getClass().getSimpleName(), exception.getMessage());
}
}
return result;
@@ -167,39 +176,45 @@
return LocalDate.ofInstant(instant, BEIJING).minusDays(1);
}
- private void aggregateMerchant(Long merchantId, Long storeId, Long bankCardId, String dateText,
- LocalDateTime windowEnd, Result result) {
- if (merchantId == null || storeId == null || bankCardId == null) return;
- transactions.executeWithoutResult(status -> {
- if (hasDailyWithdrawal(merchantId, dateText)) {
- result.merchantSkipped();
- return;
- }
+ /** 一个「商户 + 门店」一个业务日的汇总,整体在一个事务里完成。 */
+ 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;
List<OrderAmount> orders = new ArrayList<>();
long total = 0;
for (Map<String, Object> row : DatabaseRows.normalize(jdbc.queryForList(
- ELIGIBLE_ORDERS, merchantId, windowEnd))) {
+ ELIGIBLE_ORDERS, merchantId, storeId, windowEnd, windowStart))) {
Long orderId = number(row.get("id"));
Long payAmount = number(row.get("payAmount"));
- long amount = payAmount == null ? 0 : payAmount;
OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(orderId);
- if (locked.withdrawalLocked()) throw new BusinessException(ErrorCode.CONFLICT, "订单已进入其它提现记录");
- if (!isEligible(locked)) throw new BusinessException(ErrorCode.CONFLICT, "订单已不满足进入提现记录的条件");
+ if (!isEligible(locked)) {
+ // 候选查询之后订单被退款或撤销核销:只跳过这一单,同一天其它订单照常汇总。
+ log.info("Skip order {} of merchant {} store {} on {}: {}", orderId, merchantId, storeId,
+ dateText, locked.withdrawalLocked() ? "already in a withdrawal record" : "no longer eligible");
+ continue;
+ }
+ long amount = payAmount == null ? 0 : payAmount;
orders.add(new OrderAmount(orderId, amount));
total += amount;
}
- if (orders.isEmpty()) return;
- if (deferred(total)) {
- result.merchantDeferred();
- return;
- }
+ if (orders.isEmpty()) return Contribution.NONE;
+ if (deferred(total)) return Contribution.DEFERRED;
createWithdrawal(merchantId, storeId, bankCardId, dateText, total, orders);
- result.merchantAggregated(total, orders.size());
+ 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 -> { }
+ }
}
- /** 行锁拿到的状态才是判断依据:候选查询之后订单可能已经被退款或核销撤销。 */
+ /** 行锁拿到的状态才是判断依据:候选查询之后订单可能已经被退款、撤销核销或进入别的提现记录。 */
private boolean isEligible(OrderFundBoundaryService.LockedOrder locked) {
return locked.orderStatus() == 1 && locked.verifyStatus() == 1
&& locked.refundStatus() == OrderFundBoundaryService.REFUND_NONE
@@ -212,17 +227,17 @@
return total < minimum || total - buffer < minimum;
}
- private boolean hasDailyWithdrawal(Long merchantId, String dateText) {
+ private boolean hasDailyWithdrawal(Long merchantId, Long storeId, String dateText) {
Integer count = jdbc.queryForObject(
- "select count(*) from merchant_withdrawal where merchant_id = ? and withdrawal_date = ?",
- Integer.class, merchantId, dateText);
+ "select count(*) from merchant_withdrawal where merchant_id = ? and store_id = ? and withdrawal_date = ?",
+ Integer.class, merchantId, storeId, dateText);
return count != null && count > 0;
}
private void createWithdrawal(Long merchantId, Long storeId, Long bankCardId, String dateText,
long total, List<OrderAmount> orders) {
- // 业务请求号固定为商户 + 业务日:重复插入会被 uk_merchant_withdrawal_key 拦住,不会重复出账。
- String idempotencyKey = "auto-withdrawal:" + merchantId + ":" + dateText;
+ // 业务请求号固定为商户 + 门店 + 业务日:重复插入会被 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)
@@ -231,13 +246,14 @@
Long withdrawalId = jdbc.queryForObject(
"select id from merchant_withdrawal where merchant_id = ? and idempotency_key = ?",
Long.class, merchantId, idempotencyKey);
- for (OrderAmount order : orders) {
- orderFundBoundary.lockIntoWithdrawal(order.orderId());
- jdbc.update("""
- insert into merchant_withdrawal_order(withdrawal_id, order_id, amount_cents)
- values (?, ?, ?)
- """, withdrawalId, order.orderId(), order.amount());
- }
+ // 行已经被 lock(orderId) 锁住,这里只需要写锁定状态并登记关联。
+ jdbc.batchUpdate("update benefit_order set withdrawal_status = 1 where id = ? and withdrawal_status = 0",
+ 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());
}
private Long number(Object value) {
@@ -246,7 +262,7 @@
private record OrderAmount(Long orderId, long amount) { }
- /** 一次汇总的结果,用于日志和测试断言。 */
+ /** 一次(商户 + 门店 + 业务日)汇总的结果,用于日志和测试断言。 */
public static final class Result {
private final String businessDate;
@@ -294,4 +310,14 @@
+ " 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 }
+ }
}
--
Gitblit v1.7.1