uni.login and the WeChat phone-number authorization API during sign-in.openid, and issues a talent access session.openid.POST /v3/trade/payment/jspay with trade_type=T_MINIAPP. The huifu_id is the collecting sub-merchant fixed on the order, never the platform merchant.pay_info; the client receives its appId, timeStamp, nonceStr, package, signType, and paySign fields and calls uni.requestPayment.resp_data and sign to the callback URL. The server verifies the original resp_data with SHA256WithRSA.POST /api/app/pending-orders/{orderNo}/paymentAuthorization: Bearer {talentAccessToken} and requires the session talent to equal the body talentId.X-Idempotency-Key (required, 1-128 characters){ "talentId": 1, "priceVersion": 1 }paymentNo, paymentStatus, and provider paymentParams.GET /api/app/pending-orders/{orderNo}/payment?talentId={id}&paymentNo={paymentNo}NOT_STARTED, PAYMENT_PENDING, PAYMENT_CREATE_FAILED, or PAID.POST /api/payments/huifu/notifyapplication/x-www-form-urlencoded data with resp_data and sign.RECV_ORD_ID_{req_seq_id}.POST /api/app/auth/login{ "wechatCode": "...", "phoneCode": "...", "agreementVersion": "v1", "agreedAt": 0 }.talentId, accessToken, refreshToken, and expireAt.POST /api/app/auth/refreshThe legacy /pay endpoint now rejects calls so a client cannot convert an order to paid state without a verified payment result.
Store these values in the deployment secret manager and expose them as environment variables. Do not add keys to application.yml.
HUIFU_ENABLED=true
HUIFU_ENVIRONMENT=test|prod
HUIFU_SYSTEM_ID=... # sys_id from Huifu provisioning
HUIFU_PRODUCT_ID=... # product_id from Huifu provisioning
HUIFU_MERCHANT_ID=...
HUIFU_MINI_PROGRAM_APP_ID=...
HUIFU_PRIVATE_KEY=...
HUIFU_PLATFORM_PUBLIC_KEY=...
HUIFU_NOTIFY_URL=https://api.example.com/api/payments/huifu/notify
HUIFU_DELAY_ACCOUNT=false # Enable only after delayed-transaction confirmation is implemented.
WECHAT_MINI_PROGRAM_APP_ID=... # Usually the same app id used by the Huifu mini-program product.
WECHAT_MINI_PROGRAM_APP_SECRET=... # WeChat Mini Program AppSecret, from the WeChat admin console.
The callback address must be publicly reachable over HTTPS. Configure the same address with Huifu. Enable the provider only after the Huifu product, merchant, mini-program relationship, signing keys and callback reachability have been verified in the sandbox.
WECHAT_MINI_PROGRAM_APP_SECRET on the server process. Do not put it in the client or commit it to source control.talent_session; verify GET /actuator/health is UP.talent_user.payment_transaction and the app opens the native WeChat payment sheet.select payment_no, status, amount, provider_transaction_no, paid_at
from payment_transaction order by id desc;
select provider_request_no, signature_valid, process_status, received_at
from payment_callback_event order by id desc;
Expected successful values: transaction SUCCEEDED; callback signature_valid = 1, process_status = PROCESSED; corresponding benefit_order.order_status = 1.
An order that needs payment is bound to exactly one collecting sub-merchant when it is created, so the
payment request, the callback, the reconciliation query and the refund can never drift to another
merchant or to the platform account.
| Column | Meaning |
|---|---|
benefit_order.collect_onboarding_id |
Approved onboarding application that collects the money |
benefit_order.collect_store_id |
Store of that onboarding |
benefit_order.collect_huifu_id |
Huifu merchant number (huifu_id) that receives the money |
payment_transaction.provider_huifu_id |
Sub-merchant the prepayment was created against |
order_refund.provider_huifu_id |
Sub-merchant the refund is requested from |
All three order-creation paths (AppOrderPortalService.create, ManualConsumptionService.create andOrderService.create) resolve the target through MerchantPaymentReadinessService.requireCollectingMerchant
and store it; a merchant whose onboarding is not approved with a non-blank provider_huifu_id cannot
create a paid order at all. Free orders carry no collecting merchant. HuifuMiniProgramPaymentGateway,HuifuRefundGateway and the reconciliation query all send the stored huifu_id, and a payment whose
order has no binding is rejected with a 409 instead of calling Huifu.
A verified notification (or a successful provider query) is handed to PaymentSettlementService, which
locks the payment transaction and compares the provider statement with local state before anything
changes:
| Check | Accepted | Otherwise |
|---|---|---|
trans_stat |
S/SUCCESS/PAID, F/FAILED, P/PROCESSING/PENDING |
UNKNOWN_STATUS, no state change |
| Request number (order) | req_seq_id matches a local payment_no whose order_id/order_no identify the order to settle |
UNMATCHED |
| Merchant number | huifu_id equals payment_transaction.provider_huifu_id (or the order's collect_huifu_id) |
PAYMENT_MERCHANT_MISMATCH, or PAYMENT_MERCHANT_UNVERIFIED when it is absent |
| Amount | trans_amt (yuan) equals payment_transaction.amount (cents) |
PAYMENT_AMOUNT_MISMATCH |
| Payment state | not CLOSED and the order is still pending |
PAYMENT_ORDER_CANCELLED |
The order check is the request number itself: req_seq_id is the payment number this server generated
for exactly one order, the callback is resolved through payment_transaction.payment_no, and only that
transaction's order can change. A statement naming no local payment changes nothing (UNMATCHED), and
no other order is ever touched. The amount and merchant number are compared with the local record
before any state changes, and a success statement that carries no merchant number is refused rather
than trusted.
A rejected statement never marks the order paid; it is recorded as an open payment_reconciliation_issue
row for the management console. An accepted one sets the transaction to SUCCEEDED, the order to paid,
and STORE_CONSUME.verify_status = 1, whether it arrived through the callback or through a
reconciliation query.
Duplicate notifications are idempotent at two levels: an identical payload for an already processed
request is acknowledged without processing (received_at still updates), and a repeated success with a
different payload only converges the already settled state. A payload may carry the business fields at
the top level or nested under data (object or JSON string).
A refund is requested from the merchant that collected the payment. New payments carry that
sub-merchant in payment_transaction.provider_huifu_id and order_refund.provider_huifu_id.
Payments made before the collecting sub-merchant was recorded were collected by the configured
platform merchant (HUIFU_MERCHANT_ID), and that fact is not stored anywhere, so those refunds fall
back to the platform merchant exactly as before. Migration V131 therefore backfills only in-flight
pending orders and never invents a collecting sub-merchant for money that has already moved.
benefit_order carries the money state of one order: order_status (paid or not), verify_status
(consumed by the merchant or not), refund_status (0 none, 1 refund in flight, 2 refunded) andwithdrawal_status (0 not in a withdrawal record, 1 locked into one). OrderFundBoundaryService
owns the rules and the writes for the last two: every verification path, the refund path (request,
provider query and the asynchronous refund callback) and the daily aggregation lock the samebenefit_order row with SELECT ... FOR UPDATE first, so verification, refund and withdrawal locking
are serialized per order instead of racing.
The rules it enforces:
verify_record additionally has a generated column plus unique keyuk_verify_record_active_order), so one order can never hold two active verification records.refund_status = 1,2, which excludes the whole order from withdrawalrefund_status = 0, but a late failure notificationlockIntoWithdrawal (called by the daily aggregation of ticket 04) only accepts an order that isMigration V132 adds the two columns, backfills refund_status from order_refund, and marks
duplicate historical verification records as revoked (keeping the earliest one) before creating the
unique key.
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:
Migration V140 drops merchant_withdrawal, merchant_withdrawal_order and the order's
withdrawal_status.
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.
The payment query runs against the order's bound sub-merchant (coalesce(payment_transaction.provider_huifu_id, benefit_order.collect_huifu_id)) and its result goes through the same settlement rules as a callback, so a late callback and a successful query produce the same order state.
Set these values only after sandbox verification. The scheduler is deliberately off by default so a local startup does not call the real provider:
HUIFU_RECONCILIATION_ENABLED=true
HUIFU_RECONCILIATION_FIXED_DELAY_MS=300000
HUIFU_RECONCILIATION_PENDING_AFTER_MINUTES=5
The scheduler checks up to 100 old pending payments and 100 old pending refunds per run. It writes non-sensitive exceptions to payment_reconciliation_issue: pending timeout, provider-declared failure, query failure, and the mismatch types listed above. It does not store callback bodies, signatures, OpenID values, card numbers, or keys.
Administrators can use Payment reconciliation in the management console to inspect open issues, run a one-off check, and mark a confirmed issue as resolved or ignored. Marking a row resolved does not alter payment or refund state; it is an audit action only.
HUIFU_NOTIFY_URL as a public HTTPS URL in both deployment configuration and the Huifu console. Confirm that the ingress/WAF permits POST /api/payments/huifu/notify with form data and does not rewrite resp_data or sign.payment_transaction.status = SUCCEEDED, a callback event with signature_valid = 1, and benefit_order.order_status = 1. Confirm the sandbox callback really carries huifu_id and trans_amt: those fields are required for the payment to settle, and a sandbox callback that omits them leaves the order pending with an open reconciliation issue.trans_amt. The order must stay pending and payment_reconciliation_issue must gain a PAYMENT_MERCHANT_MISMATCH or PAYMENT_AMOUNT_MISMATCH row.order_refund.refund_status = 2.A merchant may only take paid orders once its sub-merchant configuration is confirmed. The server
reads that configuration with the Huifu POST /v2/merchant/basicdata/query interface
(v2.merchant.basicdata.query) and stores the result on the onboarding application:
| Stored column | Provider field | Ready value |
|---|---|---|
merchant_stat |
mer_stat |
NORMAL (any other value blocks payment) |
auto_settle_status |
qry_settle_config_list[].settle_status |
0 (automatic settlement off) |
cash_switch_status |
qry_cash_config_list[].switch_state |
1 (encashment available) |
cash_type |
qry_cash_config_list[].cash_type |
recorded for reconciliation (D0/D1/T1/DM) |
capability_verified_at |
query timestamp | set only after a successful query |
Rules enforced by MerchantPaymentReadinessService:
token_no. Because amerchant_stat must be NORMAL.qry_cash_config_list[].switch_state must be 1.When the snapshot has never been confirmed the service queries the provider on demand. That query
always runs outside any surrounding database transaction, so a slow Huifu call never holds payment
or onboarding row locks; a failed query writes no snapshot and leaves the merchant blocked. Whenapp.payment.huifu.enabled=false (local development and tests) no outbound provider call is made:
only rules 1 and 2 are enforced, plus rules 4 and 5 for whatever the stored snapshot already says.
MerchantPaymentCapabilityScheduler refreshes approved sub-merchants whose snapshot is older thanapp.payment.huifu.capability-refresh.stale-hours (default 6 hours), in its own transaction per
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 afterinnodb_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 withUnknown column ... in 'field list'. MerchantOnboardingSchemaInitializer adds the same columns for
deployments that run without Flyway.
GET /api/admin/management/merchant-settlement/onboardings/{id}/payment-capabilityapplicationId, huifuId, merchantStatus, autoSettleDisabled, autoSettleStatus,cashAvailable, cashSwitchStatus, verifiedAt. It queries the provider when the snapshot hasPOST /api/admin/management/merchant-settlement/onboardings/{id}/payment-capability/refreshHUIFU_MERCHANT_CAPABILITY_REQUIRED=true # require provider confirmation (defaults to the enabled flag)
HUIFU_CAPABILITY_REFRESH_ENABLED=true
HUIFU_CAPABILITY_STALE_HOURS=6
HUIFU_CAPABILITY_REFRESH_FIXED_DELAY_MS=1800000
Several migrations use MySQL-only syntax that the in-memory H2 default of application-test.yml
cannot parse, so the Spring tests only load when pointed at MySQL. Run them through the script, which
sets the variables, keeps one Maven build at a time and writes target/server-tests.log:
cd talent-card-server
.\scripts\run-server-tests.ps1 # whole suite
.\scripts\run-server-tests.ps1 -Test PaymentSettlementTests # one class
.\scripts\run-server-tests.ps1 -Reset # rebuild the schema first
The equivalent manual invocation, when a raw Maven run is needed:
$env:TEST_DATASOURCE_URL='jdbc:mysql://127.0.0.1:8134/talent_card_test?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false&createDatabaseIfNotExist=true'
$env:TEST_DATASOURCE_USERNAME='root'
$env:TEST_DATASOURCE_PASSWORD='123456'
$env:TEST_DATASOURCE_DRIVER='com.mysql.cj.jdbc.Driver'
.\mvnw.cmd -o test
Schema state, the migration append-only rule and the known baseline failures are documented indocs/agents/testing.md.
MerchantPaymentReadinessServiceTests, MerchantSettlementCallbackIdempotencyTests andAppOrderPortalServiceTests cover the readiness snapshot, the payment gate for paid orders and
callback idempotency. They stub HuifuMerchantCapabilityGateway, so they never call Huifu.
PaymentSettlementTests covers the payment closure: the order-fixed collecting sub-merchant, the
payment request and the refund using that sub-merchant, callback amount/merchant/order validation,
duplicate notifications, unknown request numbers and statuses, cancellation after a late success,
nested callback payloads, and query compensation. It stubs HuifuSignatureService,HuifuMiniProgramPaymentGateway and HuifuRefundGateway, so it never signs, pays, refunds or queries
the real provider.
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.