From 68c6a5802964fc8f5b577feb39a44d733ddc9038 Mon Sep 17 00:00:00 2001 From: yeg <1347747024@qq.com> Date: 星期一, 14 九月 2026 13:30:42 +0800 Subject: [PATCH] refactor(payment): 退役平台自动提现,改由斗拱自动结算并新增结算记录页 --- talent-card-server/docs/huifu-payment-integration.md | 177 +++++----------------------------------------------------- 1 files changed, 17 insertions(+), 160 deletions(-) diff --git a/talent-card-server/docs/huifu-payment-integration.md b/talent-card-server/docs/huifu-payment-integration.md index 02f031f..899a50f 100644 --- a/talent-card-server/docs/huifu-payment-integration.md +++ b/talent-card-server/docs/huifu-payment-integration.md @@ -161,61 +161,21 @@ duplicate historical verification records as revoked (keeping the earliest one) before creating the unique key. -## Daily withdrawal aggregation +## Automatic settlement -`AutoWithdrawalScheduler` runs `AutoWithdrawalAggregationService` every day at 00:10 Beijing time -(`app.merchant.auto-withdrawal.cron`, zone `Asia/Shanghai`) for the previous calendar day. The service -turns one merchant store-day into one `merchant_withdrawal` row and immediately hands the new records -to the automatic encashment path described below. `AUTO_WITHDRAWAL_ENABLED` still defaults to `false`: -the flag is turned on only after the sandbox and controlled real-amount acceptance of ticket 06. +The platform no longer aggregates daily orders and no longer calls Huifu encashment. Delayed +account plus transaction confirmation already keep unverified money away from the merchant, and +payout is left to Huifu: -| Rule | Implementation | -| --- | --- | -| Business day | Beijing time. The window is `verify_time < business day 24:00` and `>= business day - 30 days` | -| Order eligibility | `order_status = 1`, `verify_status = 1`, `refund_status = 0`, `withdrawal_status = 0`, not deleted, plus an active `verify_record` | -| Merchant store eligibility | Newest approved onboarding of the store, with a non-blank `provider_huifu_id` and an approved settlement card that carries a provider token | -| Settlement card | The approval-time card when it has a token, otherwise the store's newest approved token card; the recorded `bank_card_id` and the token always come from that one card | -| One record per merchant store-day | `merchant_withdrawal.withdrawal_date` (Beijing `yyyyMMdd`) plus unique key `uk_merchant_withdrawal_daily (merchant_id, store_key, withdrawal_date)`, where `store_key = ifnull(store_id, 0)` | -| Source order links | `merchant_withdrawal_order`, one row per order, unique key on `order_id` | -| Amount | Integer cents, the sum of `benefit_order.pay_amount` | -| Deferral | Skip the day when the total is below `min-amount-cents`, when `total - fee-buffer-cents` is still below it, or when the merchant store has no dispatchable settlement configuration | -| Idempotency | The day's record is looked up first; the unique keys on `(merchant_id, store_key, withdrawal_date)` and on `order_id` are the database-level guard | -| Locking | Every aggregated order goes through `OrderFundBoundaryService.lockIntoWithdrawal`, so it can no longer be revoked, refunded or aggregated again | +- onboarding opens automatic settlement for every merchant (settle_status=1, cycle D1, + batch settlement, no minimum and no retained amount), so a one-cent settlement is possible; +- after verification confirms the delayed transaction, the money sits in the merchant's basic + account and Huifu settles it to the bound settlement card on the next natural day; +- the platform stores no withdrawal rows. The admin **结算记录** page queries Huifu settlement + records (/v2/merchant/basicdata/settlement/query) to monitor how much was settled each day. -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, 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_CRON=0 10 0 * * * -AUTO_WITHDRAWAL_MAX_ATTEMPTS=3 -AUTO_WITHDRAWAL_QUERY_AFTER_MINUTES=5 -AUTO_WITHDRAWAL_RESULT_CHECK_ENABLED=true -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 -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`. +Migration V140 drops merchant_withdrawal, merchant_withdrawal_order and the order's +withdrawal_status. ## Reconciliation and exception handling @@ -359,114 +319,11 @@ ## Remaining provider-dependent work - Delayed-account confirmation must only be enabled after the corresponding Huifu product and confirmation API are provisioned and tested. -- Merchant onboarding, bank-card verification, merchant settlement/withdrawal callbacks, platform bill download, and accounting reconciliation still require the exact Huifu product contracts and callback specifications for the merchant account. -## Merchant withdrawal (encashment) +## Settlement records -Automatic withdrawal uses Huifu `POST /v2/trade/settlement/encashment` (主动取现), not the mini-program -payment product, and it is submitted only after the provider values are recorded: +HuifuSettlementRecordGateway queries Huifu settlement records for one sub-merchant over a date +range. The admin endpoint GET /api/admin/management/merchant-settlement/settlements asks every +approved onboarding for the range (default: last 30 days), merges the rows and returns them newest +first. A merchant whose query fails is reported in ailures instead of failing the whole page. -- the approved merchant's `huifu_id` on the onboarding application; -- the approved settlement card's `token_no` from Huifu card binding. - -The admin settlement pages are read-only for withdrawals: there is no manual approval and no way to set -a withdrawal to paid or failed by hand, on either the merchant or the admin side. A withdrawal state -changes only when Huifu confirms it through the asynchronous notification or through the status query. -Merchants can still view and replace the settlement card, because that is the account the automatic -withdrawal pays into. - -### Submission - -`AutoWithdrawalResultService.submitPending` claims each `pending` auto-withdrawal row with a conditional -update (`where status = 'pending'`), so a repeated task or a second instance can never submit the same -record twice, then calls the provider with the record's own `withdrawal_no` as the idempotent request -number (`req_seq_id`). The exact values the request used are stored on the row and reused for the later -query: `provider_request_date` is the same `yyyyMMdd` that was sent as `req_date` (computed once, not -read from the clock twice), and `provider_huifu_id` is the sub-merchant the request was submitted to. -The query locates a payout by `huifu_id` + `org_req_date` + `org_req_seq_id`, so re-deriving the -sub-merchant at query time would read a merchant that re-onboarded in between as "transaction does not -exist" (`23000001`) and wrongly release an accepted payout. Rows written before migration `V136` carry -no pinned merchant and fall back to the store's newest approved sub-merchant. - -| Provider answer | Local state | Meaning | -| --- | --- | --- | -| `trans_stat = S` and an accepted code | `paid` | The money left the sub-merchant account | -| `trans_stat = P`, an accepted code, or a payload that cannot be read | `processing` | Submitted, result not decided | -| `trans_stat = F`, or a non-accepted code with no state | `failed` | The provider rejected it; no money moved | -| Gateway exception after the request may have been sent | `processing` | Unknown result; the query has to converge it | -| Local failure before the request (`BusinessException`) | stays `pending` | Nothing was sent, the next run retries | - -The result mapping (`HuifuWithdrawalResult`) is intentionally biased towards `processing`: only a -statement whose transaction state *and* response code agree may reach a final state, a contradictory -statement is re-queried, and the duplicate-transaction code `20000000` is never read as a failure. -Wrongly declaring a request failed would release its orders and pay the same money twice. - -### Confirmation - -A signature-verified notification and the scheduled query both go through the same idempotent entry -point (`AutoWithdrawalResultService.apply`), which locks the withdrawal row, refuses to touch a record -that is already `paid` or `failed`, and only then moves `pending`/`processing` to a final state. A -repeated notification therefore changes nothing, and a late opposite result cannot reopen a terminal -one. Huifu may send two notifications for one withdrawal (channel and accounting, `msg_type 01`/`02`); -both carry the same `req_seq_id` and both converge on the same state. - -`failed` has to be terminal: the failure already released the source orders, so reopening the record on -a late success would let the same money be withdrawn a second time. A statement that contradicts the -recorded terminal state is therefore *not* applied and is written to the server log with the withdrawal -number, state, outcome and response code, so an operator can reconcile it by hand. - -While a withdrawal is still `processing`, an accepted answer from the submission is evidence and is -kept: the first non-blank response code and message are never overwritten by the temporary answers of -later queries. - -The query uses `huifu_id` + `org_req_date` + `org_req_seq_id`, and waits -`app.merchant.auto-withdrawal.query-after-minutes` (default 5) after submission. Querying too early -would read the provider's "not found" (`23000001`) as a failure and wrongly release the orders; the -grace period is also what makes `23000001` trustworthy, because a request the provider never accepted -is exactly the case that has to be deferred again. - -### Failure deferral - -When Huifu confirms a failure, the reason is stored in `merchant_withdrawal.failure_reason` and each -source order is released (`OrderFundBoundaryService.releaseFromWithdrawal`, association row marked with -`released_at`), so the next daily aggregation combines it with the new day's orders and submits a fresh -request. One order is retried at most `app.merchant.auto-withdrawal.max-attempts` times (default 3); -after that it stays locked inside the failed record, which keeps the amount visible for manual -handling instead of retrying forever. That stuck money is the one withdrawal state an operator still -has to act on, so the management console shows a "提现失败待处理" notice counting failed withdrawals -that still hold locked orders. - -The uniqueness rule of `merchant_withdrawal_order` is therefore "at most one *unreleased* association -per order": the generated column `active` is `1` while `released_at` is null and `NULL` afterwards, and -MySQL ignores `NULL` entries in the unique key `uk_withdrawal_order_active (order_id, active)`. -Migrations: `V133`/`V134` (aggregation, day-scoped uniqueness), `V135` (release support, submit -counter), `V136` (sub-merchant pinned on the withdrawal for the result query). - -### Configuration - -```text -HUIFU_WITHDRAWAL_PRODUCT_ID=... # Product that has encashment permission, may differ from payment product -HUIFU_WITHDRAWAL_NOTIFY_URL=https://api.example.com/api/payments/huifu/withdrawal-notify -HUIFU_WITHDRAWAL_INTO_ACCOUNT_DATE_TYPE=D0 -``` - -`HUIFU_WITHDRAWAL_NOTIFY_URL` must be publicly reachable HTTPS and configured with Huifu. The callback -verifies `resp_data` with Huifu's public key before a withdrawal is touched. `status=paid` is the only -terminal success; `processing` never means funds arrived. - -### Statuses exposed to users - -`merchant_withdrawal.status` keeps four internal values, but both the merchant and the admin API collapse -them to the three agreed states with `AutoWithdrawalResultService.displayStatus`: `pending` and -`processing` are shown as 处理中, `paid` as 已到账 and `failed` as 提现失败. - -### Remaining work - -- Huifu's `re_exchange = Y` (已退汇, money returned after a payout) and `acct_status = B` (回账成功) are - not handled yet: a payout that is returned after being confirmed is not moved back to 提现失败 and the - amount is not deferred again. -- The `query-after-minutes` grace period is the only thing standing between "the channel has not booked - it yet" and "the channel never accepted it". The sandbox acceptance of ticket 06 has to confirm how - quickly an accepted encashment becomes queryable before the flag is turned on. -- The single-encashment amount limits are per merchant and per bank, so the sandbox acceptance of ticket - 06 has to confirm the effective minimum, the daily cap and the fee deduction before enabling the flag. -- Gitblit v1.7.1