yeg
2026-09-14 4c5269270508b25f80c837b21cce9745ad298f68
docs(payment): 更新验收手册与集成说明

- 验收手册改为「存量商户绑定 + 真实小额」路线,补账期口径(DM 不含当天资金)、
取现权限与额度、以及按业务日补跑汇总的操作方式
- 集成说明去掉已删除的环境变量,写明没有最低提现金额与手续费缓冲
3个文件已修改
454 ■■■■■ 已修改文件
talent-card-server/docs/automatic-withdrawal-acceptance.md 432 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/docs/huifu-payment-integration.md 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/application.yml 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/docs/automatic-withdrawal-acceptance.md
@@ -1,216 +1,268 @@
# 自动提现沙盒与受控真实金额验收手册
# 支付与自动提现验收手册(存量商户 + 真实小额)
本文件是工单 06「完整链路验收与受控金额验证」的执行手册。自动化部分已经落地并可重复运行
(`AutoWithdrawalEndToEndAcceptanceTests`);需要斗拱沙盒账号、公网回调和真实到账的部分只能由人执行,
按本手册逐条勾选并回填结果。
本手册是工单 06 的执行版本,取代原先的「沙盒全链路」写法。原因:**斗拱的本地沙箱不模拟商户进件,
也不模拟取现**(官方原文:本地沙箱「不验证 … 清结算、发票、取现和生产资金结果」),官方 API 文档里也没有
文档化的测试环境。因此验收路径改为:**把斗拱上已有的真实企业商户号接入系统(跳过进件),
再用真实极小金额验证「下单 → 支付 → 核销 → 日汇总 → 提现 → 到账」全链路。**
## 1. 开关现状与前置约束
自动化部分仍然有效并可重复运行(`AutoWithdrawalEndToEndAcceptanceTests` 等)。本手册只负责自动化覆盖不到、
必须人工执行的部分。
先看清楚现在的默认值,不要照抄一份「以为默认是关」的心理模型:
## 0. 本次验收的边界(先读这一段)
| 开关 | 当前默认值 | 作用 |
**能验**:收款能力判定、下单时固定收款子商户、微信支付拉起、回调落账或查询补偿、商户核销、
日汇总生成提现记录、提交取现、结果收敛、商户端与后台的状态展示。
**不能验**(跳过进件换来的,必须如实记账):
| 跳过的能力 | 为什么跳过 | 遗留风险 |
| --- | --- | --- |
| `app.payment.huifu.enabled` | `application.yml` 里写死 `true` | 斗拱通道总开关;改为 `false` 时支付、退款、提现网关直接抛「Huifu payment is disabled」 |
| `HUIFU_MERCHANT_CAPABILITY_REQUIRED` | `true` | 是否要求已确认的能力快照才能收款;置 `false` 会退化成只校验本地进件与银行卡 |
| `AUTO_WITHDRAWAL_ENABLED` | **`false`** | 自动提现汇总任务开关;只有沙盒全链路通过后才显式打开 |
| 平台进件(企业 / 个人) | 官方要求进件材料必须真实合法,不能编造;斗拱没有进件沙箱 | 进件入参缺 `beneficiary_info`、`settle_cycle`、`cash_fee_party` 等必填项,真实接商户前必须补齐 |
| 结算卡绑卡 / 换卡 | 被 `requireApproved`(要求进件 approved)锁死,跳过进件后无入口 | 绑卡回调与 `token_no` 落库路径未被真实覆盖 |
| 微信商户配置、业务开通 | 正常由进件流程串起来 | 依赖导入的存量商户在斗拱侧「已经配好」 |
| 退汇(`re_exchange=Y`)与回账(`acct_status=B`) | 代码未实现 | 到账后被退汇不会回到「提现失败」,金额也不会再次顺延 |
也就是说**默认状态就会调用真实通道**(`enabled: true` 且 `environment: prod`),沙盒阶段必须显式把
`app.payment.huifu.enabled` 与环境相关配置指向沙盒;只有自动提现任务是默认关闭的。
## 1. 前置条件
验收顺序必须是:**先沙盒(测试环境)→ 再真实极小金额**。真实阶段另有四道硬约束,缺一不可:
### 1.1 环境
1. **商户白名单**:只允许指定测试商户的 `huifu_id`。当前**没有**任何脚本或配置实现这道过滤,它是执行
   纪律:先按 `merchant_id` 确认进件与结算配置,再手工触发汇总,不要在生产上打开定时任务。
