yeg
2026-09-12 955ce2066f4e9769821e90c39fe7da94a3bd2365
test(merchant): 补齐自动提现全链路端到端验收

新增 AutoWithdrawalEndToEndAcceptanceTests,用真实服务从 submitOnboarding 一路走到提现
到账:进件与绑卡结果、微信商户配置、商户业务开通回调、收款能力快照、下单固定收款子商户、
支付预下单、验签回调落账、商户核销、次日日汇总、提交主动取现、异步通知与定时查询收敛、
商户端三状态展示。所有斗拱网关用 @MockitoBean 替换,链路零出网调用。

覆盖链路级异常与幂等:重复回调与重复核销不产生第二次业务变更、终态提现不被重复通知和迟到
结果改写、重复任务不重复出账、处理中由查询收敛且等待期内不查询、失败顺延到次日汇总、退款
在途与退款成功都排除出提现范围、已进入提现记录的订单不能再撤销核销或退款、未支付订单不能
核销也不能汇总、验签失败与金额被改动的回调都不履约、能力快照未确认不放行。

进件真实入口的验收暴露并修掉两个既有缺陷:

1. merchant_onboarding_application 缺列:submitOnboarding 建单时写入的注册地址编码、营业
执照有效期和 subject_cert_end_date 从未被任何迁移创建,商户第一次提交进件就以 Unknown
column 失败。补 V137、V138,并同步 MerchantOnboardingSchemaInitializer 的兜底加列。
2. 能力快照刷新自锁:进件审核回调在锁着进件记录的事务里触发快照刷新,而刷新用另一条连接
写同一行,行锁互等到 innodb_lock_wait_timeout 后失败,商户进件通过却永远不能收款。改为
注册 afterCommit 提交后再刷新,并去掉刷新路径上会把调用方行锁撞死的 for update。

另补 docs/automatic-withdrawal-acceptance.md:开关现状(通道开关在 yml 里默认开启且指向
prod,只有自动提现任务默认关闭)、哪些配置项支持环境变量覆盖、沙盒环境、不必等一天的汇总
触发方式、逐条验收清单、带自动化覆盖标注的异常构造表、受控真实极小金额的商户/银行卡/账号
白名单与金额笔数上限登记表,以及必须由沙盒给出结论的 5 项遗留。

工单里「沙盒验证」「提现页面只展示三种状态」「真实金额受白名单与上限约束」三项保持未勾选:
自动化只能覆盖服务端读接口与路由表,沙盒、真机页面和真实到账都还没有执行过。

全量 196 项测试仅剩 docs/agents/testing.md 记录的 3 项基线失败。
4个文件已添加
5个文件已修改
1312 ■■■■■ 已修改文件
.scratch/automatic-withdrawal/issues/06-e2e-acceptance.md 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/docs/automatic-withdrawal-acceptance.md 216 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/docs/huifu-payment-integration.md 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V137__merchant_onboarding_registration_columns.sql 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V138__merchant_onboarding_cert_end_date.sql 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalEndToEndAcceptanceTests.java 899 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/automatic-withdrawal/issues/06-e2e-acceptance.md
@@ -4,11 +4,83 @@
**Blocked by:** 01、02、03、04、05
**Status:** ready-for-agent
**Status:** ready-for-human
- [ ] 沙盒验证进件、能力就绪、下单、支付、回调、核销、次日汇总和提现。
- [ ] 覆盖支付、核销、退款、提现的独立异常测试。
- [ ] 覆盖重复回调、重复核销、重复任务、处理中、失败顺延和退款竞态。
- [x] 覆盖支付、核销、退款、提现的独立异常测试。
- [x] 覆盖重复回调、重复核销、重复任务、处理中、失败顺延和退款竞态。
- [ ] 验证现有提现页面只展示处理中、已到账、提现失败。
- [ ] 沙盒通过后完成指定真实测试商户的极小金额到账验证。
- [ ] 真实验证受商户、银行卡、账号和金额上限约束。
## Comments
自动化的端到端验收已落地;下面逐条说明哪些已被自动化断言、哪些必须人工执行,**只有真正执行过的才勾选**。
### 已落地:链路级与幂等级验收
- 新增 `AutoWithdrawalEndToEndAcceptanceTests`(10 项,MySQL)走**真实服务**而不是造数:
  `MerchantSettlementService.submitOnboarding`(个人进件 → 进件结果 → 微信商户配置 → 商户业务开通回调
  → 进件 approved)→ 能力快照 → `AppOrderPortalService.create` 下单固定收款子商户 →
  `HuifuPaymentService.beginMiniProgramPayment` → 验签回调落账 → `MerchantWorkService.verify` 核销 →
  `AutoWithdrawalAggregationService.run(businessDate)` 日汇总 → `AutoWithdrawalResultService.submitPending`
  提交取现 → 异步通知/定时查询收敛 → 商户端只看到三种状态。所有斗拱网关用 `@MockitoBean` 替换,
  整条链路零出网调用。
- 覆盖的异常与幂等:重复成功回调与重复核销(含换幂等键)都不产生第二次业务变更;终态提现被重复通知
  (含 `msg_type=02` 账务通知)和迟到的相反结果改写时保持终态;重复执行结果处理任务不会用新业务请求号
  重复出账;处理中的提现由定时查询收敛,等待期内不查询;失败提现保存原因、解锁来源订单并在次日汇总里
  连同新订单合成一条新记录;退款在途与退款成功都把订单排除出提现范围;已进入提现记录的订单不能再撤销
  核销或退款(串行化终点);能力快照未确认时不能创建待支付订单。逐环节边界由既有
  `PaymentSettlementTests`、`OrderFundBoundaryTests`、`AutoWithdrawalAggregationTests`、
  `AutoWithdrawalResultProcessingTests`、`MerchantWithdrawalVisibilityTests` 覆盖,验收类只补链路级
  缺口,不重复它们。
- 提现页验收在自动化里做了两件事:一是从 `RequestMappingHandlerMapping` 的路由表断言商户端
  `/api/merchant/withdrawals` 只有 `GET`、后台提现管理只有 `GET`(人工发起与人工审核的入口确实不存在);
  二是让四种库内状态(`pending`/`processing`/`paid`/`failed`)走一遍商户端读接口,断言只出现
  `processing`/`paid`/`failed` 三种,失败记录带原因。
- 进件能力快照与真实入口的验收暴露并修掉了两个既有缺陷(都是「进件通过但商户永远不能收款」级别):
  1. `merchant_onboarding_application` 缺列:`submitOnboarding` 建单时就写入
     `license_validity_type`、`license_begin_date`、`license_end_date`、`registered_province_code`、
     `registered_city_code`、`registered_district_code`、`subject_cert_end_date`,但 V80 建表语句、
     V99/V127/V128 都没有创建它们,商户第一次提交进件就以
     `Unknown column 'license_validity_type' in 'field list'` 失败。补 `V137`(注册地址编码 + 营业执照
     有效期)和 `V138`(证件有效期结束日期),并同步进 `MerchantOnboardingSchemaInitializer` 的兜底加列。
     此前的测试都是用 JDBC 直接造进件记录,绕过了真实入口,所以一直没被发现。
  2. 能力快照刷新自锁:`handleWechatConfigCallback` / `handleBusinessOpenCallback` 在锁着进件记录的
     事务里走到 `completePostOnboardingProvisioning`,而快照刷新用 `REQUIRES_NEW` + 另一条连接写同一行,
     于是行锁互等到 `innodb_lock_wait_timeout`(实测 50 s)后以 `CannotAcquireLockException` 失败,
     快照永远补不上、商户无法收款。修法两处:`MerchantSettlementService` 改为注册
     `TransactionSynchronization.afterCommit` 在事务提交后再刷新;`MerchantPaymentReadinessService`
     的 `requireApprovedApplication` 去掉行锁——provider 查询与写入本来就在
     `PROPAGATION_NOT_SUPPORTED` 的另一条连接里,锁这一行挡不住并发刷新,只会和调用方事务撞锁。
- 手册:`talent-card-server/docs/automatic-withdrawal-acceptance.md`。含开关现状(`app.payment.huifu.enabled`
  在 yml 里是 `true` 且 `environment: prod`,**默认就会调用真实通道**;只有 `AUTO_WITHDRAWAL_ENABLED`
  默认 false)、哪些配置项支持环境变量覆盖、沙盒环境、不必等一天的汇总触发方式、逐条沙盒验收清单
  (含 SQL 观察点)、各环节异常构造表(每行标注是否已被自动化断言)、受控真实极小金额的登记表
  (商户/银行卡/账号白名单 + 单笔、单日、笔数上限 + 执行与审批人),以及必须由本轮验收给出结论的
  5 项遗留(`query-after-minutes` 实测值、最低金额/单日上限/内扣手续费、`into_acct_date_type` 口径、
  沙盒回调字段完整性、`re_exchange=Y` 与 `acct_status=B` 未处理)。
### 未勾选的原因
- 「沙盒验证…」:自动化用桩把整条链路跑通了,但沙盒本身没有执行过,不能算验证完成。
- 「验证现有提现页面…」:自动化只能到服务端读接口与路由表(三种对外状态、无提现写入口),页面本身
  (`rencaika_merchant/pages/withdrawal/withdrawal.uvue` 的模板与状态文案)没有前端测试,也没有真机截图;
  另外商户中心目前**没有任何入口能进入提现页**,需要人工用直达路由核对,并单独决定是否补回入口。
- 「真实验证受商户、银行卡、账号和金额上限约束」:这是对真实执行过程的约束,执行没发生就谈不上已验证。
遗留(需要人工/通道,不在自动化范围内):
- 沙盒全链路与真实极小金额到账尚未执行(`AUTO_WITHDRAWAL_ENABLED` 仍默认 false),按手册执行后勾选
  并把观察值回填到这里。
- 安全前置:`application.yml` 硬编码了真实 `private-key`、`platform-public-key`、系统号/商户号和微信
  AppSecret,这套凭据已进版本库,真实金额验收前必须先轮换并把新值放进部署密钥库(密钥字段目前不支持
  环境变量覆盖,轮换时需要一并改造)。
