yeg
2026-09-12 7bbd9688f58fd0ce612e9c2d3b3d0ccb64dffa2e
refactor(merchant): 斗拱能力查询移出数据库事务

评审后续修改:

- 通过 NOT_SUPPORTED 事务模板执行提供方查询,支付与进件流程不再在等待斗拱
响应期间持有行锁
- 支付链路不再自行调用提供方:快照未确认时直接拦截并给出明确原因,由进件
完成、定时任务或后台手动刷新补齐,从而消除调用方事务与快照写入之间的锁冲突
- 每个过期商户在独立事务中刷新,单个失败不影响后续商户,也不影响已存快照
3个文件已修改
105 ■■■■■ 已修改文件
talent-card-server/docs/huifu-payment-integration.md 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessServiceTests.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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"));