yeg
2026-09-17 e528e66851ea030e3b144760ef8d5a12e752eb3f
feat(merchant): 新增商户端收款能力自查与手动核实接口

商户端此前没有任何接口暴露通道商户状态与最近核实时间,商户只能靠「下单被拒」感知。

- GET /api/merchant/payment-readiness:只读,不查通道;reason 直接复用下单闸门抛出的那句话,
保证页面与「下单被拒」逐字一致;只返回派生结果(ready/reason/verifiedAt/verifyAvailable/
nextVerifyAt),不下发 merchant_stat 等通道原始值。
- POST /api/merchant/payment-readiness/verify:手动查一次通道,受冷却保护。冷却用一个 UPDATE
兼做判定与占位,抢到窗口的那次才查询,成功与失败都推进冷却——capability_verified_at 失败不推进
(会被连点),updated_at 是 MySQL 的 on-update 列(会被无关 UPDATE 重置),都不能当节流依据。
- 新增列 capability_sync_until(V148,并同步进 MerchantOnboardingSchemaInitializer:
开发库 Flyway 关着,只写迁移会让本地的列永远不存在)。
- merchantVerify 刻意不加 @Transactional:冷却窗口要在通道查询之前落库,且查询不能夹在持有
该行锁的事务里。
- 两个端点放在 MerchantSettlementService 复用既有 store(principal) 门店校验,控制器不加新依赖。
2个文件已添加
4个文件已修改
294 ■■■■■ 已修改文件
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V148__merchant_capability_sync_until.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessMerchantViewTests.java 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
@@ -114,6 +114,8 @@
            columns.put("capability_response_code", "varchar(32) default null");
            columns.put("capability_response_message", "varchar(512) default null");
            columns.put("capability_verified_at", "datetime default null");
            // 商户端「立即核实」的冷却时间戳,见 V148。
            columns.put("capability_sync_until", "datetime default null");
            // 法人手机号:微信实名认证走法人时的必填项,实名接口的 name/mobile/id_card_number
            // 都必须是法人本人的,不能用联系人手机号顶替。见 V145 的说明。
            columns.put("legal_mobile", "varchar(20) default null");
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java
@@ -33,6 +33,12 @@
    private static final Logger log = LoggerFactory.getLogger(MerchantPaymentReadinessService.class);
    /**
     * 商户端「立即核实」的冷却窗口。与进件刷新那套 5 分钟租约同量级:手动核实是用户动作,
     * 不能变成对通道的无限制查询入口。
     */
    private static final int MERCHANT_VERIFY_COOLDOWN_MINUTES = 5;
    private final JdbcTemplate jdbc;
    private final HuifuMerchantCapabilityGateway capabilityGateway;
    private final HuifuPaymentProperties huifuPaymentProperties;