- `re_exchange=Y`(已退汇)与 `acct_status=B`(回账成功)仍未处理:到账后被退汇不会回到「提现失败」,
  金额也不会再次顺延,需要单独工单。
- 工单 01 遗留的「订单不写 `store_id`」在验收用例里以「创建后把 `store_id` 补成 `collect_store_id`」
  绕过:同日汇总按「商户 + 门店」出账,订单缺 `store_id` 就汇总不到。接入多门店前需要让下单入口
  真正写入 `store_id`。
- 工单 01 遗留的「订单不写 `store_id`」在验收用例里以「创建后把 `store_id` 补成 `collect_store_id`」
  绕过:同日汇总按「商户 + 门店」出账,订单缺 `store_id` 就汇总不到。接入多门店前需要让下单入口
  真正写入 `store_id`。
talent-card-server/docs/automatic-withdrawal-acceptance.md
New file
@@ -0,0 +1,216 @@
# 自动提现沙盒与受控真实金额验收手册
本文件是工单 06「完整链路验收与受控金额验证」的执行手册。自动化部分已经落地并可重复运行
(`AutoWithdrawalEndToEndAcceptanceTests`);需要斗拱沙盒账号、公网回调和真实到账的部分只能由人执行,
按本手册逐条勾选并回填结果。
## 1. 开关现状与前置约束
先看清楚现在的默认值,不要照抄一份「以为默认是关」的心理模型:
| 开关 | 当前默认值 | 作用 |
| --- | --- | --- |
| `app.payment.huifu.enabled` | `application.yml` 里写死 `true` | 斗拱通道总开关;改为 `false` 时支付、退款、提现网关直接抛「Huifu payment is disabled」 |
| `HUIFU_MERCHANT_CAPABILITY_REQUIRED` | `true` | 是否要求已确认的能力快照才能收款;置 `false` 会退化成只校验本地进件与银行卡 |
| `AUTO_WITHDRAWAL_ENABLED` | **`false`** | 自动提现汇总任务开关;只有沙盒全链路通过后才显式打开 |
也就是说**默认状态就会调用真实通道**(`enabled: true` 且 `environment: prod`),沙盒阶段必须显式把
`app.payment.huifu.enabled` 与环境相关配置指向沙盒;只有自动提现任务是默认关闭的。
验收顺序必须是:**先沙盒(测试环境)→ 再真实极小金额**。真实阶段另有四道硬约束,缺一不可:
1. **商户白名单**:只允许指定测试商户的 `huifu_id`。当前**没有**任何脚本或配置实现这道过滤,它是执行
   纪律:先按 `merchant_id` 确认进件与结算配置,再手工触发汇总,不要在生产上打开定时任务。
2. **银行卡白名单**:只允许指定的一张结算卡(按 `merchant_bank_card.id` + `token_no` 双确认)。
3. **账号约束**:只允许指定运营账号在指定环境下执行;密钥只从部署密钥库注入,不落盘、不进日志。
4. **金额上限**:单笔与单日都是固定极小金额,见第 5 节;超过上限的取现请求一律不发起。
> 安全前置:`application.yml` 里当前**硬编码了真实的 `private-key`、`platform-public-key`、系统号/商户号
> 与微信 AppSecret**。这套凭据已经进了版本库,属于必须轮换的暴露面;真实金额验收之前先轮换,并把新值
> 放进部署密钥库(`HUIFU_NOTIFY_URL` 等少数字段已经支持环境变量覆盖,密钥字段目前只读 yml)。
## 2. 沙盒环境配置
`application.yml` 里只有一部分字段支持环境变量覆盖,其余是硬编码值。沙盒阶段要改的是**硬编码的那部分**:
| 配置项 | 环境变量 | 现状 |
| --- | --- | --- |
| `enabled` | — | 硬编码 `true`,沙盒/生产都走真实通道 |
| `environment` | — | 硬编码 `prod`;沙盒要改成 `test` |
| `system-id` / `product-id` / `merchant-id` | — | 硬编码,沙盒需替换为沙盒分配的号 |
| `private-key` / `platform-public-key` | — | 硬编码在 yml,见上一节的安全前置 |
| `withdrawal-product-id` | `HUIFU_WITHDRAWAL_PRODUCT_ID` | 有默认值 `KAZX`,沙盒按取现产品覆盖 |
| `withdrawal-into-account-date-type` | — | 只由 `HuifuPaymentProperties` 的 Java 默认值 `DM` 决定 |
| 六个 `notify-url` | `HUIFU_*_NOTIFY_URL` | 有默认值(内网 IP),沙盒必须覆盖成公网 HTTPS |
| `app.merchant.auto-withdrawal.*` | `AUTO_WITHDRAWAL_*` | 全部支持环境变量覆盖 |
```text
HUIFU_NOTIFY_URL=https://<公网域名>/api/payments/huifu/notify
HUIFU_WITHDRAWAL_NOTIFY_URL=https://<公网域名>/api/payments/huifu/withdrawal-notify
HUIFU_MERCHANT_ONBOARDING_NOTIFY_URL=https://<公网域名>/api/payments/huifu/merchant-onboarding-notify
HUIFU_MERCHANT_WECHAT_CONFIG_NOTIFY_URL=https://<公网域名>/api/payments/huifu/merchant-wechat-config-notify
HUIFU_MERCHANT_BUSINESS_OPEN_NOTIFY_URL=https://<公网域名>/api/payments/huifu/merchant-business-open-notify
HUIFU_MERCHANT_BUSINESS_NOTIFY_URL=https://<公网域名>/api/payments/huifu/merchant-business-notify
HUIFU_WITHDRAWAL_PRODUCT_ID=...
AUTO_WITHDRAWAL_ENABLED=true
AUTO_WITHDRAWAL_MIN_AMOUNT_CENTS=1000
AUTO_WITHDRAWAL_QUERY_AFTER_MINUTES=5
```
六个回调地址都必须是公网可达 HTTPS,并在斗拱控制台登记同一份地址;网关/WAF 不得改写
`resp_data`/`sign`。`AUTO_WITHDRAWAL_MIN_AMOUNT_CENTS` 填的是**分**,调用斗拱时转成两位小数的元。
## 3. 复现「凌晨汇总」而不等一天
日汇总按北京时间的前一自然日执行。手工验收有两条等价路径:
- 直接触发任务(无需等待):`AutoWithdrawalAggregationService.run(businessDate)` 支持指定业务日,
  补跑脚本或后台一次性检查都可以用它。
- 用定时任务:把 `AUTO_WITHDRAWAL_CRON` 临时改成 `0 */5 * * * *`(每 5 分钟),任务只会汇总
  「核销时间在业务日窗口内」的订单,重复执行不会重复出账。
窗口规则:`核销时间 < 业务日 24:00` 且 `>= 业务日 - 30 天`。超窗口的订单留给人工。
## 4. 沙盒验收清单
每条都要记录「实际观察到的值」,不要只打勾。SQL 都按本次验收创建的单号过滤。
### 4.1 主链路(进件 → 到账)
1. **进件**:商户端提交进件(个人/企业),观察 `merchant_onboarding_application`:
   `status='pending'`,`provider_huifu_id` 非空,`initial_bank_card_id` 指向同一笔银行卡。
   - 企业进件额外确认 `business_license_url`、`legal_id_front_url/back_url`、`registered_capital` 已落库。
2. **绑卡与 token**:`merchant_bank_card.status='approved'` 且 `provider_token_no` 非空。
   若通道只回 `apply_no` 未回 `token_no`,卡片保持 `pending`,等业务开通回调补齐。
3. **进件通过**:微信商户配置与商户业务开通都变为 `approved` 后,进件才变 `approved`。
   确认此时 `capability_verified_at` **已被填充**(`merchant_stat`/`auto_settle_status`/`cash_switch_status`
   三个快照字段非空)。若为空,说明能力快照刷新失败,先查服务日志里的
   `Merchant capability refresh failed`,此时商户无法收款。
4. **能力就绪**:`GET /api/admin/management/merchant-settlement/onboardings/{id}/payment-capability`
   返回 `merchantStatus=NORMAL`、`autoSettleDisabled=true`、`cashAvailable=true`。
5. **下单**:人才端下待支付订单,确认 `benefit_order.collect_onboarding_id / collect_store_id /
   collect_huifu_id` 与本次进件一致;商品零元单不绑定收款方。
6. **支付**:发起支付,确认请求里的 `huifu_id` 是订单上的 `collect_huifu_id`(不是平台商户号),
   客户端能拉起微信支付。
7. **回调**:在斗拱沙盒完成支付。**重点确认沙盒回调真的下发 `huifu_id` 和 `trans_amt`**——
   缺任何一个,订单都会保持未支付并落 `payment_reconciliation_issue`,需要靠补查或人工处理。
   成功后:`payment_transaction.status='SUCCEEDED'`、`payment_callback_event.signature_valid=1`、
   `benefit_order.order_status=1`、`verify_status` 仍为 0(核销是商户的动作)。
8. **核销**:商户端核销,确认 `verify_record.status=1`、`benefit_order.verify_status=1`,
   且同一订单不可能有第二条有效核销记录(`uk_verify_record_active_order`)。
9. **次日汇总**:跑一次日汇总,确认 `merchant_withdrawal.status='pending'`、`amount_cents` 等于
   来源订单 `pay_amount` 之和、`withdrawal_date` 是业务日、`merchant_withdrawal_order` 有对应关联,
   且订单 `withdrawal_status=1`。
10. **提交取现**:任务提交后确认本地状态 `processing`,并记下 `withdrawal_no`(= 发给斗拱的
    `req_seq_id`)、`provider_request_date`、`provider_huifu_id`。
    - 幂等:重复跑任务,斗拱侧不能出现第二笔取现;若通道回 `20000000 重复交易`,本地**必须保持
      处理中**,绝不能判失败。
