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 项基线失败。
| | |
| | | 已实现,主要落点: |
| | | |
| | | - 新增 `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。 |
| | |
| | | |
| | | `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 |
| | | |
| | |
| | | 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; |
| | |
| | | /** |
| | | * 每日自动提现汇总。 |
| | | * |
| | | * <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 { |
| | |
| | | /** 日汇总和日期边界一律按北京时间计算,与商户看到的「日」一致。 */ |
| | | 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 |
| | | """; |
| | | |
| | |
| | | |
| | | /** 某个业务日的汇总。业务日始终按北京时间解释,因此测试和补跑都可以指定具体日期。 */ |
| | | 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; |
| | |
| | | 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 |
| | |
| | | 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) |
| | |
| | | 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) { |
| | |
| | | |
| | | private record OrderAmount(Long orderId, long amount) { } |
| | | |
| | | /** 一次汇总的结果,用于日志和测试断言。 */ |
| | | /** 一次(商户 + 门店 + 业务日)汇总的结果,用于日志和测试断言。 */ |
| | | public static final class Result { |
| | | |
| | | private final String businessDate; |
| | |
| | | + " 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 } |
| | | } |
| | | } |
| | |
| | | /** 任务是否启用。凌晨的定时任务按北京时间触发,本地开发可以关掉。 */ |
| | | private boolean enabled = true; |
| | | |
| | | /** 每天触发日汇总的时间(北京时间,cron 的 秒 分 时 日 月 周)。 */ |
| | | /** 每天触发日汇总的时间(北京时间,cron 的 秒 分 时 日 月 周)。由 AutoWithdrawalScheduler 的 @Scheduled 从配置读取。 */ |
| | | private String cron = "0 10 0 * * *"; |
| | | |
| | | public long getMinAmountCents() { return minAmountCents; } |
| | |
| | | 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 * * *} |
| New file |
| | |
| | | -- 每日自动提现汇总的第二个迁移: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); |
| | |
| | | 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; |
| | |
| | | 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 |
| | |
| | | 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); |
| | | |
| | |
| | | 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); |
| | | |
| | |
| | | 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 |
| | |
| | | assertThat(sourceOrderIds(businessDate.plusDays(1))).contains(small, later); |
| | | assertThat(lockedOrderIds()).containsExactlyInAnyOrder(small, later); |
| | | } |
| | | |
| | | @Test |
| | | void anAmountThatWouldNotCoverTheFeeBufferIsDeferred() { |
| | | AutoWithdrawalProperties properties = new AutoWithdrawalProperties(); |
| | |
| | | } |
| | | |
| | | @Test |
| | | void theDatabaseRejectsASecondDailyWithdrawalForOneMerchantAndDay() { |
| | | void theDatabaseRejectsASecondDailyWithdrawalForOneMerchantStoreAndDay() { |
| | | LocalDate businessDate = someBusinessDate(); |
| | | verifiedOrder(businessDate, 2000); |
| | | service.run(businessDate); |
| | |
| | | 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 |
| | |
| | | |
| | | 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 |
| | |
| | | |
| | | /** 已支付、已核销的订单,核销时间落在业务日 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); |
| | | } |
| | |
| | | } |
| | | |
| | | 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) { |