2. **银行卡白名单**:只允许指定的一张结算卡(按 `merchant_bank_card.id` + `token_no` 双确认)。
3. **账号约束**:只允许指定运营账号在指定环境下执行;密钥只从部署密钥库注入,不落盘、不进日志。
4. **金额上限**:单笔与单日都是固定极小金额,见第 5 节;超过上限的取现请求一律不发起。
| 项 | 当前值 |
| --- | --- |
| 服务端 | `http://192.168.110.15:8080`(本机 WLAN;真机访问需放行 Windows 防火墙 8080) |
| 数据库 | `127.0.0.1:8134/talent_card`(WSL Docker 里的 MySQL 8,账号 `root` / `123456`) |
| Redis | `127.0.0.1:6368`,密码 `123456` |
| 管理后台 | `npm run dev`(5273,Vite 代理 `/api` 到 8080),账号 **`admin` / `123456`** |
| 人才端小程序 | `rencaika/`,mp-weixin AppID `wxcfee477acb54d179` |
| 商户端小程序 | `rencaika_merchant/`,AppID `wx550e520ad19458ea` |
| 两个 C 端接口地址 | 各自 `config/api.uts` 的 `developmentBaseUrl`,已改为 `http://192.168.110.15:8080` |
> 安全前置:`application.yml` 里当前**硬编码了真实的 `private-key`、`platform-public-key`、系统号/商户号
> 与微信 AppSecret**。这套凭据已经进了版本库,属于必须轮换的暴露面;真实金额验收之前先轮换,并把新值
> 放进部署密钥库(`HUIFU_NOTIFY_URL` 等少数字段已经支持环境变量覆盖,密钥字段目前只读 yml)。
> **主库不跑 Flyway**(`spring.flyway.enabled: false`),`ddl-auto` 只是 `validate`。
> 库结构必须是新的:`merchant_withdrawal` 必须含 `withdrawal_date` 与 `store_key`。
> 结构过旧时用 §1.2 的克隆办法同步,否则日汇总会以 `Unknown column 'withdrawal_date'` 失败。
## 2. 沙盒环境配置
### 1.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 覆盖这些场景的自动化用例
离线环境没有 Flyway 的 jar,`mvnw spring-boot:run` 也用不了(插件依赖缺失),所以用「把测试库结构克隆到开发库」的办法:
```powershell
# 先把测试库跑到最新结构(顺便验证代码)
cd talent-card-server
.\scripts\run-server-tests.ps1 -Test AutoWithdrawalEndToEndAcceptanceTests
.\scripts\run-server-tests.ps1
# 再把它的表结构克隆到开发库
$jar='C:\Users\15708\.m2\repository\mysql\mysql-connector-java\8.0.23\mysql-connector-java-8.0.23.jar'
java -cp $jar ..\.scratch\automatic-withdrawal\tools\CloneSchema.java talent_card_test talent_card
```
`AutoWithdrawalEndToEndAcceptanceTests` 用真实服务(`submitOnboarding` → 业务开通回调 →
能力快照 → 下单 → 支付 → 验签回调 → 核销 → 日汇总 → 提交取现 → 通知/查询)跑完整条链路,
所有斗拱网关都用 `@MockitoBean` 替换,零出网调用。上表里自动化已覆盖:无效验签回调被直接拒绝、
金额被改动的回调不履约并落 `PAYMENT_AMOUNT_MISMATCH`、未支付订单不能核销也不能汇总、
`22000004` 按失败处理并展示原因、`23000001` 按失败顺延、处理中不被过早查询、终态不被改写。
### 1.3 造测试数据
自动化**没有**覆盖、必须靠沙盒或人工确认的:通道是否真的下发 `huifu_id`/`trans_amt`/`fee_amt`/
`acct_status`、`23000003`(内扣手续费吃掉本金)的真实触发与所需缓冲、各错误码的真实取值、
`re_exchange=Y` 与 `acct_status=B` 的当前行为。逐环节的其余边界由
`PaymentSettlementTests`、`OrderFundBoundaryTests`、`AutoWithdrawalAggregationTests`、
`AutoWithdrawalResultProcessingTests`、`MerchantWithdrawalVisibilityTests` 覆盖。
```powershell
$env:MYSQL_URL='jdbc:mysql://127.0.0.1:8134/talent_card?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false'
$env:SQL_HUIFU_ID='<斗拱上的真实子商户号>'
$env:SQL_TOKEN_NO='<结算卡 token,拿不到可留空>'
$env:SQL_TALENT_MOBILE='<你的微信手机号>'
$jar='C:\Users\15708\.m2\repository\mysql\mysql-connector-java\8.0.23\mysql-connector-java-8.0.23.jar'
java -cp $jar .scratch\automatic-withdrawal\tools\SeedMerchant.java
## 5. 受控真实极小金额到账验证
# 结构是从测试库克隆来的,迁移里的种子数据不会跟过来,所以管理员账号要单独补
java -cp $jar .scratch\automatic-withdrawal\tools\AdminSeed.java admin 123456
```
沙盒全链路通过后才执行本节,并且必须先登记再执行。
`token_no` 留空时,卡片先没有 token;服务端在能力判定时**会自己去斗拱查**
(回执里 `qry_cash_card_info_list[].token_no` 就是它),查回来才写入。**提现提交必须要有这个值**。
**登记(执行前填好,缺一项不执行)**
脚本幂等,可反复执行。它会打印 `merchantId / storeId / bankCardId / productId / talentId`,
后面所有 SQL 都按它们过滤。它同时补上:进件(`approved` + 微信配置/业务开通 `approved` + 能力快照已确认)、
结算卡(`approved`,带 token)、到店消费规则、0.01 元的已审核商品、人才用户与有效个人卡、
商户端登录账号(默认 `verify_merchant` / `123456`)。
### 1.4 启动参数
在 IntelliJ 的 `TalentCardServerApplication` 运行配置里加 Program arguments:
```
--app.merchant.auto-withdrawal.enabled=false
--app.merchant.auto-withdrawal.query-after-minutes=1
```
- `enabled=false`:验证期不跑凌晨自动汇总,只用手动补跑,避免它扫全库商户。
- **`result-check-enabled` 保持默认 true**:结果收敛(补提交 + 查询)不受 `enabled` 影响;关掉 `enabled`
  之后已生成的提现记录仍会被提交并收敛。
