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