yeg
2026-09-12 50784fc40d98faa46af89a0b2c2e4f22c06d6dcb
fix(merchant): 收紧自动提现汇总的结算配置与门店范围

代码复查发现的四个问题:

- 结算卡和 token 可能来自两张不同的卡:首绑卡审核通过但没有 token 时,
bank_card_id 指向那张没有 token 的卡,提交时直接冲突,而订单已经锁定。
改成先判断首绑卡有没有 token,没有就整张换成该门店最新一张带 token 的
审核通过卡,卡 id 和 token 一定同源。
- 汇总条件没有要求和提交路径一致的结算配置:进件没有 provider_huifu_id 时
生成的记录提交不出去,订单反而被永久锁死。现在要求进件通过、子商户号非空,
缺配置就顺延到下一次任务。
- 窗口原本没有下界,首次上线会把全部历史订单一次性汇总进来。加上 30 天回看
下界(MAX_LOOKBACK_DAYS),窗口内的跳过订单仍然跨天顺延。
- 日汇总粒度与提现记录本来的门店归属不一致,多门店商户会用单个门店的 store_id
汇总全部门店的订单。改成按「商户 + 门店」汇总,唯一约束收紧为
(merchant_id, store_key, withdrawal_date),store_key 是 ifnull(store_id, 0)
生成列,避免 store_id 为 NULL 时唯一约束失效。

另外:候选查询之后状态变化的订单只跳过它自己而不是整笔失败;结果计数移到事务
提交之后;失败日志带上异常信息;AUTO_WITHDRAWAL_ENABLED 默认改为 false,手动
提现入口还在,同时开启会让同一批订单进两条记录。

测试补齐:五笔反例订单各自带有效核销记录,让每个过滤条件本身被测到;新增 30 天
回看下界、没有子商户号的进件、门店隔离三项;数据库唯一约束断言改为
DuplicateKeyException 并验证同一天另一门店不受影响。迁移 V134 新增生成列并把
唯一索引换成新定义(V133 已在共享测试库执行,按只追加规则未修改)。全量 155 项
测试仅剩 docs/agents/testing.md 记录的 3 项基线失败。
1个文件已添加
6个文件已修改
485 ■■■■■ 已修改文件
.scratch/automatic-withdrawal/issues/04-daily-withdrawal-aggregation.md 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/docs/huifu-payment-integration.md 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationService.java 184 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/AutoWithdrawalProperties.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/application.yml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V134__daily_withdrawal_aggregation_store_scope.sql 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalAggregationTests.java 162 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.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。
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
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);
            } catch (RuntimeException exception) {
                // 一个商户汇总失败不影响其它商户;失败的订单保持未锁定,下一次任务会重新汇总。
                // 与另一个实例撞在一起时唯一索引会抛 DuplicateKeyException:那一天的记录由对方生成,
                // 本实例没有出账,属于「已存在」而不是失败。
                if (exception instanceof DuplicateKeyException) {
                aggregate(merchantId, storeId, bankCardId, dateText, windowEnd, windowStart, result);
            } catch (DuplicateKeyException exception) {
                // 与另一个实例的汇总撞在一起:这一天的记录由对方生成,本实例没有出账。
                    result.merchantSkipped();
                    log.info("Auto withdrawal for merchant {} on {} already exists, created by another run",
                            merchantId, dateText);
                } else {
                log.info("Auto withdrawal for merchant {} store {} on {} already exists, created by another run",
                        merchantId, storeId, dateText);
            } catch (RuntimeException exception) {
                    result.merchantFailed();
                    log.warn("Auto withdrawal aggregation failed for merchant {} on {}: {}",
                            merchantId, dateText, exception.getClass().getSimpleName());
                }
                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 }
    }
}
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; }
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 * * *}
talent-card-server/src/main/resources/db/migration/V134__daily_withdrawal_aggregation_store_scope.sql
New file
@@ -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);
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) {