11. **结果收敛**:等异步通知 `msg_type=01`(通道)和 `msg_type=02`(账务)都到达;或者等
    `AUTO_WITHDRAWAL_QUERY_AFTER_MINUTES` 之后由查询收敛。终态 `paid` 时
    `provider_trade_no` 非空、`paid_at` 非空。
12. **商户端展示**:`GET /api/merchant/withdrawals` 只返回 `processing`/`paid`/`failed` 三种状态,
    金额与银行卡掩码正确;重复通知不改写终态。
### 4.2 各环节独立异常
「自动化」一列说明该行是否已被 `AutoWithdrawalEndToEndAcceptanceTests` 或既有测试类断言;
只有沙盒/人工才能构造的行必须实际执行并记录观察值。
| 环节 | 构造方式 | 期望 | 自动化 |
| --- | --- | --- | --- |
| 支付 | 重复发送同一成功回调 | 状态与金额不变,只更新回调审计时间 | 是(`PaymentSettlementTests`) |
| 支付 | 回调金额改一位小数 | 订单保持未支付,落 `PAYMENT_AMOUNT_MISMATCH` | 是(验收类 + `PaymentSettlementTests`) |
| 支付 | 回调用别的 `huifu_id` | 订单保持未支付,落 `PAYMENT_MERCHANT_MISMATCH` | 是(`PaymentSettlementTests`) |
| 支付 | 成功回调但不带 `huifu_id` | 订单保持未支付,落 `PAYMENT_MERCHANT_UNVERIFIED` | 是(`PaymentSettlementTests`) |
| 支付 | 回调被延迟/拦截,超过阈值后补查 | 补查成功即落账;仍未终态则落 `PAYMENT_PENDING_TIMEOUT` | 是(`PaymentSettlementTests`) |
| 支付 | 回调签名错误 | 直接拒绝,不写审计行、不落账,不暴露报文与密钥 | 是(验收类;真实验签由 `HuifuSignatureServiceTests` 覆盖) |
| 核销 | 未支付订单核销 | 拒绝 | 是(验收类) |
| 核销 | 同幂等键重复核销 | 返回首次结果,只有一条有效核销记录 | 是(验收类) |
| 核销 | 换幂等键重复核销 | 拒绝(订单已核销) | 是(验收类) |
| 退款 | 已支付未核销订单退款 | 受理后 `order_refund.refund_status=2`、订单 `refund_status=2` | 是(验收类) |
| 退款 | 退款在途时核销 / 汇总 | 都被拒;该订单当天不进提现记录 | 是(验收类) |
| 退款 | 通道宣告退款失败后核销 | 订单回到可核销状态 | 是(`OrderFundBoundaryTests`) |
| 退款 | 退款成功后补发失败通知 | 不重新打开已退款订单 | 是(`OrderFundBoundaryTests`) |
| 提现 | 提交超时/读不出结果 | 保持 `processing`,用同一 `req_seq_id` 补查,**不新建记录** | 是(`AutoWithdrawalResultProcessingTests`) |
| 提现 | 查询回 `23000001 交易不存在` | 超过等待期后按失败顺延 | 是(`AutoWithdrawalResultProcessingTests`) |
| 提现 | 提交回 `22000004 暂未开通取现权限` | 失败并保存原因,来源订单解锁 | 是(验收类) |
| 提现 | 回 `23000003 取现金额不足以支付内扣手续费` | 失败顺延;据此校准 `AUTO_WITHDRAWAL_FEE_BUFFER_CENTS` | **否:只能沙盒构造**(本地把它当普通失败处理,映射逻辑已被 `22000004` 覆盖) |
| 提现 | 失败后次日重新汇总 | 同一订单连同新订单合并成**一条**新记录 | 是(验收类) |
| 提现 | 同一订单累计 3 次失败 | 不再自动解锁,金额留在失败记录,后台「提现失败待处理」可见 | 是(`AutoWithdrawalResultProcessingTests`) |
| 提现 | 后台尝试改状态 / 商户尝试发起提现 | 路由不存在,请求被拒 | 是(验收类查路由表;真实 HTTP 拒绝需人工确认) |
| 提现 | 重复通知 / 迟到的相反结果 | 终态不变,矛盾结果只写 WARN 日志供人工核对 | 是(验收类 + `AutoWithdrawalResultProcessingTests`) |
### 4.3 覆盖这些场景的自动化用例
```powershell
cd talent-card-server
.\scripts\run-server-tests.ps1 -Test AutoWithdrawalEndToEndAcceptanceTests
```
`AutoWithdrawalEndToEndAcceptanceTests` 用真实服务(`submitOnboarding` → 业务开通回调 →
能力快照 → 下单 → 支付 → 验签回调 → 核销 → 日汇总 → 提交取现 → 通知/查询)跑完整条链路,
所有斗拱网关都用 `@MockitoBean` 替换,零出网调用。上表里自动化已覆盖:无效验签回调被直接拒绝、
金额被改动的回调不履约并落 `PAYMENT_AMOUNT_MISMATCH`、未支付订单不能核销也不能汇总、
`22000004` 按失败处理并展示原因、`23000001` 按失败顺延、处理中不被过早查询、终态不被改写。
自动化**没有**覆盖、必须靠沙盒或人工确认的:通道是否真的下发 `huifu_id`/`trans_amt`/`fee_amt`/
`acct_status`、`23000003`(内扣手续费吃掉本金)的真实触发与所需缓冲、各错误码的真实取值、
`re_exchange=Y` 与 `acct_status=B` 的当前行为。逐环节的其余边界由
`PaymentSettlementTests`、`OrderFundBoundaryTests`、`AutoWithdrawalAggregationTests`、
`AutoWithdrawalResultProcessingTests`、`MerchantWithdrawalVisibilityTests` 覆盖。
## 5. 受控真实极小金额到账验证
沙盒全链路通过后才执行本节,并且必须先登记再执行。
**登记(执行前填好,缺一项不执行)**
| 项 | 值 |
| --- | --- |
| 环境 | 生产 / 真实通道(不是 test) |
| 测试商户 `merchant_id` | ____ |
| 测试商户 `huifu_id` | ____ |
| 结算银行卡 `merchant_bank_card.id` | ____ |
| 卡 `token_no` | ____ |
| 到账账号(脱敏) | ____ |
| 单笔金额上限 | ≤ __ 元(建议 0.01~1.00 元) |
| 单日累计上限 | ≤ __ 元 |
| 单日取现笔数上限 | ≤ __ 笔 |
| 执行人 / 审批人 | ____ / ____ |
**执行步骤**
1. 只放行上述白名单:`merchant_id`、银行卡、账号三者的组合必须完全匹配,任一不符立即终止。
2. 关掉真实环境的定时任务(`AUTO_WITHDRAWAL_ENABLED=false`),改为手工按业务日触发一次汇总,
   避免任务选中白名单之外的商户。
3. 用最小金额造一笔已支付已核销订单(金额取「最低提现金额」临界值,优先验证
   `AUTO_WITHDRAWAL_MIN_AMOUNT_CENTS` 是否可用;被 `23000003` 拒绝就说明内扣手续费吃掉了本金,
   据此调整手续费缓冲)。
4. 触发一次汇总并提交取现,记录 `withdrawal_no`、`cash_amt`(元)、`into_acct_date_type`。
5. 确认 `processing` → 等通知/查询 → `paid`。
6. 在银行侧核对**真实到账**:金额 = `cash_amt` 减去内扣手续费,到账日符合到账类型。
   记录银行流水号与到账时间。
7. 汇总当天的取现笔数与累计金额,确认没有超过登记的上限。
8. 收尾:恢复定时任务开关(或保持关闭直到正式上线决策),把结果回填到工单 06。
**失败即停**:出现金额超过上限、到账目标与白名单不符、同一 `req_seq_id` 被提交两次、
或 `paid` 之后金额又被顺延,立即停止并保持 `AUTO_WITHDRAWAL_ENABLED=false`。
## 6. 需要沙盒/真实阶段确认后才敢打开的遗留项
这些是代码里明确留白、必须由本轮验收给出结论的点:
1. **`AUTO_WITHDRAWAL_QUERY_AFTER_MINUTES` 的取值**:等待期是「通道还没落库」与「通道从未受理」
   之间唯一的区分手段。要实测一笔已受理的取现在多久之后可以被查到;如果沙盒里 5 分钟不够,
   失败会被误判并解锁订单,必须调大。
2. **最低取现金额、单笔与单日上限、内扣手续费**由斗拱与银行按商户配置,需要在沙盒/真实环境
   确认实际取值,才能定 `AUTO_WITHDRAWAL_MIN_AMOUNT_CENTS` 与
   `AUTO_WITHDRAWAL_FEE_BUFFER_CENTS`。
3. **`into_acct_date_type` 的到账口径**:文档给的是 `D0`/`T1`/`D1`/`DM`,要以目标产品与沙盒
   实际返回为准;当前默认 `DM`。
4. **沙盒回调字段完整性**:成功回调是否一定带 `huifu_id`、`trans_amt`;取现回调是否一定带
   `trans_status`、`fee_amt`、`acct_status`。
5. **未处理的通道语义(代码尚未实现,需要单独工单)**:
   - 取现回调的 `re_exchange=Y`(已退汇):到账后被退汇目前不会回到「提现失败」,金额也不会再次顺延。
   - `acct_status=B`(回账成功):同上。
   这两项在沙盒里就能构造,验收时要明确记录当前行为(钱回来了但本地仍是 `paid`)。