- 不需要打开 `app.payment.huifu.reconciliation.enabled`:回调收不到时用 §3.3 的后台接口手动补偿。
### 1.5 斗拱侧必须先配好的四件事
1. **结算配置**:自动结算必须**关闭**(能力查询里 `settle_status = 0`)。代码没有下发结算配置的能力,只能在控台关。
   **为什么必须关**:自动结算会把子商户账户的**全部余额**按日结到银行卡,包括用户已支付但**尚未核销**的订单金额——
   那部分钱是要留作退款的。我们的主动取现只提**已核销**订单的金额,两者语义完全不同。
2. **取现权限已开通**(`switch_state = 1`)。只能在斗拱运营控台开。
3. **微信配置已关联人才端小程序** `wxcfee477acb54d179`。
4. **账户里有余额**:全新商户号余额为 0,必须先有一笔真实付款进账(§3 就是这笔)。
## 2. 收款能力核对(零成本,先做)
```
GET /api/admin/management/merchant-settlement/onboardings/{onboardingId}/payment-capability
```
期望:`merchantStatus=NORMAL`、`autoSettleDisabled=true`、`cashAvailable=true`。
任何一项不满足都不要往下走。
再确认结算卡有 token(没有 token 提现请求根本提交不出去):
```sql
select id, status, provider_token_no from merchant_bank_card where merchant_id = {merchantId};
```
为空时先调一次 `/payment-capability/refresh`,由服务端去斗拱查询后再看。
## 3. 主链路(每笔 1 分钱)
金额取 1 分:**没有最低提现金额,1 分钱也会生成提现记录**。每步都记录「实际观察到的值」。
### 3.1 商户端发单(商户视角)
1. 商户端小程序用种子脚本打印的账号登录(默认 `verify_merchant` / `123456`)。
2. 首页「扫码核销」扫人才端出示的**人才动态码**(人才端「我的卡」→ 亮码,**60 秒有效**)。
3. 扫到 `TALENT_CODE` 后按提示录入消费金额并「发送订单」。
```sql
select order_no, merchant_id, store_id, pay_amount, order_status, verify_status
from benefit_order where merchant_id = {merchantId} order by id desc limit 3;
```
期望:`order_status=0`、`verify_status=0`,**`store_id` 等于进件门店**(商户代客下单这条路本来就写门店)。
> 注意:商户扫码**不是核销**。人才码只能用来「录金额发订单」,核销要等用户付款后再扫一次订单核销码。
### 3.2 人才端付款
1. 人才端小程序用**你自己的微信**登录(服务端按手机号匹配种子里的 `talent_user`)。
2. 「使用记录 → 待支付」打开订单,点支付,微信拉起付款 1 分钱。
```sql
select payment_no, status, amount, provider_transaction_no, paid_at from payment_transaction order by id desc limit 3;
select provider_request_no, signature_valid, process_status, received_at from payment_callback_event order by id desc limit 3;
select order_no, order_status, verify_status from benefit_order where order_no = '{orderNo}';
```
期望:`SUCCEEDED`、`order_status=1`、`verify_status=0`。
### 3.3 回调没到就手动补偿(本地几乎一定需要)
斗拱登记的回调地址是给公网实例的,本机 `192.168.110.15` 收不到。后台已有补偿接口,它会**主动向斗拱查单**
并把结果落账(与回调走同一套结算逻辑):
```
POST /api/admin/management/payment-reconciliation/run?pendingAfterMinutes=1
```
> **时序**:该接口只查「创建时间早于 N 分钟」的交易,付款后要**等够 1 分钟**再调,否则查不到;
> 想立刻验证就把参数改成 `0`。
等 1 分钟后调用,再查 §3.2 的三条 SQL。疑问记录在 `payment_reconciliation_issue`。
> 这一条替代了「公网可达回调」。代价:**回调路径本身未被真实覆盖**,必须在结论里写明。
### 3.4 商户端核销
1. 人才端「使用记录 → 待使用」出示订单核销码(**60 秒有效**,自动刷新)。
2. 商户端「扫码核销」再扫一次,确认核销。
```sql
select id, order_id, status, verify_time from verify_record
where order_id = (select id from benefit_order where order_no = '{orderNo}');
select order_no, verify_status, withdrawal_status from benefit_order where order_no = '{orderNo}';
```
期望:`verify_record.status=1`、`verify_status=1`、`withdrawal_status=0`。
### 3.5 手动补跑日汇总
```
POST /api/admin/management/merchant-settlement/auto-withdrawal/run?businessDate=yyyyMMdd
```
`businessDate` 用**当天**(窗口是 `[业务日-30天, 业务日 24:00)`)。定时任务平时汇总前一日,
补跑接口可以指定当天,所以不用等到凌晨。接口会顺带提交并收敛一次结果。
```sql
select id, withdrawal_no, amount_cents, status, withdrawal_date, store_id
from merchant_withdrawal where merchant_id = {merchantId} order by id desc limit 3;
select withdrawal_id, order_id, amount_cents from merchant_withdrawal_order
where withdrawal_id = (select max(id) from merchant_withdrawal where merchant_id = {merchantId});
select order_no, withdrawal_status from benefit_order where order_no = '{orderNo}';
```
期望:金额 = 该订单实付金额(1 分)、`withdrawal_date` 是传入的业务日、来源订单已关联、
`benefit_order.withdrawal_status=1`。
### 3.6 结果收敛与到账
```sql
select withdrawal_no, status, provider_trade_no, provider_request_date, paid_at, failure_reason
from merchant_withdrawal where merchant_id = {merchantId} order by id desc limit 1;
```
期望终态 `paid`、`provider_trade_no` 与 `paid_at` 非空;`failed` 时记录 `failure_reason`。
仍是 `processing` 时,等 `query-after-minutes`(设成 1 分钟)后再调一次补跑接口触发收敛。
### 3.7 商户端与后台展示
- **后台**:`提现管理`(`/merchant-settlement?kind=withdrawals`)按商家名称筛出这条记录。
  **这一页目前不显示门店、到账时间、通道流水号、失败原因**(后端返回了,前端未渲染)。
