From 7bbd9688f58fd0ce612e9c2d3b3d0ccb64dffa2e Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 12 九月 2026 13:42:08 +0800
Subject: [PATCH] refactor(merchant): 斗拱能力查询移出数据库事务

---
 talent-card-server/docs/huifu-payment-integration.md                                                               |   10 +++--
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java      |   46 +++++++++++++----------
 talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessServiceTests.java |   49 +++++++++++++++++-------
 3 files changed, 67 insertions(+), 38 deletions(-)

diff --git a/talent-card-server/docs/huifu-payment-integration.md b/talent-card-server/docs/huifu-payment-integration.md
index 98ed4e7..9adda6b 100644
--- a/talent-card-server/docs/huifu-payment-integration.md
+++ b/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
 
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java
index 8bb5530..1ebd55c 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java
+++ b/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 {
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessServiceTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessServiceTests.java
index c01e2d1..4a2dfa0 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessServiceTests.java
+++ b/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"));

--
Gitblit v1.7.1