talent-card-server/docs/huifu-payment-integration.md
@@ -206,6 +206,13 @@
guard, the 30-day lookback, the approved settlement card, a merchant store with no dispatchable
configuration, and per-store isolation.
`AutoWithdrawalEndToEndAcceptanceTests` is the chain-level acceptance for ticket 06: it drives the
real services from `submitOnboarding` through sub-merchant approval, the capability snapshot, order
creation, miniprogram payment, the signed callback, merchant verification, daily aggregation, the
encashment submission and the notification/query convergence, with every Huifu gateway stubbed. The
sandbox and controlled real-amount procedure, the whitelist and amount-cap rules, and the items that
still need provider confirmation are in `docs/automatic-withdrawal-acceptance.md`.
## Reconciliation and exception handling
The server records verified callback audit events, then uses the provider query API as a second confirmation path for payments and refunds that remain pending. It never converts an order to paid from a client request or a local timer result alone.
@@ -273,6 +280,23 @@
merchant so one failing merchant never affects the others. Administrators can force a refresh from
the management console.
When onboarding completes, the snapshot refresh is registered as an `afterCommit` synchronization.
The onboarding and business-open callbacks reach that point while their own transaction still holds
the application row, and the snapshot ends up written from a second connection (`REQUIRES_NEW` for
the read, `PROPAGATION_NOT_SUPPORTED` for the provider query and the update), so refreshing inside the
transaction waited for its own row lock and failed with `CannotAcquireLockException` after
`innodb_lock_wait_timeout` instead of filling the snapshot — an approved merchant that can never
collect money. For the same reason the refresh path reads the application without `for update`:
locking that row cannot serialize a refresh that happens on another connection, it only collides with
the caller's lock.
Migrations `V137` and `V138` add the onboarding columns the submission path writes but the earlier
migrations never created (`registered_province_code`/`registered_city_code`/
`registered_district_code`, `license_validity_type`/`license_begin_date`/`license_end_date`,
`subject_cert_end_date`); without them the very first onboarding submission failed with
`Unknown column ... in 'field list'`. `MerchantOnboardingSchemaInitializer` adds the same columns for
deployments that run without Flyway.
### Management endpoints
- `GET /api/admin/management/merchant-settlement/onboardings/{id}/payment-capability`
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
@@ -32,6 +32,17 @@
            columns.put("legal_id_back_url", "varchar(512) default null");
            columns.put("legal_certificate_address", "varchar(255) default null");
            columns.put("administrator_email", "varchar(128) default null");
            // 进件入口在插入申请时就写这些列(企业进件还要写入营业执照有效期);历史迁移漏建了它们,
            // 缺少时商户第一次提交进件就会以 SQL 语法错误失败,见 V137/V138。
            columns.put("registered_province_code", "varchar(6) default null");
            columns.put("registered_city_code", "varchar(6) default null");
            columns.put("registered_district_code", "varchar(6) default null");
            columns.put("license_validity_type", "varchar(8) default null");
            columns.put("license_begin_date", "char(8) default null");
            columns.put("license_end_date", "char(8) default null");
            columns.put("subject_cert_validity_type", "varchar(8) default null");
            columns.put("subject_cert_begin_date", "char(8) default null");
            columns.put("subject_cert_end_date", "char(8) default null");
            columns.put("initial_bank_card_id", "bigint default null");
            columns.put("provider_huifu_id", "varchar(32) default null");
            columns.put("provider_apply_no", "varchar(32) default null");
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java
@@ -220,8 +220,16 @@
        return rows.isEmpty() ? null : application(rows.get(0));
    }
    /**
     * 读取一条已通过的进件。这里刻意不加 {@code for update}:能力快照的 provider 查询和写入都在
     * {@code providerTransaction}({@code PROPAGATION_NOT_SUPPORTED},另一条连接)里完成,锁住这一行
     * 挡不住并发刷新(快照本来就是后写覆盖先写),却会和调用方事务已经持有的行锁撞在一起。进件审核的
     * 微信配置回调和业务开通回调都是在锁着这条进件记录的事务里走到这里的,行锁加在这里会让快照写入一直
     * 等到 {@code innodb_lock_wait_timeout} 后以 {@code CannotAcquireLockException} 失败,商户进件通过
     * 却永远无法收款。
     */
    private Application requireApprovedApplication(Long applicationId) {
        List<Map<String, Object>> rows = rows("select * from merchant_onboarding_application where id = ? limit 1 for update", applicationId);
        List<Map<String, Object>> rows = rows("select * from merchant_onboarding_application where id = ? limit 1", applicationId);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户进件不存在");
        Application application = application(rows.get(0));
        if (!"approved".equals(application.status())) throw new BusinessException(ErrorCode.CONFLICT, "商户进件尚未审核通过,无法确认收款能力");
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -31,6 +31,8 @@
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@Service
public class MerchantSettlementService {
@@ -469,10 +471,31 @@
        int completed = jdbc.update("update merchant_onboarding_application set status='approved', reject_reason=null, reviewed_at=now() where id=? and wechat_config_status='approved' and business_open_status='approved'", applicationId);
        if (completed > 0 && applicationId instanceof Number number) {
            // Approved sub-merchants need a confirmed settlement/encashment snapshot before they can collect money.
            paymentReadinessService.refreshQuietly(number.longValue());
            refreshCapabilityAfterCommit(number.longValue());
        }
    }
    /**
     * 进件审核通过后补一次收款能力快照。
     *
     * <p>必须在当前事务提交之后再刷新:能力快照用 {@code REQUIRES_NEW} 在独立事务里写,而微信商户配置
     * 回调和商户业务开通回调都是先 {@code select ... for update} 锁住这条进件记录再走到这里。在事务内
     * 刷新等于让新事务去抢本事务已经持有的同一行锁,会一直等到 {@code innodb_lock_wait_timeout} 然后以
     * {@code CannotAcquireLockException} 失败,快照永远补不上,商户虽然进件通过却始终无法收款。
     */
    private void refreshCapabilityAfterCommit(Long applicationId) {
        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            paymentReadinessService.refreshQuietly(applicationId);
            return;
        }
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                paymentReadinessService.refreshQuietly(applicationId);
            }
        });
    }
    @SuppressWarnings("unchecked")
    private Map<String, Object> callbackData(Map<String, Object> callback) {
        Object nested = callback.get("data");
talent-card-server/src/main/resources/db/migration/V137__merchant_onboarding_registration_columns.sql
New file
@@ -0,0 +1,30 @@
-- 补齐进件进件资料的注册地址编码与证件有效期列。
--
-- MerchantSettlementService.submitOnboarding 在插入进件申请时就写入这些列(企业进件还要写入营业执照
-- 有效期),但 V80 建表语句、V99/V127/V128 的历史迁移都没有创建它们,只留下了 registered_region 和
-- registered_address 两个自由文本列。结果是商户第一次提交进件就会以
-- `Unknown column 'license_validity_type' in 'field list'` 失败,工单 06 的端到端验收才把它暴露出来
-- (此前的测试都是用 JDBC 直接造进件记录,绕过了真实入口)。
--
-- 每个列都按 INFORMATION_SCHEMA 存在性判断后再加,因此在已经手工补过列的环境里可以安全重跑。
DELIMITER $$
DROP PROCEDURE IF EXISTS add_merchant_onboarding_registration_columns$$
CREATE PROCEDURE add_merchant_onboarding_registration_columns()
BEGIN
    DECLARE col_exists INT DEFAULT 0;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'registered_province_code';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN registered_province_code VARCHAR(6) DEFAULT NULL AFTER registered_region; END IF;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'registered_city_code';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN registered_city_code VARCHAR(6) DEFAULT NULL AFTER registered_province_code; END IF;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'registered_district_code';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN registered_district_code VARCHAR(6) DEFAULT NULL AFTER registered_city_code; END IF;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'license_validity_type';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN license_validity_type VARCHAR(8) DEFAULT NULL AFTER credit_code; END IF;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'license_begin_date';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN license_begin_date CHAR(8) DEFAULT NULL AFTER license_validity_type; END IF;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'license_end_date';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN license_end_date CHAR(8) DEFAULT NULL AFTER license_begin_date; END IF;
END$$
DELIMITER ;
CALL add_merchant_onboarding_registration_columns();
DROP PROCEDURE IF EXISTS add_merchant_onboarding_registration_columns;
talent-card-server/src/main/resources/db/migration/V138__merchant_onboarding_cert_end_date.sql
New file
@@ -0,0 +1,19 @@
-- 补齐进件负责人/法人证件有效期结束日期列。
--
-- MerchantSettlementService.submitOnboarding 同时写入证件有效期的开始与结束日期,但 V99 当年只建了
-- subject_cert_validity_type 和 subject_cert_begin_date,漏掉了 subject_cert_end_date;V137 补齐注册
-- 地址编码与营业执照有效期时也漏了这一列。缺少时商户第一次提交进件就会以
-- `Unknown column 'subject_cert_end_date' in 'field list'` 失败。
--
-- 按 INFORMATION_SCHEMA 存在性判断后再加,环境里手工补过列时同样可以安全重跑。
DELIMITER $$
DROP PROCEDURE IF EXISTS add_merchant_onboarding_cert_end_date$$
CREATE PROCEDURE add_merchant_onboarding_cert_end_date()
BEGIN
    DECLARE col_exists INT DEFAULT 0;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'subject_cert_end_date';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN subject_cert_end_date CHAR(8) DEFAULT NULL AFTER subject_cert_begin_date; END IF;
END$$
DELIMITER ;
CALL add_merchant_onboarding_cert_end_date();
DROP PROCEDURE IF EXISTS add_merchant_onboarding_cert_end_date;
talent-card-server/src/test/java/com/talentcard/server/business/merchant/AutoWithdrawalEndToEndAcceptanceTests.java
New file
@@ -0,0 +1,899 @@
package com.talentcard.server.business.merchant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.AutoWithdrawalResultService.Application;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.business.order.OrderRefundRequest;
import com.talentcard.server.business.payment.HuifuPaymentCallbackService;
import com.talentcard.server.business.payment.HuifuPaymentService;
import com.talentcard.server.business.payment.HuifuRefundService;
import com.talentcard.server.business.payment.huifu.HuifuIndividualOnboardingGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentCommand;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
import com.talentcard.server.business.payment.huifu.HuifuPictureUploadGateway;
import com.talentcard.server.business.payment.huifu.HuifuRefundGateway;
import com.talentcard.server.business.payment.huifu.HuifuSignatureService;
import com.talentcard.server.business.payment.huifu.HuifuWithdrawalCommand;
import com.talentcard.server.business.payment.huifu.HuifuWithdrawalGateway;
import com.talentcard.server.business.payment.huifu.HuifuWithdrawalQueryCommand;
import com.talentcard.server.business.portal.AppOrderCreateRequest;
import com.talentcard.server.business.portal.AppOrderPortalService;
import com.talentcard.server.common.BusinessException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
/**
 * 完整链路验收 seam(工单 06):从真实进件入口一路走到提现到账,全部用生产服务而不是直接写库造数。
 *
 * <p>链路:{@code submitOnboarding} → 斗拱进件与绑卡结果 → 微信商户配置 → 商户业务开通回调 →
 * 收款能力快照 → 下单固定收款子商户 → 支付预下单 → 验签回调落账 → 商户核销 → 次日凌晨日汇总 →
 * 提交主动取现 → 异步通知/主动查询收敛 → 商户端只看到三种状态。
 *
 * <p>共享测试库不清空,也不回滚,因此:断言只看本用例自己创建的商户/订单/提现记录;业务日取一个
 * 足够远的未来日期,避免别的用例留下的记录落进同一个汇总窗口;后台任务在测试 profile 里是关闭的,
 * 用例自己直接调用汇总和结果处理服务。
 *
 * <p>这里用 {@code @MockitoBean} 替换所有斗拱网关,所以整条链路零出网调用。真实沙盒和受控真实小额
 * 到账验证的步骤、白名单与金额上限约束见 {@code docs/automatic-withdrawal-acceptance.md}。
 */
@SpringBootTest
@ActiveProfiles("test")
@TestPropertySource(properties = {
        // 打开通道与能力校验,才能走到「能力就绪才允许支付」的真实闸口。
        "app.payment.huifu.enabled=true",
        "app.payment.huifu.merchant-capability-required=true",
        // 关掉两个定时任务:它们会扫描全表并改动别的用例正在断言的快照。
        "app.payment.huifu.capability-refresh.enabled=false",
        "app.merchant.auto-withdrawal.result-check-enabled=false"
})
class AutoWithdrawalEndToEndAcceptanceTests {
    /** 未来业务日的随机起点 + 每次调用递增,保证同一次运行内每个用例的业务日互不相同。 */
    private static final AtomicLong BUSINESS_DATE_OFFSET =
            new AtomicLong(30_000 + ThreadLocalRandom.current().nextLong(1_000_000));
    /** 单人单笔的极小金额:既高于默认最低提现金额 1000 分,又便于人工核对到账金额。 */
    private static final long PAY_AMOUNT_CENTS = 1200;
    private static final String BRANCH_CODE = "102100099996";
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private MerchantSettlementService settlementService;
    @Autowired
    private MerchantPaymentReadinessService readinessService;
    @Autowired
    private AppOrderPortalService appOrderPortalService;
    @Autowired
    private HuifuPaymentService paymentService;
    @Autowired
    private HuifuPaymentCallbackService callbackService;
    @Autowired
    private HuifuRefundService refundService;
    @Autowired
    private MerchantWorkService merchantWorkService;
    @Autowired
    private AutoWithdrawalAggregationService aggregation;
    @Autowired
    private AutoWithdrawalResultService withdrawalResultService;
    /** 路由表真源:用来证明商户端/后台都没有提现写入口(提现状态只能由通道收敛)。 */
    @Autowired
    @Qualifier("requestMappingHandlerMapping")
    private RequestMappingHandlerMapping handlerMapping;
    @MockitoBean
    private HuifuSignatureService signatureService;
    @MockitoBean
    private HuifuPictureUploadGateway pictureUploadGateway;
    @MockitoBean
    private HuifuIndividualOnboardingGateway individualOnboardingGateway;
    @MockitoBean
    private HuifuMerchantWechatConfigGateway merchantWechatConfigGateway;
    @MockitoBean
    private HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway;
    @MockitoBean
    private HuifuMerchantCapabilityGateway capabilityGateway;
    @MockitoBean
    private HuifuMiniProgramPaymentGateway paymentGateway;
    @MockitoBean
    private HuifuRefundGateway refundGateway;
    @MockitoBean
    private HuifuWithdrawalGateway withdrawalGateway;
    private Long merchantId;
    private Long storeId;
    private Long talentId;
    private Long productId;
    private Long onboardingId;
    private String huifuId;
    private String suffix;
    private MerchantPrincipal principal;
    @BeforeEach
    void prepareMerchantAndProviderAnswers() {
        suffix = String.valueOf(System.nanoTime());
        // 测试库的网点目录是空的(测试 profile 关掉了 10 万行导入),进件要求开户行真实存在。
        jdbc.update("insert ignore into bank_branch_directory(clearing_code, branch_name, bank_name, head_bank_code) "
                + "values (?, '中国建设银行攀枝花东区支行营业部', '中国建设银行', '105100000017')", BRANCH_CODE);
        jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
                "acceptance-openid-" + suffix, "验收用例用户", "13900000006");
        talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
        jdbc.update("insert into talent_card(talent_id, card_no, card_type, card_level, status, deleted) "
                + "values (?, ?, 'PERSONAL', ?, 1, 0)", talentId, "TC-ACC-" + suffix, "卓越人才卡");
        jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) "
                + "values (?, ?, ?, 1, 0)", "Acceptance merchant " + suffix, "08120000006", "攀枝花市东区验收路 1 号");
        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, "Acceptance store " + suffix);
        storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
        jdbc.update("""
                insert into merchant_product(product_no, merchant_id, product_type, product_name, original_price,
                                             sale_price, subsidy_amount, stock, audit_status, status, deleted)
                values (?, ?, 'food', '验收权益商品', 20.00, 12.00, 0.00, 10, 2, 1, 0)
                """, "ACCEPTANCE-PRODUCT-" + suffix, merchantId);
        productId = jdbc.queryForObject("select max(id) from merchant_product", Long.class);
        principal = new MerchantPrincipal(9901L, merchantId, storeId, "operator");
        when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(true);
        when(pictureUploadGateway.uploadUrl(any(), any())).thenReturn("HP-" + suffix);
        // 通道答复:能力就绪(自动结算已关闭 + 取现可用 + 子商户正常)。
        when(capabilityGateway.query(any())).thenReturn(new HuifuMerchantCapabilityGateway.CapabilityResult(
                true, "00000000", "成功", "NORMAL", "0", "D1", "1", "DM"));
        when(individualOnboardingGateway.create(any())).thenReturn(
                new HuifuIndividualOnboardingGateway.Result(true, shortReference("H", suffix), "MO-APPLY-" + suffix,
                        shortReference("T", suffix), "00000000", "成功"));
        when(merchantWechatConfigGateway.configure(any(), any())).thenReturn(
                new HuifuMerchantWechatConfigGateway.Result(true, "00000000", "成功"));
        when(merchantBusinessOpenGateway.open(any(), any(), any(), any())).thenReturn(
                new HuifuMerchantBusinessOpenGateway.Result(true, "BO-APPLY-" + suffix, "00000000", "成功"));
        // 两个任务都会扫描全表,所以两个窗口都要收拾干净:把别的用例留下的记录移出任务窗口
        // (而不是删掉它们),这样断言和 verify 只针对本用例自己创建的记录。
        jdbc.update("""
                update merchant_withdrawal set withdrawal_date = null
                where status = 'pending' and withdrawal_date is not null and merchant_id <> ?
                """, merchantId);
        jdbc.update("""
                update merchant_withdrawal set provider_request_date = null
                where status = 'processing' and provider_request_date is not null and merchant_id <> ?
                """, merchantId);
    }
    @Test
    void theWholeChainTakesAMerchantFromOnboardingToAPaidWithdrawal() {
        onboardIndividualMerchant();
        assertThat(applicationRow())
                .containsEntry("status", "approved")
                .containsEntry("wechat_config_status", "approved")
                .containsEntry("business_open_status", "approved")
                .containsEntry("provider_huifu_id", huifuId);
        assertThat(cardRow())
                .containsEntry("status", "approved")
                .containsEntry("provider_token_no", shortReference("T", suffix));
        // 进件通过 → 能力快照刷新 → 商户能收款,这是工单 01 的完整判定链。
        assertThat(onboardingStatus()).isEqualTo("approved");
        assertThat(capabilityRow())
                .containsEntry("merchant_stat", "NORMAL")
                .containsEntry("auto_settle_status", "0")
                .containsEntry("cash_switch_status", "1");
        assertThat(readinessService.readiness(merchantId, storeId).reason()).isEmpty();
        assertThat(readinessService.readiness(merchantId, storeId).ready()).isTrue();
        assertThat(readinessService.requireCollectingMerchant(merchantId, storeId).huifuId()).isEqualTo(huifuId);
        String orderNo = createPayableOrder();
        assertThat(orderRow(orderNo))
                .containsEntry("collect_onboarding_id", onboardingId)
                .containsEntry("collect_huifu_id", huifuId);
        assertThat(orderRow(orderNo).get("collect_store_id")).isEqualTo(storeId);
        // 支付预下单只使用订单固定的收款子商户。
        Map<String, Object> payment = paymentService.beginMiniProgramPayment(
                orderNo, talentId, 1, "acceptance-payment-" + suffix);
        String paymentNo = String.valueOf(payment.get("paymentNo"));
        ArgumentCaptor<HuifuMiniProgramPaymentCommand> paymentCommand =
                ArgumentCaptor.forClass(HuifuMiniProgramPaymentCommand.class);
        verify(paymentGateway).createMiniProgramPayment(paymentCommand.capture());
        assertThat(paymentCommand.getValue().huifuId()).isEqualTo(huifuId);
        assertThat(payment.get("paymentStatus")).isEqualTo("PAYMENT_PENDING");
        // 验签回调落账:支付流水成功、订单已支付(未核销,核销是商户的动作)。
        assertThat(callbackService.handle(paymentCallback(paymentNo, amountYuan(PAY_AMOUNT_CENTS)), "signature"))
                .isEqualTo("RECV_ORD_ID_" + paymentNo);
        assertThat(paymentRow(paymentNo))
                .containsEntry("status", "SUCCEEDED")
                .containsEntry("provider_huifu_id", huifuId);
        assertThat(orderRow(orderNo))
                .containsEntry("order_status", 1)
                .containsEntry("verify_status", 0)
                .containsEntry("withdrawal_status", 0);
        // 商户核销,并把这笔核销挪到业务日窗口内——验收要的是「次日汇总」,不是等一天。
        LocalDate businessDate = someBusinessDate();
        merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "acceptance"), verifyKey(orderNo));
        Long orderId = orderId(orderNo);
        verifiedOn(orderId, businessDate);
        assertThat(aggregation.run(businessDate).merchantsAggregated()).isPositive();
        Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
        assertThat(withdrawal.amountCents()).isEqualTo(PAY_AMOUNT_CENTS);
        assertThat(sourceOrderIds(withdrawal.id())).containsExactly(orderId);
        assertThat(orderRow(orderNo).get("withdrawal_status")).isEqualTo(1);
        // 还没提交给斗拱的记录,商户端也是「处理中」,不是「待审核」。
        assertThat(merchantWithdrawals()).singleElement().satisfies(row -> assertThat(row)
                .containsEntry("status", "processing")
                .containsEntry("amountCents", PAY_AMOUNT_CENTS));
        when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
                "resp_code", "00000000", "resp_desc", "成功", "trans_stat", "P", "hf_seq_id", "HF-W-P-" + suffix)));
        assertThat(withdrawalResultService.submitPending(200)).isPositive();
        ArgumentCaptor<HuifuWithdrawalCommand> withdrawalCommand =
                ArgumentCaptor.forClass(HuifuWithdrawalCommand.class);
        verify(withdrawalGateway).create(withdrawalCommand.capture());
        assertThat(withdrawalCommand.getValue().withdrawalNo()).isEqualTo(withdrawal.withdrawalNo());
        assertThat(withdrawalCommand.getValue().amountCents()).isEqualTo(PAY_AMOUNT_CENTS);
        assertThat(withdrawalCommand.getValue().huifuId()).isEqualTo(huifuId);
        assertThat(withdrawalCommand.getValue().tokenNo()).isEqualTo(shortReference("T", suffix));
        // 通道受理但结果未定:查询要用提交时记下的营业日和子商户号。
        assertThat(withdrawalRow(withdrawal.id()))
                .containsEntry("status", "processing")
                .containsEntry("provider_huifu_id", huifuId);
        assertThat(withdrawalResultService.handleNotification(withdrawal.withdrawalNo(),
                withdrawalNotification("S", "00000000", "到账成功"))).isEqualTo(Application.PAID);
        assertThat(withdrawalRow(withdrawal.id()))
                .containsEntry("status", "paid")
                .containsEntry("amount_cents", PAY_AMOUNT_CENTS);
        assertThat(withdrawalRow(withdrawal.id()).get("paid_at")).isNotNull();
        assertOnlyWithdrawalStatus("paid");
        // 到账之后金额冻结在提现记录里,可提现金额归零。
        assertThat(settlementService.withdrawalSummary(principal))
                .containsEntry("availableAmountCents", 0L)
                .containsEntry("frozenAmountCents", PAY_AMOUNT_CENTS);
    }
    @Test
    void aProcessingWithdrawalIsConvergedByTheScheduledQueryInsteadOfByHand() {
        readyMerchant();
        LocalDate businessDate = someBusinessDate();
        String orderNo = paidVerifiedOrder(businessDate);
        Long orderId = orderId(orderNo);
        aggregation.run(businessDate);
        Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
        when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
                "resp_code", "00000000", "trans_stat", "P", "hf_seq_id", "HF-W-Q-" + suffix)));
        withdrawalResultService.submitPending(200);
        assertThat(withdrawalRow(withdrawal.id())).containsEntry("status", "processing");
        assertOnlyWithdrawalStatus("processing");
        // 等待期内的记录不查询:刚提交时通道可能还没落库,过早查询会把「查不到」当成失败。
        assertThat(withdrawalResultService.reconcileProcessing(200)).isZero();
        verify(withdrawalGateway, never()).query(any());
        ageProcessing(withdrawal.id());
        when(withdrawalGateway.query(any())).thenReturn(sync(Map.of(
                "resp_code", "00000000", "trans_status", "S", "trans_desc", "", "org_hf_seq_id", "HF-W-QS-" + suffix)));
        assertThat(withdrawalResultService.reconcileProcessing(200)).isPositive();
        ArgumentCaptor<HuifuWithdrawalQueryCommand> query =
                ArgumentCaptor.forClass(HuifuWithdrawalQueryCommand.class);
        verify(withdrawalGateway).query(query.capture());
        assertThat(query.getValue().withdrawalNo()).isEqualTo(withdrawal.withdrawalNo());
        assertThat(query.getValue().huifuId()).isEqualTo(huifuId);
        assertThat(withdrawalRow(withdrawal.id()))
                .containsEntry("status", "paid")
                .containsEntry("provider_trade_no", "HF-W-QS-" + suffix);
        assertOnlyWithdrawalStatus("paid");
    }
    @Test
    void aFailedWithdrawalDefersItsOrdersIntoTheNextBusinessDay() {
        readyMerchant();
        LocalDate businessDate = someBusinessDate();
        Long firstOrder = orderId(paidVerifiedOrder(businessDate));
        aggregation.run(businessDate);
        Withdrawal failed = withdrawalOf(businessDate, firstOrder);
        when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
                "resp_code", "00000000", "trans_stat", "P", "hf_seq_id", "HF-W-F-" + suffix)));
        withdrawalResultService.submitPending(200);
        assertThat(withdrawalResultService.handleNotification(failed.withdrawalNo(),
                withdrawalNotification("F", "00000000", "账务失败"))).isEqualTo(Application.FAILED);
        assertThat(withdrawalRow(failed.id()))
                .containsEntry("status", "failed")
                .containsEntry("failure_reason", "账务失败")
                .containsEntry("amount_cents", PAY_AMOUNT_CENTS);
        // 失败顺延:订单解锁回到可汇总范围,关联行留痕但标记为已释放。
        assertThat(orderRow(firstOrder)).containsEntry("withdrawal_status", 0);
        assertThat(releasedAt(failed.id(), firstOrder)).isNotNull();
        // 商户端把失败原因一并展示出来,状态仍然是三种之一。
        assertThat(merchantWithdrawals()).singleElement().satisfies(row -> assertThat(row)
                .containsEntry("status", "failed")
                .containsEntry("reason", "账务失败"));
        // 次日汇总必须把顺延的订单和新订单合并成一条新的提现记录。
        Long secondOrder = orderId(paidVerifiedOrder(businessDate.plusDays(1)));
        aggregation.run(businessDate.plusDays(1));
        Withdrawal retried = withdrawalOf(businessDate.plusDays(1), firstOrder);
        assertThat(retried.amountCents()).isEqualTo(2 * PAY_AMOUNT_CENTS);
        assertThat(sourceOrderIds(retried.id())).containsExactlyInAnyOrder(firstOrder, secondOrder);
        assertThat(orderRow(firstOrder).get("withdrawal_status")).isEqualTo(1);
        assertThat(orderRow(secondOrder).get("withdrawal_status")).isEqualTo(1);
    }
    @Test
    void repeatedProviderNotificationsAndRepeatedVerificationNeverChangeSettledState() {
        readyMerchant();
        LocalDate businessDate = someBusinessDate();
        String orderNo = paidVerifiedOrder(businessDate);
        Long orderId = orderId(orderNo);
        // 核销幂等:同一个幂等键重复提交只产生一条有效核销记录,不同幂等键被订单状态挡住。
        assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
        merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "again"), verifyKey(orderNo));
        assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
        assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "again"),
                "acceptance-verify-second-" + suffix + "-" + orderNo))
                .isInstanceOf(BusinessException.class);
        assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
        aggregation.run(businessDate);
        Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
        when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
                "resp_code", "00000000", "trans_stat", "S", "hf_seq_id", "HF-W-DUP-" + suffix)));
        withdrawalResultService.submitPending(200);
        assertThat(withdrawalRow(withdrawal.id())).containsEntry("status", "paid");
        // 重复通知(含通道的第二次账务通知)不能改写终态,也不会释放来源订单。
        assertThat(withdrawalResultService.handleNotification(withdrawal.withdrawalNo(),
                withdrawalNotification("S", "00000000", "到账成功"))).isEqualTo(Application.ALREADY_SETTLED);
        assertThat(withdrawalResultService.handleNotification(withdrawal.withdrawalNo(),
                withdrawalNotification("F", "00000000", "迟到的失败"))).isEqualTo(Application.ALREADY_SETTLED);
        assertThat(withdrawalRow(withdrawal.id())).containsEntry("status", "paid");
        assertThat(orderRow(orderId)).containsEntry("withdrawal_status", 1);
        // 重复执行结果处理任务:终态记录不会被重新提交,也就不会用新的业务请求号重复出账。
        assertThat(withdrawalResultService.submitPending(200)).isZero();
        verify(withdrawalGateway).create(any());
    }
    @Test
    void aRefundUnderWayExcludesTheOrderFromTheDailyWithdrawal() {
        readyMerchant();
        LocalDate businessDate = someBusinessDate();
        // 退款的订单必须是「已支付未核销」:核销过的订单按既有策略不可退。
        String orderNo = paidOrder();
        Long orderId = orderId(orderNo);
        Refund refund = requestRefundUnderWay(orderNo);
        aggregation.run(businessDate);
        // 退款在途(整单排除)的订单不进提现记录,金额留作退款。
        assertThat(withdrawalCount(businessDate)).isZero();
        assertThat(orderRow(orderId)).containsEntry("withdrawal_status", 0);
        // 通道确认退款成功后,这笔订单永远退出提现范围。
        assertThat(callbackService.handle(refundCallback(refund.refundNo(), "S", "00000000"), "signature"))
                .isEqualTo("RECV_ORD_ID_" + refund.refundNo());
        assertThat(orderRow(orderId))
                .containsEntry("refund_status", 2)
                .containsEntry("withdrawal_status", 0);
        assertThat(withdrawalCount(businessDate)).isZero();
    }
    @Test
    void anOrderLockedIntoAWithdrawalRecordCanNeitherBeRevokedNorRefunded() {
        readyMerchant();
        LocalDate businessDate = someBusinessDate();
        String orderNo = paidVerifiedOrder(businessDate);
        Long orderId = orderId(orderNo);
        aggregation.run(businessDate);
        Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
        assertThat(orderRow(orderId)).containsEntry("withdrawal_status", 1);
        // 已经进入提现记录的订单不能再撤销核销或退款:这是「退款与批次锁定按订单串行化」的终点。
        assertThatThrownBy(() -> merchantWorkService.revokeVerification(principal,
                new MerchantRevokeVerificationRequest(String.valueOf(orderId), "too late"), "acceptance-revoke-" + suffix))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("提现记录");
        assertThatThrownBy(() -> refundService.request(orderNo,
                new OrderRefundRequest(BigDecimal.valueOf(PAY_AMOUNT_CENTS, 2), "too late", "tester")))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("提现记录");
        assertThat(merchantWorkService.order(orderNo, merchantId, storeId))
                .containsEntry("canRevoke", false)
                .containsEntry("revokeReason", "订单已进入提现记录");
        // 撤销与退款都失败后订单状态不变,金额仍在这条提现记录里等待通道结果。
        assertThat(orderRow(orderId))
                .containsEntry("verify_status", 1)
                .containsEntry("refund_status", 0)
                .containsEntry("withdrawal_status", 1);
        assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
        assertThat(withdrawalRow(withdrawal.id())).containsEntry("amount_cents", PAY_AMOUNT_CENTS);
    }
    @Test
    void anUnpaidOrderCannotBeVerifiedAndNeverReachesAWithdrawal() {
        readyMerchant();
        LocalDate businessDate = someBusinessDate();
        String orderNo = createPayableOrder();
        Long orderId = orderId(orderNo);
        assertThat(orderRow(orderNo)).containsEntry("order_status", 0);
        assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal,
                new VerifyRequest("merchant", "unpaid"), verifyKey(orderNo)))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("未支付");
        assertThat(activeVerifyRecords(orderId)).isZero();
        aggregation.run(businessDate);
        assertThat(withdrawalCount(businessDate)).isZero();
        assertThat(orderRow(orderId)).containsEntry("withdrawal_status", 0);
    }
    @Test
    void aForgedOrTamperedPaymentCallbackNeverMarksTheOrderPaid() {
        readyMerchant();
        String orderNo = createPayableOrder();
        String paymentNo = String.valueOf(paymentService.beginMiniProgramPayment(
                orderNo, talentId, 1, "acceptance-payment-forged-" + suffix).get("paymentNo"));
        Long orderId = orderId(orderNo);
        String validPayload = paymentCallback(paymentNo, amountYuan(PAY_AMOUNT_CENTS));
        // 验签不通过:回调被直接拒绝,连审计行都不写,业务状态更不会变。
        when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(false);
        assertThatThrownBy(() -> callbackService.handle(validPayload, "forged-signature"))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("验签失败");
        assertThat(paymentRow(paymentNo)).containsEntry("status", "PREPAY_CREATED");
        assertThat(orderRow(orderId)).containsEntry("order_status", 0);
        assertThat(count("select count(*) from payment_callback_event where provider_request_no = ?", paymentNo))
                .isZero();
        // 签名有效但金额被改动:同样不履约,落异常账。
        when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(true);
        callbackService.handle(paymentCallback(paymentNo, "0.01"), "signature");
        assertThat(paymentRow(paymentNo)).containsEntry("status", "PREPAY_CREATED");
        assertThat(orderRow(orderId)).containsEntry("order_status", 0);
        assertThat(openIssueCount("PAYMENT_AMOUNT_MISMATCH", paymentNo)).isEqualTo(1L);
    }
    @Test
    void aMerchantWithoutAConfirmedCapabilityCannotCreateAPayableOrder() {
        // 用 JDBC 造一条「进件已通过、能力快照还没确认」的商户:支付链路上不调用通道,但也不放行。
        insertApprovedOnboardingWithoutSnapshot();
        assertThatThrownBy(() -> createPayableOrder())
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("待确认");
        assertThat(count("select count(*) from benefit_order where merchant_id = ?", merchantId)).isZero();
    }
    @Test
    void theWithdrawalSurfaceIsReadOnlyAndNeverShowsAStateOutsideTheThreeAgreedOnes() {
        // 商户不能自己发起提现:写路由不存在,只有只读的列表与汇总。
        assertThat(registeredMethods("/api/merchant/withdrawals"))
                .as("商户端提现只有 GET 列表")
                .containsExactly("GET");
        assertThat(registeredPatterns().stream().filter(path -> path.startsWith("/api/merchant/withdrawals")).toList())
                .as("商户端不存在提现写路由")
                .allMatch(path -> path.equals("/api/merchant/withdrawals") || path.equals("/api/merchant/withdrawals/summary"));
        // 后台也不能人工审核或随意改状态:提现管理只有查询。
        assertThat(registeredMethods("/api/admin/management/merchant-settlement/withdrawals"))
                .as("后台提现管理只有 GET 查询")
                .containsExactly("GET");
        assertThat(registeredPatterns().stream()
                .filter(path -> path.startsWith("/api/admin/management/merchant-settlement/withdrawals")).toList())
                .as("后台不存在提现写路由")
                .containsExactly("/api/admin/management/merchant-settlement/withdrawals");
        // 四种库内状态在商户端只折叠成三种对外状态。
        readyMerchant();
        LocalDate businessDate = someBusinessDate();
        String orderNo = paidVerifiedOrder(businessDate);
        Long orderId = orderId(orderNo);
        aggregation.run(businessDate);
        Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
        assertThat(withdrawalRow(withdrawal.id())).containsEntry("status", "pending");
        assertOnlyWithdrawalStatus("processing");
        when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
                "resp_code", "00000000", "trans_stat", "P", "hf_seq_id", "HF-VIS-" + suffix)));
        withdrawalResultService.submitPending(200);
        assertOnlyWithdrawalStatus("processing");
        when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
                "resp_code", "22000004", "resp_desc", "暂未开通取现权限")));
        jdbc.update("update merchant_withdrawal set status='pending' where id = ?", withdrawal.id());
        withdrawalResultService.submitPending(200);
        assertThat(merchantWithdrawals()).singleElement().satisfies(row -> assertThat(row)
                .containsEntry("status", "failed")
                .containsEntry("reason", "暂未开通取现权限"));
        when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
                "resp_code", "00000000", "trans_stat", "S", "hf_seq_id", "HF-VIS-S-" + suffix)));
        jdbc.update("update merchant_withdrawal set status='pending' where id = ?", withdrawal.id());
        withdrawalResultService.submitPending(200);
        assertOnlyWithdrawalStatus("paid");
    }
    // ------------------------------------------------------------------------------------------
    // 生产路径 helper
    // ------------------------------------------------------------------------------------------
    /** 每个用例都从真实进件入口把商户做成「可收款」状态,链路起点一致。 */
    private void readyMerchant() {
        onboardIndividualMerchant();
        assertThat(readinessService.requireCollectingMerchant(merchantId, storeId).huifuId()).isEqualTo(huifuId);
    }
    /** 走真实进件入口,直到进件 approved、结算卡 approved 且能力快照已确认。 */
    private void onboardIndividualMerchant() {
        Map<String, Object> submitted = settlementService.submitOnboarding(principal, onboardingRequest(), "acceptance-onboarding-" + suffix);
        assertThat(submitted).containsEntry("status", "pending");
        // 商户业务开通是异步的:通道回调(notify_type=W + business_stat=S)才会让进件最终通过。
        String applicationNo = String.valueOf(submitted.get("applicationNo"));
        assertThat(settlementService.handleBusinessOpenCallback(Map.of(
                "req_seq_id", applicationNo + "BO",
                "sub_resp_code", "00000000",
                "sub_resp_desc", "成功",
                "audit_status", "Y",
                "notify_type", "W",
                "reg_result_list", List.of(Map.of("business_stat", "S"))))).isTrue();
        Map<String, Object> application = applicationRow();
        assertThat(application).containsEntry("status", "approved");
        onboardingId = ((Number) application.get("id")).longValue();
        huifuId = String.valueOf(application.get("provider_huifu_id"));
        assertThat(huifuId).isNotBlank();
    }
    /** 用 JDBC 造一条「进件已通过、能力快照还没确认」的商户:闸口必须挡住支付。 */
    private void insertApprovedOnboardingWithoutSnapshot() {
        when(capabilityGateway.query(any())).thenThrow(new RuntimeException("snapshot must not be queried on the payment path"));
        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-" + suffix, "acceptance-card-" + suffix, shortReference("T", suffix));
        Long cardId = 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', '验收商户负责人', '验收商户负责人', '13900000006', ?, 'approved', ?, ?)
                """, merchantId, storeId, "MO-ACC-" + suffix, "acceptance-onboarding-snapshot-" + suffix,
                shortReference("H", suffix), cardId);
        onboardingId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class);
        huifuId = shortReference("H", suffix);
    }
    private MerchantOnboardingRequest onboardingRequest() {
        return new MerchantOnboardingRequest(
                "individual",
                "验收商户负责人",
                null,
                "510411199001010011",
                "long_term",
                "2020-01-01",
                null,
                null,
                null,
                null,
                "四川省攀枝花市东区",
                "510000",
                "510400",
                "510402",
                "攀枝花市东区验收路 1 号",
                null,
                null,
                null,
                null,
                null,
                null,
                "https://example.com/acceptance-id-front.png",
                "https://example.com/acceptance-id-back.png",
                "攀枝花市东区验收路 1 号",
                "acceptance-" + suffix + "@example.com",
                "验收商户负责人",
                "13900000006",
                "6217001234567891234",
                "510000",
                "510400",
                BRANCH_CODE,
                "https://example.com/acceptance-card-front.png",
                null);
    }
    /** 下单 → 支付预下单 → 验签回调 → 商户核销,并把这笔核销挪进指定业务日的窗口。 */
    private String paidVerifiedOrder(LocalDate businessDate) {
        String orderNo = paidOrder();
        merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "acceptance"),
                verifyKey(orderNo));
        verifiedOn(orderId(orderNo), businessDate);
        return orderNo;
    }
    /** 下单 → 支付预下单 → 验签回调:订单已支付、未核销,可以退款。 */
    private String paidOrder() {
        String orderNo = createPayableOrder();
        String paymentNo = String.valueOf(paymentService.beginMiniProgramPayment(
                orderNo, talentId, 1, "acceptance-payment-" + suffix + "-" + orderNo).get("paymentNo"));
        callbackService.handle(paymentCallback(paymentNo, amountYuan(PAY_AMOUNT_CENTS)), "signature");
        assertThat(orderRow(orderNo)).containsEntry("order_status", 1).containsEntry("verify_status", 0);
        return orderNo;
    }
    /** 核销幂等键按订单区分:核销键在商户维度唯一,重复用同一个键会被判成「已用于其他订单」。 */
    private String verifyKey(String orderNo) {
        return "acceptance-verify-" + suffix + "-" + orderNo;
    }
    private String createPayableOrder() {
        when(paymentGateway.createMiniProgramPayment(any())).thenReturn(sync(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "pay_info", "{\"appId\":\"wxtest\"}")));
        Map<String, Object> created = appOrderPortalService.create(talentId,
                new AppOrderCreateRequest(merchantId, productId, null), "acceptance-order-" + suffix + "-" + System.nanoTime());
        String orderNo = String.valueOf(created.get("orderNo"));
        // 工单 01 的已知遗留:AppOrderCreateRequest 不携带门店,订单不写 store_id,能力校验只能到商户维度。
        // 日汇总按「商户 + 门店」出账,所以这里补上门店(订单已经记下收款门店 collect_store_id)。
        jdbc.update("update benefit_order set store_id = collect_store_id, order_type = 'GROUP_BUY' where order_no = ?", orderNo);
        assertThat(orderRow(orderNo)).containsEntry("pay_amount", PAY_AMOUNT_CENTS);
        return orderNo;
    }
    private Refund requestRefundUnderWay(String orderNo) {
        when(refundGateway.createRefund(any())).thenReturn(sync(Map.of(
                "trans_stat", "P", "resp_code", "00000000", "hf_seq_id", "HF-R-P-" + suffix)));
        Map<String, Object> requested = refundService.request(orderNo,
                new OrderRefundRequest(BigDecimal.valueOf(PAY_AMOUNT_CENTS, 2), "acceptance", "tester"));
        return new Refund(String.valueOf(requested.get("refundNo")));
    }
    // ------------------------------------------------------------------------------------------
    // 报文 builder
    // ------------------------------------------------------------------------------------------
    /** 回调报文里的金额是两位小数的元,和通道一致;整数分换算一次,避免用 double 表示金额。 */
    private String paymentCallback(String paymentNo, String transAmt) {
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("resp_code", "00000000");
        data.put("resp_desc", "成功");
        data.put("trans_stat", "S");
        data.put("req_seq_id", paymentNo);
        data.put("huifu_id", huifuId);
        data.put("trans_amt", transAmt);
        data.put("hf_seq_id", "HF-PAY-" + paymentNo);
        return JSON.toJSONString(data);
    }
    /** 整数分 → 斗拱要求的两位小数元。 */
    private String amountYuan(long cents) {
        return BigDecimal.valueOf(cents, 2).toPlainString();
    }
    private String refundCallback(String refundNo, String status, String code) {
        return JSON.toJSONString(Map.of(
                "resp_code", code,
                "resp_desc", "退款回调",
                "trans_stat", status,
                "req_seq_id", refundNo));
    }
    private Map<String, Object> withdrawalNotification(String status, String code, String description) {
        return Map.of(
                "sub_resp_code", code,
                "sub_resp_desc", description,
                "trans_status", status,
                "msg_type", "02");
    }
    private Map<String, Object> sync(Map<String, Object> data) {
        return Map.of("data", data);
    }
    // ------------------------------------------------------------------------------------------
    // 查询 helper
    // ------------------------------------------------------------------------------------------
    private Map<String, Object> orderRow(String orderNo) {
        return jdbc.queryForMap("select * from benefit_order where order_no = ?", orderNo);
    }
    private Map<String, Object> orderRow(Long orderId) {
        return jdbc.queryForMap("select * from benefit_order where id = ?", orderId);
    }
    private Long orderId(String orderNo) {
        return jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
    }
    private Map<String, Object> paymentRow(String paymentNo) {
        return jdbc.queryForMap("select * from payment_transaction where payment_no = ?", paymentNo);
    }
    private Map<String, Object> withdrawalRow(Long withdrawalId) {
        return jdbc.queryForMap("select * from merchant_withdrawal where id = ?", withdrawalId);
    }
    private Map<String, Object> applicationRow() {
        return jdbc.queryForMap("""
                select * from merchant_onboarding_application
                where merchant_id = ? and store_id = ? and initial_bank_card_id is not null
                order by id desc limit 1
                """, merchantId, storeId);
    }
    private Map<String, Object> cardRow() {
        return jdbc.queryForMap("""
                select * from merchant_bank_card where merchant_id = ? and store_id = ? order by id desc limit 1
                """, merchantId, storeId);
    }
    private Map<String, Object> capabilityRow() {
        return jdbc.queryForMap("select * from merchant_onboarding_application where id = ?", onboardingId);
    }
    private String onboardingStatus() {
        return String.valueOf(settlementService.onboardingStatus(principal).get("status"));
    }
    private List<Map<String, Object>> merchantWithdrawals() {
        PageResult<Map<String, Object>> page = settlementService.withdrawals(principal, 1, 50);
        return page.records();
    }
    /** 本商户只有一条提现记录,且商户端读到的状态就是期望的那一种。 */
    private void assertOnlyWithdrawalStatus(String status) {
        assertThat(merchantWithdrawals()).singleElement()
                .satisfies(row -> assertThat(row).containsEntry("status", status));
    }
    /** 某个业务日里包含指定订单的那条提现记录。 */
    private Withdrawal withdrawalOf(LocalDate businessDate, Long orderId) {
        Map<String, Object> row = jdbc.queryForMap("""
                select w.id, w.withdrawal_no, w.amount_cents
                from merchant_withdrawal w
                join merchant_withdrawal_order a on a.withdrawal_id = w.id and a.released_at is null
                where w.merchant_id = ? and w.withdrawal_date = ? and a.order_id = ?
                """, merchantId, businessDate.toString().replace("-", ""), orderId);
        return new Withdrawal(((Number) row.get("id")).longValue(), String.valueOf(row.get("withdrawal_no")),
                ((Number) row.get("amount_cents")).longValue());
    }
    private Integer withdrawalCount(LocalDate businessDate) {
        return jdbc.queryForObject("""
                select count(*) from merchant_withdrawal where merchant_id = ? and withdrawal_date = ?
                """, Integer.class, merchantId, businessDate.toString().replace("-", ""));
    }
    private List<Long> sourceOrderIds(Long withdrawalId) {
        return jdbc.queryForList("select order_id from merchant_withdrawal_order where withdrawal_id = ?",
                Long.class, withdrawalId);
    }
    private Object releasedAt(Long withdrawalId, Long orderId) {
        return jdbc.queryForObject("""
                select released_at from merchant_withdrawal_order where withdrawal_id = ? and order_id = ?
                """, Object.class, withdrawalId, orderId);
    }
    private Integer activeVerifyRecords(Long orderId) {
        return jdbc.queryForObject("select count(*) from verify_record where order_id = ? and status = 1",
                Integer.class, orderId);
    }
    private Long openIssueCount(String type, String businessNo) {
        return jdbc.queryForObject("""
                select count(*) from payment_reconciliation_issue
                where issue_type = ? and business_no = ? and status = 'open'
                """, Long.class, type, businessNo);
    }
    private long count(String sql, Object... args) {
        Long value = jdbc.queryForObject(sql, Long.class, args);
        return value == null ? 0 : value;
    }
    private Set<String> registeredMethods(String path) {
        return handlerMapping.getHandlerMethods().entrySet().stream()
                .filter(entry -> entry.getKey().getPathPatternsCondition() != null
                        && entry.getKey().getPathPatternsCondition().getPatternValues().contains(path))
                .flatMap(entry -> entry.getKey().getMethodsCondition().getMethods().stream())
                .map(Enum::name)
                .collect(Collectors.toSet());
    }
    private Set<String> registeredPatterns() {
        return handlerMapping.getHandlerMethods().keySet().stream()
                .filter(info -> info.getPathPatternsCondition() != null)
                .flatMap(info -> info.getPathPatternsCondition().getPatternValues().stream())
                .collect(Collectors.toSet());
    }
    // ------------------------------------------------------------------------------------------
    // 造数与时间 helper
    // ------------------------------------------------------------------------------------------
    /** 业务日取足够远的未来日期,避开共享测试库里别的用例留下的核销时间。 */
    private LocalDate someBusinessDate() {
        return LocalDate.now(ZoneId.of("Asia/Shanghai")).plusDays(BUSINESS_DATE_OFFSET.getAndIncrement());
    }
    private void verifiedOn(Long orderId, LocalDate businessDate) {
        jdbc.update("""
                update verify_record set verify_time = ?
                where order_id = ? and status = 1
                """, LocalDateTime.of(businessDate, java.time.LocalTime.of(10, 0)), orderId);
    }
    private void ageProcessing(Long withdrawalId) {
        jdbc.update("update merchant_withdrawal set updated_at = date_sub(now(), interval 30 minute) where id = ?",
                withdrawalId);
    }
    /** 通道标识限长 32,测试里统一截短。 */
    private String shortReference(String prefix, String suffix) {
        return prefix + suffix.substring(Math.max(0, suffix.length() - 8));
    }
    private record Withdrawal(Long id, String withdrawalNo, long amountCents) { }
    private record Refund(String refundNo) { }
}