- **商户端**:提现页 `pages/withdrawal/withdrawal` 只在 `pages.json` 注册,**商户中心没有入口**,
  只能直达路由打开。用直达路由确认待提现金额、结算卡掩码、三种状态、失败原因。
## 4. 各环节异常
| 环节 | 构造方式 | 期望 | 自动化已覆盖 |
| --- | --- | --- | --- |
| 支付 | 重复调用对账接口 | 状态与金额不变 | 是 |
| 支付 | 回调金额改一位小数 | 订单保持未支付,落 `PAYMENT_AMOUNT_MISMATCH` | 是 |
| 支付 | 回调用别的 `huifu_id` | 落 `PAYMENT_MERCHANT_MISMATCH` | 是 |
| 支付 | 回调签名错误 | 直接拒绝,不写审计行、不落账 | 是 |
| 核销 | 同订单重复核销 / 换幂等键 | 拒绝,只有一条有效核销记录 | 是 |
| 核销 | 未支付订单核销 | 拒绝 | 是 |
| 提现 | 同一业务日重复补跑 | 不生成第二条记录 | 是 |
| 提现 | 通道回失败(例如取现权限未开) | 记录 `failed` 存原因、来源订单解锁、次日重新汇总成一笔 | 部分 |
| 提现 | 后台改状态 / 商户发起提现 | 路由不存在 | 是(查路由表) |
## 5. 受控真实金额登记(执行前填好,缺一项不执行)
| 项 | 值 |
| --- | --- |
| 环境 | 生产 / 真实通道(不是 test) |
| 测试商户 `merchant_id` | ____ |
| 测试商户 `huifu_id` | ____ |
| 结算银行卡 `merchant_bank_card.id` | ____ |
| 卡 `token_no` | ____ |
| 到账账号(脱敏) | ____ |
| 单笔金额上限 | ≤ __ 元(建议 0.01~1.00 元) |
| 单日累计上限 | ≤ __ 元 |
| 单日取现笔数上限 | ≤ __ 笔 |
| 执行人 / 审批人 | ____ / ____ |
| 环境 | 真实通道(`app.payment.huifu.environment=prod`) |
| 商户 `merchant_id` / `huifu_id` | ___ / ___ |
| 结算卡 `merchant_bank_card.id` / `token_no` | ___ / ___ |
| 到账账号(脱敏) | ___ |
| 人才 `talent_user.id` / 手机号 | ___ / ___ |
| 单笔金额上限 | ≤ __ 元 |
| 单日累计 / 笔数上限 | ≤ __ 元 / __ 笔 |
| 执行人 / 审批人 | ___ / ___ |
**执行步骤**
**失败即停**:金额超过上限、到账目标与登记不符、同一 `req_seq_id` 被提交两次、
或 `paid` 之后金额又被顺延,立即停止。
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。
## 6. 未解决的问题(上线前必须有结论)
**失败即停**:出现金额超过上限、到账目标与白名单不符、同一 `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`)。
1. **`AUTO_WITHDRAWAL_QUERY_AFTER_MINUTES` 的取值**:等待期是区分「通道还没落库」与「通道从未受理」的唯一手段,
   要实测一笔已受理的取现多久后能查到;太短会把失败误判并解锁订单。
