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/resources/application.yml | 6
talent-card-server/src/main/resources/db/migration/V134__daily_withdrawal_aggregation_store_scope.sql | 39 ++++
.scratch/automatic-withdrawal/issues/04-daily-withdrawal-aggregation.md | 53 +++--
talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationService.java | 186 +++++++++++--------
talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalProperties.java | 2
talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationTests.java | 162 ++++++++++++++---
talent-card-server/docs/huifu-payment-integration.md | 39 ++-
7 files changed, 334 insertions(+), 153 deletions(-)
diff --git a/.scratch/automatic-withdrawal/issues/04-daily-withdrawal-aggregation.md b/.scratch/automatic-withdrawal/issues/04-daily-withdrawal-aggregation.md
index 1a9d428..9355600 100644
--- a/.scratch/automatic-withdrawal/issues/04-daily-withdrawal-aggregation.md
+++ b/.scratch/automatic-withdrawal/issues/04-daily-withdrawal-aggregation.md
@@ -18,36 +18,47 @@
已实现,主要落点:
- 新增 `AutoWithdrawalAggregationService`:`run()` 汇总前一个自然日(北京时间),`run(businessDate)`
- 供测试和补跑指定业务日。业务日窗口是「核销时间不晚于该业务日 24:00」且没有下界——凌晨执行时当天
- 核销的订单都在窗口内,某天被跳过的订单(退款在途、商户当时没有可用结算银行卡)下一次仍在窗口内,
- 不会永久留在未提现状态。业务日与日期边界都用 `Asia/Shanghai`,不依赖服务器或 JVM 默认时区。
+ 供测试和补跑指定业务日。业务日窗口是「核销时间 < 该业务日 24:00,且 >= 该业务日往前 30 天」——
+ 凌晨执行时当天核销的订单都在窗口内,被跳过的订单(退款在途、门店当时没有可用结算银行卡、当天金额
+ 不足)在下一次任务里仍在窗口内会继续累积(顺延),同时首次上线不会把多年前的历史一次性汇总进来。
+ 业务日与日期边界都用 `Asia/Shanghai`,不依赖服务器或 JVM 默认时区。
- 新增 `AutoWithdrawalScheduler`:cron 默认 `0 10 0 * * *` 且 `zone = "Asia/Shanghai"`,可用
`app.merchant.auto-withdrawal.*` 配置。只生成本地提现记录,不调用斗拱;提交给斗拱、到账结果与
失败顺延留给工单 05。
- 新增 `AutoWithdrawalProperties`(`min-amount-cents` 默认 1000、`fee-buffer-cents` 默认 0、
`enabled`、`cron`)。可提金额低于最低金额、或扣掉手续费缓冲后仍低于最低金额时,该商户当天不生成
- 记录,金额顺延到下一次任务。
+ 记录,金额顺延到下一次任务。`enabled` 默认 false:手动提现入口还在,同时开启会让同一批订单既进
+ 人工提现又进自动提现,工单 05 移除手动入口后再打开。
- 提现记录复用 `merchant_withdrawal`,金额为整数分(来源订单 `pay_amount` 之和),状态保持本地
- `pending`,业务日写新列 `withdrawal_date`。
+ `pending`,业务日写新列 `withdrawal_date`;汇总粒度是「商户 + 门店」,与提现记录本来按门店归属、
+ 门店页面按门店统计的既有语义一致。
- 来源订单关联写新表 `merchant_withdrawal_order(withdrawal_id, order_id, amount_cents)`,
`order_id` 唯一。每个订单先 `OrderFundBoundaryService.lock(orderId)` 复检「已支付 + 已核销 +
- 未退款 + 未进入其它提现记录」,再 `lockIntoWithdrawal(orderId)` 锁定,最后登记关联;锁定失败即整笔
- 汇总回滚,因此同一订单不可能出现在两条提现记录里。
-- 每个商户一个事务(`TransactionTemplate`),一个商户失败不影响其它商户;失败时订单保持未锁定,
- 下一次任务重新汇总。
-- 幂等三层:先查当天是否已有记录(有则跳过)、业务请求号固定为 `auto-withdrawal:{merchantId}:{yyyyMMdd}`
- (`uk_merchant_withdrawal_key`)、新增唯一索引 `uk_merchant_withdrawal_daily(merchant_id, withdrawal_date)`。
- 重复执行或两个实例并发都只会有一条记录、一份金额。
-- 迁移 `V133__daily_withdrawal_aggregation.sql`:新增 `withdrawal_date` 列、唯一索引和关联表。
+ 未退款 + 未进入其它提现记录」,再写 `withdrawal_status = 1` 并登记关联;候选查询之后状态变化的
+ 订单只跳过它自己,同一天其它订单照常汇总。
+- 汇总前要求的结算配置和提交路径一致:进件通过、`provider_huifu_id` 非空、结算银行卡审核通过且带
+ `token_no`;记录的 `bank_card_id` 和 token 一定来自同一张卡(首绑卡没有 token 就整张换成该门店
+ 最新一张带 token 的审核通过卡)。配置不完整时这一天的金额顺延,而不是生成一条提交不出去、还会把
+ 订单永久锁死的记录。
+- 每个(商户 + 门店)一个事务(`TransactionTemplate`),一个门店失败不影响其它门店;失败时订单保持
+ 未锁定,下一次任务重新汇总。
+- 幂等三层:先查当天是否已有记录(有则跳过)、业务请求号固定为
+ `auto-withdrawal:{merchantId}:{storeId}:{yyyyMMdd}`(`uk_merchant_withdrawal_key`)、新增唯一索引
+ `uk_merchant_withdrawal_daily(merchant_id, store_key, withdrawal_date)`(`store_key` 是 `ifnull(store_id, 0)`
+ 生成列,避免 NULL 让唯一约束失效)。重复执行或两个实例并发都只会有一条记录、一份金额。
+- 迁移:`V133__daily_withdrawal_aggregation.sql`(`withdrawal_date` 列、唯一索引、关联表)和
+ `V134__daily_withdrawal_aggregation_store_scope.sql`(`store_key` 生成列,把日汇总唯一约束从
+ 「商户 + 业务日」收紧到「商户 + 门店 + 业务日」)。V133 已在共享测试库执行,按只追加规则没有修改它。
- 文档:`talent-card-server/docs/huifu-payment-integration.md` 新增 "Daily withdrawal aggregation"
- 一节,写明规则表、配置项和窗口无下界的原因。
+ 一节,写明规则表、配置项、窗口上下界的理由和门店粒度。
-测试:新增 `AutoWithdrawalAggregationTests` 10 项(MySQL),覆盖北京时间业务日边界(含 UTC 与北京
-日期不同的时刻)、业务日窗口右端、只汇总已核销未退款且未进入其它提现记录的订单、来源订单与金额关联、
-最低金额顺延(跨天累积)、手续费缓冲顺延、重复执行不重复出账(当天跑完后又核销一笔)、数据库层
-唯一约束、使用审核通过的结算银行卡、没有可用银行卡的商户不汇总、默认 `run()` 汇总北京时间昨天。
-全量 152 项测试仅剩 `docs/agents/testing.md` 记录的 3 项基线失败。
+测试:新增 `AutoWithdrawalAggregationTests` 13 项(MySQL),覆盖北京时间业务日边界(含 UTC 与北京
+日期不同的时刻)、业务日窗口右端、30 天回看下界、只汇总已支付已核销未退款且未进入其它提现记录的
+订单(五笔反例订单各自带有效核销记录,保证每个过滤条件本身被测到)、来源订单与金额关联、最低金额
+顺延(跨天累积)、手续费缓冲顺延、重复执行不重复出账(当天跑完后又核销一笔)、数据库层唯一约束、
+使用审核通过的结算银行卡、没有 token 的银行卡不汇总、没有子商户号的进件不汇总、门店之间互不影响、
+默认 `run()` 汇总北京时间昨天。全量测试仅剩 `docs/agents/testing.md` 记录的 3 项基线失败。
遗留(不在本 ticket 范围):提交斗拱、到账结果处理、失败重试与失败顺延解锁订单,见工单 05;失败解锁
-需要 `OrderFundBoundaryService` 增加显式的解锁方法。首次上线会把历史上所有「已核销未提现」订单一次
-汇总进来,若单笔金额超过斗拱主动取现上限,需要在上限约束(工单 05/06)里处理。
+需要 `OrderFundBoundaryService` 增加显式的解锁方法;工单 05 落地时同时移除手动提现入口。超过斗拱
+主动取现单笔上限的处理放在工单 05/06。
diff --git a/talent-card-server/docs/huifu-payment-integration.md b/talent-card-server/docs/huifu-payment-integration.md
index 49491f6..39b5d08 100644
--- a/talent-card-server/docs/huifu-payment-integration.md
+++ b/talent-card-server/docs/huifu-payment-integration.md
@@ -165,36 +165,43 @@
`AutoWithdrawalScheduler` runs `AutoWithdrawalAggregationService` every day at 00:10 Beijing time
(`app.merchant.auto-withdrawal.cron`, zone `Asia/Shanghai`) for the previous calendar day. The service
-turns one merchant-day into one `merchant_withdrawal` row; it does not call the provider, so the row
-stays in the local `pending` state until a later ticket submits it.
+turns one merchant store-day into one `merchant_withdrawal` row; it does not call the provider, so the
+row stays in the local `pending` state until a later ticket submits it. `AUTO_WITHDRAWAL_ENABLED`
+defaults to `false`: the manual withdrawal endpoint still exists until it is removed, and running both
+would put the same orders into two records.
| Rule | Implementation |
| --- | --- |
-| Business day | Beijing time. The window is `verify_time < business day 24:00` with no lower bound |
-| Order eligibility | `order_status = 1`, `verify_status = 1`, `refund_status = 0`, `withdrawal_status = 0`, plus an active `verify_record` |
-| Merchant eligibility | Newest approved onboarding of each merchant store with an approved settlement card that has a provider token |
-| One record per merchant-day | `merchant_withdrawal.withdrawal_date` (Beijing `yyyyMMdd`) plus unique key `uk_merchant_withdrawal_daily` |
+| Business day | Beijing time. The window is `verify_time < business day 24:00` and `>= business day - 30 days` |
+| Order eligibility | `order_status = 1`, `verify_status = 1`, `refund_status = 0`, `withdrawal_status = 0`, not deleted, plus an active `verify_record` |
+| Merchant store eligibility | Newest approved onboarding of the store, with a non-blank `provider_huifu_id` and an approved settlement card that carries a provider token |
+| Settlement card | The approval-time card when it has a token, otherwise the store's newest approved token card; the recorded `bank_card_id` and the token always come from that one card |
+| One record per merchant store-day | `merchant_withdrawal.withdrawal_date` (Beijing `yyyyMMdd`) plus unique key `uk_merchant_withdrawal_daily (merchant_id, store_key, withdrawal_date)`, where `store_key = ifnull(store_id, 0)` |
| Source order links | `merchant_withdrawal_order`, one row per order, unique key on `order_id` |
| Amount | Integer cents, the sum of `benefit_order.pay_amount` |
-| Deferral | Skip the day when the total is below `min-amount-cents`, or when `total - fee-buffer-cents` is still below it |
-| Idempotency | The day's record is looked up first; the unique keys on `(merchant_id, withdrawal_date)` and `order_id` are the database-level guard |
+| Deferral | Skip the day when the total is below `min-amount-cents`, when `total - fee-buffer-cents` is still below it, or when the merchant store has no dispatchable settlement configuration |
+| Idempotency | The day's record is looked up first; the unique keys on `(merchant_id, store_key, withdrawal_date)` and on `order_id` are the database-level guard |
| Locking | Every aggregated order goes through `OrderFundBoundaryService.lockIntoWithdrawal`, so it can no longer be revoked, refunded or aggregated again |
-The window has no lower bound on purpose: an order that was skipped because its refund was still in
-flight, or because the merchant had no usable settlement card that day, is picked up by the next run
-instead of being stranded. A merchant-day that never reaches the minimum simply never creates a row,
-and the amount carries forward into the next run.
+The window's lower bound exists so a first deployment does not sweep years of history into one
+withdrawal; anything older than 30 days is left alone for manual review. Inside the window, an order
+that was skipped because its refund was still in flight, because the merchant store had no usable
+settlement card, or because that day's total was below the minimum, is picked up by the next run
+instead of being stranded. Aggregation is per merchant store, matching the store-scoped withdrawal
+pages and the store that collected the money.
```text
-AUTO_WITHDRAWAL_ENABLED=true
+AUTO_WITHDRAWAL_ENABLED=false
AUTO_WITHDRAWAL_MIN_AMOUNT_CENTS=1000
AUTO_WITHDRAWAL_FEE_BUFFER_CENTS=0
AUTO_WITHDRAWAL_CRON=0 10 0 * * *
```
-`AutoWithdrawalAggregationTests` covers the Beijing date boundary, the eligibility filters, the source
-order links, the minimum amount and fee-buffer deferral, repeated runs, the database uniqueness guard,
-the approved settlement card and the merchant without a usable card.
+`AutoWithdrawalAggregationTests` covers the Beijing date boundary, the eligibility filters (each
+negative order carries an active verification record, so the filters themselves are exercised), the
+source order links, the minimum amount and fee-buffer deferral, repeated runs, the database uniqueness
+guard, the 30-day lookback, the approved settlement card, a merchant store with no dispatchable
+configuration, and per-store isolation.
## Reconciliation and exception handling
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 }
+ }
}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalProperties.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalProperties.java
index 9935691..3575875 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalProperties.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalProperties.java
@@ -20,7 +20,7 @@
/** 任务是否启用。凌晨的定时任务按北京时间触发,本地开发可以关掉。 */
private boolean enabled = true;
- /** 每天触发日汇总的时间(北京时间,cron 的 秒 分 时 日 月 周)。 */
+ /** 每天触发日汇总的时间(北京时间,cron 的 秒 分 时 日 月 周)。由 AutoWithdrawalScheduler 的 @Scheduled 从配置读取。 */
private String cron = "0 10 0 * * *";
public long getMinAmountCents() { return minAmountCents; }
diff --git a/talent-card-server/src/main/resources/application.yml b/talent-card-server/src/main/resources/application.yml
index 764fc8a..61c3494 100644
--- a/talent-card-server/src/main/resources/application.yml
+++ b/talent-card-server/src/main/resources/application.yml
@@ -85,8 +85,10 @@
fixed-delay-ms: ${HUIFU_CAPABILITY_REFRESH_FIXED_DELAY_MS:1800000}
merchant:
auto-withdrawal:
- # 每日凌晨按北京时间汇总前一个自然日已核销订单,生成一条商户日汇总提现记录。
- enabled: ${AUTO_WITHDRAWAL_ENABLED:true}
+ # 每日凌晨按北京时间汇总前一个自然日已核销订单,生成一条商户(门店)日汇总提现记录。
+ # 手动提现入口在工单 05 之前仍然存在,且不会写 benefit_order.withdrawal_status,同时开启会出现
+ # 同一批订单既进人工提现又进自动提现;因此默认关闭,等工单 05 提交斗拱并移除手动入口后再打开。
+ enabled: ${AUTO_WITHDRAWAL_ENABLED:false}
min-amount-cents: ${AUTO_WITHDRAWAL_MIN_AMOUNT_CENTS:1000}
fee-buffer-cents: ${AUTO_WITHDRAWAL_FEE_BUFFER_CENTS:0}
cron: ${AUTO_WITHDRAWAL_CRON:0 10 0 * * *}
diff --git a/talent-card-server/src/main/resources/db/migration/V134__daily_withdrawal_aggregation_store_scope.sql b/talent-card-server/src/main/resources/db/migration/V134__daily_withdrawal_aggregation_store_scope.sql
new file mode 100644
index 0000000..bd98720
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V134__daily_withdrawal_aggregation_store_scope.sql
@@ -0,0 +1,39 @@
+-- 每日自动提现汇总的第二个迁移:V133 只加了列和唯一索引,这里把「同一商户同一自然日一条日汇总记录」
+-- 收紧到「同一商户 + 同一门店 + 同一自然日」,与提现记录本来按门店归属的既有语义一致;
+-- 并给 store_id 为空的历史行一个非空键(MySQL 唯一索引允许多个 NULL,NULL 会让约束形同虚设)。
+--
+-- 之所以不改 V133:它已经在共享测试库上执行过,迁移只追加不修改(docs/agents/testing.md)。
+-- 索引名沿用 V133 里的 uk_merchant_withdrawal_daily,所以先按条件把它换成新定义。
+
+-- 生成列 store_key:store_id 为空时取 0。generated 列不能直接改唯一索引里的 NULL 语义,
+-- 但可以让 NULL 变成一个确定值,从而保证同一天不会因为 store_id 为空而多出一行。
+alter table merchant_withdrawal
+ add column store_key bigint generated always as (ifnull(store_id, 0)) stored
+ comment '门店键:store_id 为空时取 0,仅用于日汇总唯一约束';
+
+-- 清理 V133 之后可能存在的重复日汇总(同一商户 + 门店 + 业务日),保留最早一条。
+delete later from merchant_withdrawal later
+ join merchant_withdrawal earliest
+ on earliest.merchant_id = later.merchant_id
+ and earliest.store_key = later.store_key
+ and earliest.withdrawal_date = later.withdrawal_date
+ and earliest.id < later.id
+where later.withdrawal_date is not null;
+
+-- 旧索引是 (merchant_id, withdrawal_date),新索引是 (merchant_id, store_key, withdrawal_date)。
+-- MySQL 的 DDL 不支持 IF EXISTS,用 information_schema 判断,保证在全新库和已执行过 V133 的库上都能跑。
+set @drop_daily_index = (
+ select if(count(*) > 0,
+ 'alter table merchant_withdrawal drop index uk_merchant_withdrawal_daily',
+ 'select 1')
+ from information_schema.statistics
+ where table_schema = database()
+ and table_name = 'merchant_withdrawal'
+ and index_name = 'uk_merchant_withdrawal_daily'
+);
+prepare drop_daily_index from @drop_daily_index;
+execute drop_daily_index;
+deallocate prepare drop_daily_index;
+
+alter table merchant_withdrawal
+ add unique key uk_merchant_withdrawal_daily (merchant_id, store_key, withdrawal_date);
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationTests.java
index 22c1b9d..6c6323d 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationTests.java
@@ -18,7 +18,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.test.context.ActiveProfiles;
@@ -58,22 +58,33 @@
suffix = String.valueOf(System.nanoTime());
jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "Auto withdrawal merchant " + suffix);
merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
- jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, "Auto withdrawal store " + suffix);
- storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", "auto-withdrawal-" + suffix, "自动提现用户", "13900000003");
talentId = jdbc.queryForObject("select max(id) from talent_user", 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)
- values (?, ?, ?, 'individual', 'Auto withdrawal owner', 'Auto withdrawal owner', '13800000001', ?, 'approved', ?)
- """, merchantId, storeId, "MO-AWA-" + suffix, "auto-withdrawal-onboarding-" + suffix, shortReference("H", suffix));
+ storeId = insertOnboardedStore("main");
+ bankCardId = jdbc.queryForObject(
+ "select id from merchant_bank_card where merchant_id = ? and store_id = ?", Long.class, merchantId, storeId);
+ }
+
+ /** 给商户加一个「进件通过 + 结算卡审核通过(带 token)」的门店,返回门店 ID。 */
+ private Long insertOnboardedStore(String name) {
+ String reference = name + "-" + suffix;
+ jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)",
+ merchantId, "Auto withdrawal " + reference);
+ Long newStoreId = jdbc.queryForObject("select max(id) from merchant_store", 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)
values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '8888', '**** **** **** 8888', ?, 'approved', ?, ?)
- """, merchantId, storeId, "hash-auto-withdrawal-" + suffix, "auto-withdrawal-card-" + suffix, shortReference("T", suffix));
- bankCardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class);
- jdbc.update("update merchant_onboarding_application set initial_bank_card_id=? where application_no=?", bankCardId, "MO-AWA-" + suffix);
+ """, merchantId, newStoreId, "hash-" + reference, "card-" + reference, shortReference("T", reference));
+ Long newBankCardId = jdbc.queryForObject("select max(id) from merchant_bank_card", 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, initial_bank_card_id)
+ values (?, ?, ?, 'individual', 'Auto withdrawal owner', 'Auto withdrawal owner', '13800000001', ?, 'approved', ?, ?)
+ """, merchantId, newStoreId, "MO-" + reference, "onboarding-" + reference,
+ shortReference("H", reference), newBankCardId);
+ return newStoreId;
}
@Test
@@ -81,11 +92,15 @@
LocalDate businessDate = someBusinessDate();
Long first = verifiedOrder(businessDate, 9000);
Long second = verifiedOrder(businessDate, 3000);
- Long unpaid = order(1, 0, 0, 0, 7000, businessDate); // 未支付
- Long unverified = order(1, 1, 0, 0, 5000, businessDate); // 支付但未核销
- Long refunded = order(1, 1, 1, 2, 4000, businessDate); // 核销后退款
- Long alreadyInWithdrawal = order(1, 1, 1, 0, 6000, businessDate); // 已进入其它提现记录
- jdbc.update("update benefit_order set withdrawal_status = 1 where id = ?", alreadyInWithdrawal);
+ // 下面每一笔都有有效核销记录,因此只能被对应的状态条件排除,保证过滤条件本身被测到。
+ Long unpaid = order(0, 0, 0, 0, 7000, businessDate);
+ Long unverified = order(1, 0, 0, 0, 5000, businessDate);
+ Long deleted = order(1, 1, 1, 0, 6000, businessDate);
+ Long refunded = order(1, 2, 0, 0, 4000, businessDate);
+ Long alreadyInWithdrawal = order(1, 0, 0, 1, 2000, businessDate);
+ for (Long orderId : List.of(unpaid, unverified, deleted, refunded, alreadyInWithdrawal)) {
+ verifiedRecord(orderId, storeId, businessDate.atTime(10, 0));
+ }
service.run(businessDate);
@@ -93,15 +108,15 @@
assertThat(withdrawal).containsEntry("amount_cents", 12000L).containsEntry("status", "pending");
assertThat(sourceOrders(withdrawal)).containsExactlyInAnyOrder(
new SourceOrder(first, 9000L), new SourceOrder(second, 3000L));
- // 未支付、未核销、已退款的订单没有进入提现记录;已在其它提现记录里的保持原状。
- assertThat(lockedOrderIds()).containsExactlyInAnyOrder(first, second, alreadyInWithdrawal)
- .doesNotContain(unpaid, unverified, refunded);
+ // 已进入其它提现记录的订单保持原状,其余四笔都被各自的过滤条件挡在外面。
+ assertThat(lockedOrderIds()).containsExactlyInAnyOrder(first, second)
+ .doesNotContain(unpaid, unverified, deleted, refunded, alreadyInWithdrawal);
}
@Test
void ordersVerifiedAfterTheBusinessDayWaitForTheNextRun() {
LocalDate businessDate = someBusinessDate();
- Long early = verifiedOrderAt(businessDate.minusDays(1).atTime(23, 59, 59), 1500);
+ Long early = verifiedOrderAt(businessDate.minusDays(10).atTime(23, 59, 59), 1500);
Long lastMoment = verifiedOrderAt(businessDate.atTime(23, 59, 59), 2000);
Long afterWindow = verifiedOrderAt(businessDate.plusDays(1).atStartOfDay(), 3000);
@@ -117,6 +132,19 @@
assertThat(sourceOrderIds(businessDate.plusDays(1))).containsExactly(afterWindow);
assertThat(lockedOrderIds()).containsExactlyInAnyOrder(early, lastMoment, afterWindow);
assertThat(allSourceOrderIds()).containsExactlyInAnyOrder(early, lastMoment, afterWindow);
+ }
+
+ @Test
+ void ordersOlderThanTheLookbackWindowAreLeftForManualReview() {
+ LocalDate businessDate = someBusinessDate();
+ Long tooOld = verifiedOrderAt(
+ businessDate.minusDays(AutoWithdrawalAggregationService.MAX_LOOKBACK_DAYS + 1).atTime(10, 0), 9000);
+
+ service.run(businessDate);
+
+ // 首次上线时不会把很久以前的历史订单一次性汇总进来。
+ assertThat(dailyWithdrawalOrNull(businessDate)).isNull();
+ assertThat(lockedOrderIds()).doesNotContain(tooOld);
}
@Test
@@ -137,7 +165,6 @@
assertThat(sourceOrderIds(businessDate.plusDays(1))).contains(small, later);
assertThat(lockedOrderIds()).containsExactlyInAnyOrder(small, later);
}
-
@Test
void anAmountThatWouldNotCoverTheFeeBufferIsDeferred() {
AutoWithdrawalProperties properties = new AutoWithdrawalProperties();
@@ -184,7 +211,7 @@
}
@Test
- void theDatabaseRejectsASecondDailyWithdrawalForOneMerchantAndDay() {
+ void theDatabaseRejectsASecondDailyWithdrawalForOneMerchantStoreAndDay() {
LocalDate businessDate = someBusinessDate();
verifiedOrder(businessDate, 2000);
service.run(businessDate);
@@ -195,7 +222,17 @@
idempotency_key, withdrawal_date)
values (?, ?, ?, ?, 100, ?, ?)
""", merchantId, storeId, "WA-DUP-" + suffix, bankCardId, "auto-withdrawal-duplicate:" + suffix, dateText))
- .isInstanceOf(DataIntegrityViolationException.class);
+ .isInstanceOf(DuplicateKeyException.class);
+
+ // 同一天另一个门店是另一条日汇总记录,不受这条约束影响。
+ Long otherStoreId = insertOnboardedStore("daily-key");
+ jdbc.update("""
+ insert into merchant_withdrawal(merchant_id, store_id, withdrawal_no, bank_card_id, amount_cents,
+ idempotency_key, withdrawal_date)
+ values (?, ?, ?, ?, 100, ?, ?)
+ """, merchantId, otherStoreId, "WA-OTHER-" + suffix, bankCardId, "auto-withdrawal-other:" + suffix, dateText);
+ assertThat(dailyWithdrawalCountForStore(businessDate, storeId)).isEqualTo(1);
+ assertThat(dailyWithdrawalCountForStore(businessDate, otherStoreId)).isEqualTo(1);
}
@Test
@@ -216,8 +253,36 @@
service.run(businessDate);
+ // 卡没有 token 时不能生成记录,否则订单被锁死而记录提交不出去;这一天的金额顺延。
assertThat(dailyWithdrawalOrNull(businessDate)).isNull();
assertThat(lockedOrderIds()).doesNotContain(orderId);
+ }
+
+ @Test
+ void anOnboardingWithoutASubMerchantNumberIsNotAggregated() {
+ jdbc.update("update merchant_onboarding_application set provider_huifu_id = null where merchant_id = ?", merchantId);
+ LocalDate businessDate = someBusinessDate();
+ Long orderId = verifiedOrder(businessDate, 9000);
+
+ service.run(businessDate);
+
+ assertThat(dailyWithdrawalOrNull(businessDate)).isNull();
+ assertThat(lockedOrderIds()).doesNotContain(orderId);
+ }
+
+ @Test
+ void aDedicatedStoreIsAggregatedSeparatelyAndKeepsTheOrdersOfOtherStores() {
+ Long otherStoreId = insertOnboardedStore("other");
+ LocalDate businessDate = someBusinessDate();
+ Long mainOrder = verifiedOrder(businessDate, 2000);
+ Long otherOrder = verifiedOrderForStore(otherStoreId, businessDate, 500);
+
+ service.run(businessDate);
+
+ // 另一个门店只有 500 分,低于最低金额,它的订单不会被主门店的汇总带走。
+ assertThat(dailyWithdrawal(businessDate)).containsEntry("amount_cents", 2000L).containsEntry("store_id", storeId);
+ assertThat(sourceOrderIds(businessDate)).containsExactly(mainOrder);
+ assertThat(lockedOrderIds()).containsExactly(mainOrder).doesNotContain(otherOrder);
}
@Test
@@ -249,27 +314,52 @@
/** 已支付、已核销的订单,核销时间落在业务日 10:00,返回订单 ID。 */
private Long verifiedOrder(LocalDate businessDate, long payAmount) {
- return verifiedOrderAt(businessDate.atTime(10, 0), payAmount);
+ return verifiedOrderForStore(storeId, businessDate, payAmount);
+ }
+
+ private Long verifiedOrderForStore(Long orderStoreId, LocalDate businessDate, long payAmount) {
+ return verifiedOrderAt(orderStoreId, businessDate.atTime(10, 0), payAmount);
}
private Long verifiedOrderAt(LocalDateTime verifyTime, long payAmount) {
- Long orderId = order(1, 1, 0, 0, payAmount, verifyTime.toLocalDate());
- jdbc.update("""
- insert into verify_record(verify_no, order_id, talent_id, merchant_id, store_id, verify_type,
- verify_amount, verify_time, status)
- values (?, ?, ?, ?, ?, 'ORDER_CODE', ?, ?, 1)
- """, "V-AWA-" + suffix + "-" + orderId, orderId, talentId, merchantId, storeId, payAmount, verifyTime);
+ return verifiedOrderAt(storeId, verifyTime, payAmount);
+ }
+
+ private Long verifiedOrderAt(Long orderStoreId, LocalDateTime verifyTime, long payAmount) {
+ Long orderId = order(orderStoreId, 1, 1, 0, 0, payAmount, verifyTime.toLocalDate());
+ verifiedRecord(orderId, orderStoreId, verifyTime);
return orderId;
}
+ private void verifiedRecord(Long orderId, LocalDateTime verifyTime) {
+ verifiedRecord(orderId, storeId, verifyTime);
+ }
+
+ private void verifiedRecord(Long orderId, Long orderStoreId, LocalDateTime verifyTime) {
+ jdbc.update("""
+ insert into verify_record(verify_no, order_id, talent_id, merchant_id, store_id, verify_type,
+ verify_amount, verify_time, status)
+ values (?, ?, ?, ?, ?, 'ORDER_CODE', 0, ?, 1)
+ """, "V-AWA-" + suffix + "-" + orderId, orderId, talentId, merchantId, orderStoreId, verifyTime);
+ }
+
+ /**
+ * 插入一张订单。参数顺序是 {@code order_status, verify_status, deleted, refund_status},
+ * {@code deleted} 是 {@code verify_status = 0}(没有有效核销记录)之外的第二个排除原因。
+ */
private Long order(int orderStatus, int verifyStatus, int deleted, int refundStatus, long payAmount, LocalDate createdAt) {
+ return order(storeId, orderStatus, verifyStatus, deleted, refundStatus, payAmount, createdAt);
+ }
+
+ private Long order(Long orderStoreId, int orderStatus, int verifyStatus, int deleted, int refundStatus,
+ long payAmount, LocalDate createdAt) {
String orderNo = "AWA-" + suffix + "-" + System.nanoTime();
jdbc.update("""
insert into benefit_order(order_no, talent_id, merchant_id, store_id, order_type, order_amount,
subsidy_amount, pay_amount, order_status, verify_status, refund_status,
withdrawal_status, deleted, created_at)
values (?, ?, ?, ?, 'GROUP_BUY', ?, 0, ?, ?, ?, ?, 0, ?, ?)
- """, orderNo, talentId, merchantId, storeId, payAmount, payAmount, orderStatus, verifyStatus,
+ """, orderNo, talentId, merchantId, orderStoreId, payAmount, payAmount, orderStatus, verifyStatus,
refundStatus, deleted, createdAt.atTime(9, 0));
return jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
}
@@ -288,8 +378,14 @@
}
private Integer dailyWithdrawalCount(LocalDate businessDate) {
- return jdbc.queryForObject("select count(*) from merchant_withdrawal where merchant_id = ? and withdrawal_date = ?",
- Integer.class, merchantId, businessDate.format(BUSINESS_DATE));
+ return dailyWithdrawalCountForStore(businessDate, storeId);
+ }
+
+ private Integer dailyWithdrawalCountForStore(LocalDate businessDate, Long withdrawalStoreId) {
+ return jdbc.queryForObject("""
+ select count(*) from merchant_withdrawal
+ where merchant_id = ? and store_id = ? and withdrawal_date = ?
+ """, Integer.class, merchantId, withdrawalStoreId, businessDate.format(BUSINESS_DATE));
}
private List<Long> sourceOrderIds(LocalDate businessDate) {
--
Gitblit v1.7.1