talent-card-server/docs/huifu-payment-integration.md
@@ -124,14 +124,16 @@ next capability refresh confirms it. 5. `qry_cash_config_list[].switch_state` must be `1`. When the snapshot has never been confirmed the service queries the provider on demand. An unanswered or rejected query keeps the merchant blocked rather than guessing. When 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. When `app.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 than `app.payment.huifu.capability-refresh.stale-hours` (default 6 hours). Administrators can force a refresh from the management console. `app.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. ### Management endpoints talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java
@@ -37,6 +37,7 @@ private final HuifuMerchantCapabilityGateway capabilityGateway; private final HuifuPaymentProperties huifuPaymentProperties; private final TransactionTemplate refreshTransaction; private final TransactionTemplate providerTransaction; public MerchantPaymentReadinessService( JdbcTemplate jdbc, @@ -51,6 +52,10 @@ // The snapshot must survive a failed provider query in the caller's transaction, so it runs // in its own transaction instead of joining the caller's. this.refreshTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); // The provider is an HTTP call: never make payment or onboarding hold a database transaction // (and its row locks) while waiting for it. this.providerTransaction = new TransactionTemplate(refreshTransaction.getTransactionManager()); this.providerTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_NOT_SUPPORTED); } /** Readiness of the merchant's newest approved onboarding, refreshing the provider view when stale. */ @@ -75,8 +80,8 @@ /** Re-reads the provider configuration and stores the resulting snapshot. */ public Map<String, Object> refresh(Long applicationId) { Application application = requireApprovedApplication(applicationId); queryProvider(application); // The snapshot is only updated inside providerTransaction, which needs no row lock. queryProviderOutsideTransaction(requireApprovedApplication(applicationId)); return capabilityView(requireApprovedApplication(applicationId)); } @@ -84,11 +89,10 @@ * Capability snapshot for the admin settlement page. It queries the provider when the snapshot * has never been confirmed, because the explicit admin request is the moment to find out. */ @Transactional public Map<String, Object> capability(Long applicationId) { Application application = requireApprovedApplication(applicationId); if (application.capabilityVerifiedAt().isBlank()) { queryProvider(application); queryProviderOutsideTransaction(application); application = requireApprovedApplication(applicationId); } return capabilityView(application); @@ -96,9 +100,8 @@ /** * Attempts to refresh approved applications whose capability snapshot is missing or older than * the configured interval. Each application is refreshed in its own transaction, so a provider * outage leaves every previous snapshot untouched; the run stops early because one failure * normally means the next call fails too. * the configured interval. Each application is refreshed in its own transaction, so one failing * merchant never affects the others or the snapshots already stored. */ public int refreshStaleCapabilities(int staleHours, int limit) { if (!providerVerificationRequired() || !huifuPaymentProperties.isEnabled()) return 0; @@ -111,8 +114,7 @@ """, Math.max(1, staleHours), Math.max(1, Math.min(limit, 100))); int refreshed = 0; for (Map<String, Object> row : rows) { if (!refreshQuietly(((Number) row.get("id")).longValue())) break; refreshed++; if (refreshQuietly(((Number) row.get("id")).longValue())) refreshed++; } return refreshed; } @@ -124,8 +126,10 @@ public boolean refreshQuietly(Long applicationId) { if (applicationId == null || !providerVerificationRequired() || !huifuPaymentProperties.isEnabled()) return false; try { refreshTransaction.executeWithoutResult(status -> refresh(applicationId)); return true; return Boolean.TRUE.equals(refreshTransaction.execute(status -> { refresh(applicationId); return Boolean.TRUE; })); } catch (RuntimeException exception) { log.warn("Merchant capability refresh failed: applicationId={}, type={}", applicationId, exception.getClass().getSimpleName()); return false; @@ -138,15 +142,9 @@ || application.merchantStatus().isBlank() || application.capabilityVerifiedAt().isBlank(); if (unknown && !providerVerificationRequired()) return localOnly(application); if (unknown) { if (!queryProvider(application)) return Readiness.blocked("商户收款能力待确认,暂时无法收款"); Application refreshed = approvedApplication(application.merchantId(), application.storeId()); if (refreshed == null || refreshed.id() != application.id()) return Readiness.blocked("商户收款能力待确认,暂时无法收款"); if (refreshed.autoSettleStatus().isBlank() || refreshed.cashSwitchStatus().isBlank()) { return Readiness.blocked("商户收款能力待确认,暂时无法收款"); } application = refreshed; } // The payment path never calls the provider itself: an unfilled snapshot is filled by the // onboarding completion, the scheduler, or an explicit admin refresh. if (unknown) return Readiness.blocked("商户收款能力待确认,暂时无法收款"); if (!isAutoSettleOff(application.autoSettleStatus())) return Readiness.blocked("商户自动结算尚未关闭,暂时无法收款"); if (!isCashAvailable(application.cashSwitchStatus())) return Readiness.blocked("商户取现能力不可用,暂时无法收款"); if (!isMerchantNormal(application.merchantStatus())) { @@ -166,6 +164,14 @@ return Readiness.allowed(); } /** * Runs the provider query outside any surrounding transaction so a slow Huifu call never holds * the caller's row locks. Returns whether the snapshot was refreshed. */ private boolean queryProviderOutsideTransaction(Application application) { return Boolean.TRUE.equals(providerTransaction.execute(status -> queryProvider(application))); } /** Returns false when the provider explicitly rejected the query; the snapshot then stays unknown. */ private boolean queryProvider(Application application) { try { talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessServiceTests.java
@@ -74,7 +74,7 @@ @Test void allowsPaymentOnceAutoSettlementIsOffAndEncashmentIsConfirmed() { stubProvider("NORMAL", "0", "1"); confirmSnapshot("NORMAL", "0", "1"); MerchantPaymentReadinessService.Readiness readiness = readinessService.readiness(merchantId, storeId); @@ -88,8 +88,8 @@ } @Test void blocksPaymentWhileAutoSettlementIsStillOn() { stubProvider("NORMAL", "1", "1"); void rejectsANewPaidOrderOnceAutoSettlementIsBackOn() { confirmSnapshot("NORMAL", "1", "1"); assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId)) .isInstanceOf(BusinessException.class) @@ -97,8 +97,8 @@ } @Test void blocksPaymentWhenEncashmentCapabilityIsUnavailable() { stubProvider("NORMAL", "0", "0"); void rejectsANewPaidOrderWhenEncashmentCapabilityIsUnavailable() { confirmSnapshot("NORMAL", "0", "0"); assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId)) .isInstanceOf(BusinessException.class) @@ -106,8 +106,8 @@ } @Test void blocksPaymentWhenTheSubMerchantIsFrozen() { stubProvider("FREEZEWITHDRAWAL", "0", "1"); void rejectsANewPaidOrderWhenTheSubMerchantIsFrozen() { confirmSnapshot("FREEZEWITHDRAWAL", "0", "1"); assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId)) .isInstanceOf(BusinessException.class) @@ -115,22 +115,29 @@ } @Test void blocksPaymentWhenTheProviderRejectsTheCapabilityQuery() { void aRejectedProviderQueryLeavesTheSnapshotUnconfirmed() { when(capabilityGateway.query(any(HuifuMerchantCapabilityQueryCommand.class))) .thenReturn(HuifuMerchantCapabilityGateway.CapabilityResult.failed("99999999", "商户不存在")); Map<String, Object> capability = readinessService.capability(applicationId); assertThat(capability).containsEntry("autoSettleDisabled", false).containsEntry("verifiedAt", ""); assertThat(jdbc.queryForObject("select capability_response_code from merchant_onboarding_application where id=?", String.class, applicationId)).isEqualTo("99999999"); assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId)) .isInstanceOf(BusinessException.class) .hasMessageContaining("待确认"); } @Test void blocksPaymentWhenTheProviderQueryTimesOut() { void aProviderTimeoutLeavesTheSnapshotUnconfirmed() { when(capabilityGateway.query(any(HuifuMerchantCapabilityQueryCommand.class))) .thenThrow(new HuifuPaymentGatewayException("timeout", true, null)); assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId)) .isInstanceOf(BusinessException.class); assertThat(readinessService.refresh(applicationId)).containsEntry("verifiedAt", ""); assertThat(jdbc.queryForObject("select capability_response_message from merchant_onboarding_application where id=?", String.class, applicationId)) .isEqualTo("商户收款能力查询结果待确认"); assertThat(readinessService.readiness(merchantId, storeId).ready()).isFalse(); } @Test @@ -155,12 +162,20 @@ @Test void reusesTheConfirmedSnapshotInsteadOfQueryingTheProviderAgain() { stubProvider("NORMAL", "0", "1"); readinessService.requirePayable(merchantId, storeId); confirmSnapshot("NORMAL", "0", "1"); readinessService.requirePayable(merchantId, storeId); assertThat(readinessService.readiness(merchantId, storeId).ready()).isTrue(); verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class)); } @Test void blocksPaymentUntilTheSnapshotHasBeenConfirmed() { // The payment path never calls the provider itself, so an unconfirmed snapshot must block. assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId)) .isInstanceOf(BusinessException.class) .hasMessageContaining("待确认"); verify(capabilityGateway, never()).query(any(HuifuMerchantCapabilityQueryCommand.class)); } @Test @@ -181,6 +196,12 @@ return prefix + suffix.substring(Math.max(0, suffix.length() - 8)); } /** Stores a provider snapshot through the admin refresh path. */ private void confirmSnapshot(String merchantStatus, String settleStatus, String cashSwitch) { stubProvider(merchantStatus, settleStatus, cashSwitch); readinessService.refresh(applicationId); } private void stubProvider(String merchantStatus, String settleStatus, String cashSwitch) { when(capabilityGateway.query(any(HuifuMerchantCapabilityQueryCommand.class))) .thenReturn(new HuifuMerchantCapabilityGateway.CapabilityResult(true, "00000000", "成功", merchantStatus, settleStatus, "D1", cashSwitch, "DM"));