2. **内扣手续费的真实情况**:本商户取现无手续费(手续费在支付时内扣),其他商户/产品是否如此未知。
   官方错误码 `23000003`「取现金额不足以支付内扣手续费」说明存在这种可能。**最低金额与手续费缓冲配置已删除**,
   将来若真遇到该错误码,应重新引入门槛而不是放宽别处。
3. **`into_acct_date_type` 到账口径**:文档给 `D0/T1/D1/DM`,当前默认 `DM`,以目标产品实际返回为准。
4. **回调字段完整性**:真实回调是否一定带 `huifu_id`、`trans_amt`、`fee_amt`、`acct_status`。
   本手册用查询补偿绕过了回调,**回调路径未被真实覆盖**。
5. **退汇与回账未实现**:`re_exchange=Y` 与 `acct_status=B` 不改变本地状态,钱回来了记录仍是 `paid`。
6. **提现金额与通道余额无对账**:`cash_amt` 取自已核销订单汇总,从不校验通道可用余额 `avl_bal`。
   已核销金额尚未结算到账时,取现会因余额不足失败并顺延,重试循环里看不到根因。
7. **多门店仍无门店选择**:`benefit_order.store_id` 已修复(人才端自主下单会写门店),但
   `AppOrderCreateRequest` 只有 `merchantId/productId/couponId`,接入多门店前需让下单入口支持选门店。