@@ -90,6 +96,58 @@
        // The snapshot is only updated inside providerTransaction, which needs no row lock.
        queryProviderOutsideTransaction(requireApprovedApplication(applicationId));
        return capabilityView(requireApprovedApplication(applicationId));
    }
    /**
     * 商户端自查:与下单闸门同源、同文案的判定结果,外加最近核实时间。
     *
     * <p>只读,不查通道:快照的新鲜度由既有的定时核实与 {@link #merchantVerify} 负责。只返回派生
     * 结果——通道原始值({@code merchant_stat} 等)不下发给商户端。
     */
    public Map<String, Object> merchantView(Long merchantId, Long storeId) {
        Application application = approvedApplication(merchantId, storeId);
        Readiness readiness = readiness(merchantId, storeId);
        Map<String, Object> view = new LinkedHashMap<>();
        view.put("ready", readiness.ready());
        view.put("reason", readiness.reason());
        view.put("verifiedAt", application == null ? "" : application.capabilityVerifiedAt());
        view.put("nextVerifyAt", application == null ? "" : application.capabilitySyncUntil());
        view.put("verifyAvailable", application != null && verifyWindowOpen(application.id()));
        return view;
    }
    /**
     * 商户端手动核实:一次通道查询,受冷却保护,冷却期内直接回当前快照。
     *
     * <p>冷却用一个 UPDATE 兼做判定与占位:只有抢到窗口的那次调用会去查通道,并发的第二次点击
     * 直接拿当前快照。查询成功与失败都留在冷却里——失败**不**推进 {@code capability_verified_at},
     * 拿它当节流依据会让按钮被连点,而通道查不通时恰恰最需要节流。
     *
     * <p>刻意不加 {@code @Transactional}:冷却窗口必须在通道查询之前落库,否则查询进行中的并发点击
     * 会一起穿透;而通道查询也绝不能夹在一个持有该行锁的事务里。
     */
    public Map<String, Object> merchantVerify(Long merchantId, Long storeId) {
        Application application = approvedApplication(merchantId, storeId);
        if (application == null) throw new BusinessException(ErrorCode.CONFLICT, "商户进件尚未审核通过,暂时无法收款");
        if (application.huifuId().isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "商户进件缺少斗拱商户号,暂时无法收款");
        if (!providerVerificationRequired() || !huifuPaymentProperties.isEnabled()) return merchantView(merchantId, storeId);
        boolean refreshed = jdbc.update("""
                update merchant_onboarding_application
                set capability_sync_until = date_add(now(), interval ? minute)
                where id = ? and (capability_sync_until is null or capability_sync_until < now())
                """, MERCHANT_VERIFY_COOLDOWN_MINUTES, application.id()) > 0;
        if (refreshed) queryProviderOutsideTransaction(application);
        Map<String, Object> view = merchantView(merchantId, storeId);
        view.put("refreshed", refreshed);
        return view;
    }
    /** 冷却窗口是否已放开:商户端据此决定「立即核实」能不能点。 */
    private boolean verifyWindowOpen(long applicationId) {
        return !rows("""
                select id from merchant_onboarding_application
                where id = ? and (capability_sync_until is null or capability_sync_until < now())
                """, applicationId).isEmpty();
    }
    /**
@@ -305,7 +363,8 @@
                clean(row.get("merchantStat")),
                clean(row.get("autoSettleStatus")),
                clean(row.get("cashSwitchStatus")),
                clean(row.get("capabilityVerifiedAt"))
                clean(row.get("capabilityVerifiedAt")),
                clean(row.get("capabilitySyncUntil"))
        );
    }
@@ -327,7 +386,8 @@
            String merchantStatus,
            String autoSettleStatus,
            String cashSwitchStatus,
            String capabilityVerifiedAt
            String capabilityVerifiedAt,
            String capabilitySyncUntil
    ) {}
    /**
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -94,6 +94,29 @@
        return realnameService.submit(((Number)records.get(0).get("id")).longValue(),retry);
    }
    /**
     * 商户端「收款能力」自查:能不能收款、为什么不能、最近什么时候核实过。
     *
     * <p>判定与下单闸门同源同文案,只读不查通道——商户在页面上看到的失败原因,必须和下单被拒时
     * 看到的是同一句话,否则同一个事实会有两套口径。
     */
    @Transactional(readOnly = true)
    public Map<String, Object> paymentReadiness(MerchantPrincipal principal) {
        store(principal);
        return paymentReadinessService.merchantView(principal.merchantId(), principal.storeId());
    }
    /**
     * 商户端「立即核实」:主动查一次通道刷新能力快照,受冷却保护。
     *
     * <p>这里刻意不加 {@code @Transactional}:冷却窗口要在通道查询之前落库,而查询本身不能在持有
     * 该行锁的事务里进行,见 {@code MerchantPaymentReadinessService.merchantVerify}。
     */
    public Map<String, Object> verifyPaymentReadiness(MerchantPrincipal principal) {
        store(principal);
        return paymentReadinessService.merchantVerify(principal.merchantId(), principal.storeId());
    }
    public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway, HuifuMerchantWechatConfigGateway merchantWechatConfigGateway, HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway, HuifuUserBusinessOpenGateway userBusinessOpenGateway, HuifuPaymentProperties huifuPaymentProperties, HuifuPictureUploadGateway pictureUploadGateway, MerchantPaymentReadinessService paymentReadinessService) {
        this.jdbc = jdbc;
        this.sensitiveDataCrypto = sensitiveDataCrypto;
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java
@@ -53,6 +53,14 @@
    @PostMapping("/onboarding")
    public ApiResponse<Map<String, Object>> submitOnboarding(@RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal, @Valid @RequestBody MerchantOnboardingRequest request, @RequestHeader(value = "X-Idempotency-Key", required = false) String key) { return ApiResponse.success(merchantSettlementService.submitOnboarding(principal, request, key)); }
    /** 收款能力自查:与下单闸门同源同文案的判定结果与最近核实时间,只读不查通道。 */
    @GetMapping("/payment-readiness")
    public ApiResponse<Map<String, Object>> paymentReadiness(@RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal) { return ApiResponse.success(merchantSettlementService.paymentReadiness(principal)); }
    /** 立即核实:主动查一次通道刷新收款能力快照,受冷却保护。 */
    @PostMapping("/payment-readiness/verify")
    public ApiResponse<Map<String, Object>> verifyPaymentReadiness(@RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal) { return ApiResponse.success(merchantSettlementService.verifyPaymentReadiness(principal)); }
    @GetMapping("/bank-cards")
    public ApiResponse<java.util.List<Map<String, Object>>> bankCards(@RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal) { return ApiResponse.success(merchantSettlementService.bankCards(principal)); }
talent-card-server/src/main/resources/db/migration/V148__merchant_capability_sync_until.sql
New file
@@ -0,0 +1,14 @@
-- 商户端「立即核实」的冷却时间戳:手动核实一次通道查询后,在此时间之前不再重复查询。
-- 不放在 capability_verified_at:查询失败时那一列不推进,会被连点;也不放在 updated_at:
-- 那是 MySQL 的 on update current_timestamp,任何无关 UPDATE 都会顺带把它重置。
DELIMITER $$
DROP PROCEDURE IF EXISTS add_merchant_capability_sync_until$$
CREATE PROCEDURE add_merchant_capability_sync_until()
BEGIN
    DECLARE col_exists INT DEFAULT 0;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'capability_sync_until';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN capability_sync_until DATETIME DEFAULT NULL AFTER capability_verified_at; END IF;
END$$
CALL add_merchant_capability_sync_until()$$
DROP PROCEDURE IF EXISTS add_merchant_capability_sync_until$$
DELIMITER ;
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessMerchantViewTests.java
New file
@@ -0,0 +1,183 @@
package com.talentcard.server.business.merchant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mockingDetails;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Map;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityQueryCommand;
import com.talentcard.server.common.BusinessException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
/**
 * 商户端的收款能力自查({@code GET /api/merchant/payment-readiness})与手动核实
 * ({@code POST /api/merchant/payment-readiness/verify})。
 *
 * <p>锁定的行为:判定与下单闸门同源同文案、只读的判定绝不查通道、手动核实有冷却(成功与失败都
 * 节流),以及没有已通过的进件时按既有规则拒绝。
 */
@SpringBootTest
@ActiveProfiles("test")
@TestPropertySource(properties = {
        "app.payment.huifu.enabled=true",
        "app.payment.huifu.merchant-capability-required=true"
})
class MerchantPaymentReadinessMerchantViewTests {
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private MerchantPaymentReadinessService readinessService;
    @MockitoBean
    private HuifuMerchantCapabilityGateway capabilityGateway;
    private Long merchantId;
    private Long storeId;
    private Long applicationId;
    @BeforeEach
    void prepareMerchant() {
        String suffix = String.valueOf(System.nanoTime());
        jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "Readiness view merchant " + suffix);
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, "Readiness view store " + suffix);
        storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
        jdbc.update("""
                insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name,
                                                            contact_name, contact_mobile, idempotency_key, status, provider_huifu_id)
                values (?, ?, ?, 'individual', 'Readiness view owner', 'Readiness view owner', '13800000000', ?, 'approved', ?)
                """, merchantId, storeId, "MO-RDV-" + suffix, "readiness-view-" + suffix, shortReference("H", suffix));
        applicationId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class);
        jdbc.update("""
                insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked,
                                               card_hash, status, idempotency_key, provider_token_no)
                values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '6666', '**** **** **** 6666', ?, 'approved', ?, ?)
                """, merchantId, storeId, "hash-readiness-view-" + suffix, "readiness-view-card-" + suffix, shortReference("T", suffix));
        Long bankCardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class);
        jdbc.update("update merchant_onboarding_application set initial_bank_card_id=? where id=?", bankCardId, applicationId);
        jdbc.update("insert into merchant_onboarding_progress(application_id, stages_json) values (?, '{\"wechat\":\"confirmed\",\"realname\":\"confirmed\"}')", applicationId);
    }
    /** 只读的判定不查通道:快照的新鲜度由定时核实与手动核实负责。 */
    @Test
    void merchantViewNeverQueriesTheProvider() {
        stubProvider("NORMAL", "0", "1");
        readinessService.refresh(applicationId);
        int before = mockingDetails(capabilityGateway).getInvocations().size();
        Map<String, Object> view = readinessService.merchantView(merchantId, storeId);
        assertThat(view).containsEntry("ready", true).containsEntry("verifyAvailable", true);
        assertThat(String.valueOf(view.get("verifiedAt"))).isNotBlank();
        assertThat(mockingDetails(capabilityGateway).getInvocations().size()).isEqualTo(before);
    }
    /**
     * 页面上的失败原因与下单被拒的文案必须逐字一致——同一个事实不能有两套口径。
     * 这里直接拿闸门抛出的那句话来比对。
     */
    @Test
    void merchantViewReusesTheGateMessage() {
        stubProvider("FREEZEWITHDRAWAL", "0", "1");
        readinessService.refresh(applicationId);
        String viewReason = String.valueOf(readinessService.merchantView(merchantId, storeId).get("reason"));
        assertThat(viewReason).contains("FREEZEWITHDRAWAL");
        assertThatThrownBy(() -> readinessService.requireCollectingMerchant(merchantId, storeId))
                .isInstanceOf(BusinessException.class)
                .hasMessage(viewReason);
    }
    /** 进件还没通过时也打开这个页面:给结论,不查通道。 */
    @Test
    void merchantViewBlocksWithoutAnApprovedApplication() {
        jdbc.update("update merchant_onboarding_application set status='pending' where id=?", applicationId);
        Map<String, Object> view = readinessService.merchantView(merchantId, storeId);
        assertThat(view).containsEntry("ready", false).containsEntry("verifyAvailable", false);
        assertThat(String.valueOf(view.get("reason"))).contains("进件");
        assertThat(String.valueOf(view.get("verifiedAt"))).isEmpty();
        verify(capabilityGateway, never()).query(any(HuifuMerchantCapabilityQueryCommand.class));
    }
    @Test
    void merchantVerifyQueriesTheProviderOnceAndThenKeepsTheCooldown() {
        stubProvider("NORMAL", "0", "1");
        Map<String, Object> verified = readinessService.merchantVerify(merchantId, storeId);
        assertThat(verified).containsEntry("refreshed", true).containsEntry("ready", true).containsEntry("verifyAvailable", false);
        assertThat(String.valueOf(verified.get("verifiedAt"))).isNotBlank();
        verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class));
        Map<String, Object> throttled = readinessService.merchantVerify(merchantId, storeId);
        assertThat(throttled).containsEntry("refreshed", false).containsEntry("ready", true);
        verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class));
    }
    /** 通道查不通时恰恰最需要节流:失败不推进 capability_verified_at,冷却必须照样生效。 */
    @Test
    void merchantVerifyThrottlesEvenWhenTheProviderQueryFails() {
        when(capabilityGateway.query(any(HuifuMerchantCapabilityQueryCommand.class)))
                .thenReturn(HuifuMerchantCapabilityGateway.CapabilityResult.failed("99999999", "商户不存在"));
        Map<String, Object> failed = readinessService.merchantVerify(merchantId, storeId);
        assertThat(failed).containsEntry("refreshed", true).containsEntry("ready", false);
        assertThat(String.valueOf(failed.get("reason"))).contains("待确认");
        assertThat(readinessService.merchantVerify(merchantId, storeId)).containsEntry("refreshed", false);
        verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class));
    }
    @Test
    void merchantVerifyQueriesAgainAfterTheCooldownExpires() {
        stubProvider("NORMAL", "0", "1");
        readinessService.merchantVerify(merchantId, storeId);
        jdbc.update("update merchant_onboarding_application set capability_sync_until=date_sub(now(), interval 1 minute) where id=?", applicationId);
        Map<String, Object> again = readinessService.merchantVerify(merchantId, storeId);
        assertThat(again).containsEntry("refreshed", true).containsEntry("verifyAvailable", false);
        verify(capabilityGateway, times(2)).query(any(HuifuMerchantCapabilityQueryCommand.class));
    }
    /** 没有已通过的进件(或没有商户号)时不查通道:与闸门的拒绝规则一致。 */
    @Test
    void merchantVerifyRefusesWithoutAnApprovedApplication() {
        jdbc.update("update merchant_onboarding_application set status='pending' where id=?", applicationId);
        assertThatThrownBy(() -> readinessService.merchantVerify(merchantId, storeId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("进件");
        verify(capabilityGateway, never()).query(any(HuifuMerchantCapabilityQueryCommand.class));
    }
    /** Provider identifiers are limited to 32 characters and huifu ids to 18, so tests keep them short. */
    private String shortReference(String prefix, String suffix) {
        return prefix + suffix.substring(Math.max(0, suffix.length() - 8));
    }
    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"));
    }
}