8. **商户端提现页无入口**;后台提现列表不显示门店 / 到账时间 / 通道流水号 / 失败原因。
9. **`login_name` 全局唯一**:进件网关用进件单号拼管理员账号(`HuifuEnterpriseOnboardingGateway:129`),
   同一主体重复提交进件会撞唯一约束,真实接商户前必须改成稳定唯一的生成规则。
10. **商户端 `verification/confirm` 的幂等键是假的**:收到 `X-Idempotency-Key` 但不落库,
    真正兜底是 `merchant_verification_attempt`;`/orders/{orderNo}/verify` 才是真幂等。
talent-card-server/docs/huifu-payment-integration.md
@@ -184,15 +184,12 @@
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
that was skipped because its refund was still in flight, or because the merchant store had no usable
settlement card, 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=false
AUTO_WITHDRAWAL_MIN_AMOUNT_CENTS=1000
AUTO_WITHDRAWAL_FEE_BUFFER_CENTS=0
AUTO_WITHDRAWAL_CRON=0 10 0 * * *
AUTO_WITHDRAWAL_MAX_ATTEMPTS=3
AUTO_WITHDRAWAL_QUERY_AFTER_MINUTES=5
@@ -200,6 +197,13 @@
AUTO_WITHDRAWAL_RESULT_CHECK_FIXED_DELAY_MS=300000
```
There is no minimum withdrawal amount and no fee buffer: Huifu's active encashment has no minimum,
and encashment itself is free (the fee is deducted when the payment is collected), so any verified
amount is withdrawn no matter how small. `AUTO_WITHDRAWAL_ENABLED` only gates the automatic nightly
aggregation; result convergence (submitting pending rows and reconciling processing ones) is gated by
`AUTO_WITHDRAWAL_RESULT_CHECK_ENABLED` alone, because it must keep working when aggregation is run
manually from the admin endpoint.
`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
talent-card-server/src/main/resources/application.yml
@@ -86,11 +86,11 @@
  merchant:
    auto-withdrawal:
      # 每日凌晨按北京时间汇总前一个自然日已核销订单,生成一条商户(门店)日汇总提现记录,并调用斗拱
      # 主动取现接口。手动提现入口和后台审核已在工单 05 移除,这个开关不再有「同一批订单出两次账」的风险;
      # 但仍保持默认关闭,等工单 06 的沙盒全链路和受控真实小额验证通过后再显式打开。
      # 主动取现接口。手动提现入口和后台审核已在工单 05 移除,这个开关不再有「同一批订单出两次账」的风险。
      # 它只管「自动汇总」;结果收敛(补提交 + 查询)由 result-check-enabled 单独控制。
      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 * * *}
      # 同一订单失败后最多重新提交几次;达到上限后金额留在失败记录里等人处理。
      max-attempts: ${AUTO_WITHDRAWAL_MAX_ATTEMPTS:3}