yeg
2026-09-14 0d9afc5aadbe4dcb1b934523d7bf90c834fad407
fix(merchant): 进件重试时把银行卡一起收口,否则同一张卡仍被拦

上一版只把失败的进件标成 rejected,漏了它的银行卡。银行卡校验是独立的一条:

if ("pending".equals(existingStatus) || !linkedActiveOnboardings.isEmpty()) {
throw ... "该银行卡已有审核中的进件或已通过的申请,请勿重复提交";
}

所以进件虽然放行了,商户用同一张卡重新提交仍会被拦——实测就是这个报错。
那张卡停在 pending,本来就是这条失败的进件留下的,两者必须一起收口。

改动:closeRetryableApplication 同时把该进件绑定的银行卡按失败收口
(限定 status in ('pending','approved'),不碰已 rejected 的),再收口进件本身。
方法改为包级可见以便直接断言。

新增 MerchantOnboardingRetryReleaseTests 3 项(微信配置失败、业务开通失败都要连卡放行;
进行中的申请必须原样不动),连同相关测试共 12 项通过。
1个文件已添加
1个文件已修改
115 ■■■■■ 已修改文件
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingRetryReleaseTests.java 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -554,16 +554,20 @@
    private static String cleanStatus(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
    /**
     * 商户重新提交时,把上一次「通道侧已明确失败」的进件收口成 rejected。
     * 商户重新提交时,把上一次「通道侧已明确失败」的进件连同它的银行卡一起收口。
     *
     * <p>目的是解开两处占用:按门店查 active 进件的判断,以及银行卡「已有审核中/已通过的申请」的校验,
     * 否则商户连同一张卡都不能用。只处理可重试的那一条,真正的进行中申请不受影响。
     * <p>只把进件标成 rejected 是不够的:银行卡校验是独立的一条,
     * `status='pending'` 的卡本身就足以拦住复用("该银行卡已有审核中的进件或已通过的申请")。
     * 而那张卡之所以停在 pending,正是这条失败的进件留下的,所以两者必须一起收口,
     * 否则商户连同一张卡都不能用——实际就踩到过这个坑。
     */
    private void closeRetryableApplication(MerchantPrincipal principal) {
    void closeRetryableApplication(MerchantPrincipal principal) {
        List<Map<String, Object>> applications = rows("select * from merchant_onboarding_application where merchant_id = ? and store_id = ? and initial_bank_card_id is not null and status in ('pending','approved') order by id desc limit 1", principal.merchantId(), principal.storeId());
        if (applications.isEmpty()) return;
        Map<String, Object> application = applications.get(0);
        if (!retryable(application)) return;
        Object bankCardId = application.get("initialBankCardId");
        jdbc.update("update merchant_bank_card set status='rejected', reject_reason=?, reviewed_at=now() where id=? and status in ('pending','approved')", "通道侧配置失败,商户重新提交进件", bankCardId);
        jdbc.update("update merchant_onboarding_application set status='rejected', reject_reason=?, reviewed_at=now() where id=?", "通道侧配置失败,商户重新提交进件", application.get("id"));
    }
    private Map<String, Object> bankCard(Map<String, Object> row) { return Map.of("id", String.valueOf(row.get("id")), "bankName", row.get("bankName"), "branchName", row.get("branchName"), "cardMasked", row.get("cardMasked"), "status", row.get("status"), "reason", clean(row.get("rejectReason")), "createdAt", String.valueOf(row.get("createdAt"))); }
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingRetryReleaseTests.java
New file
@@ -0,0 +1,103 @@
package com.talentcard.server.business.merchant;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Map;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
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;
/**
 * 通道侧配置失败的进件,重新提交时要连银行卡一起收口。
 *
 * <p>只把进件标成 rejected 不够:银行卡校验是独立的一条,`status='pending'` 的卡本身就足以拦住复用,
 * 报「该银行卡已有审核中的进件或已通过的申请,请勿重复提交」。那张卡之所以停在 pending,
 * 正是这条失败的进件留下的——实际就踩到过这个坑,所以两者必须一起收口。
 *
 * <p>反过来,**进行中**的申请(配置还在跑或还没开始)不能被动到,否则正常流程会被重复提交搅乱。
 */
@SpringBootTest
@ActiveProfiles("test")
class MerchantOnboardingRetryReleaseTests {
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private MerchantSettlementService settlementService;
    private Long merchantId;
    private Long storeId;
    private Long applicationId;
    private Long bankCardId;
    private String suffix;
    @BeforeEach
    void prepare() {
        suffix = String.valueOf(System.nanoTime());
        jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "Retry 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, "Retry store " + suffix);
        storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
    }
    @Test
    void aFailedProvisioningReleasesBothTheApplicationAndItsBankCard() {
        seedApplication("failed", "not_started");
        settlementService.closeRetryableApplication(principal());
        assertThat(status("merchant_onboarding_application", applicationId)).isEqualTo("rejected");
        // 关键:卡也要放行,否则商户用同一张卡重新提交仍会被拦。
        assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("rejected");
    }
    @Test
    void aFailedBusinessOpenAlsoReleasesTheCard() {
        seedApplication("approved", "failed");
        settlementService.closeRetryableApplication(principal());
        assertThat(status("merchant_onboarding_application", applicationId)).isEqualTo("rejected");
        assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("rejected");
    }
    @Test
    void provisioningStillInFlightIsLeftAlone() {
        seedApplication("processing", "not_started");
        settlementService.closeRetryableApplication(principal());
        assertThat(status("merchant_onboarding_application", applicationId)).isEqualTo("pending");
        assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("pending");
    }
    private void seedApplication(String wechatConfigStatus, String businessOpenStatus) {
        String applicationNo = "MO-RT-" + suffix;
        jdbc.update("""
                insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name,
                                                            contact_name, contact_mobile, idempotency_key, status,
                                                            wechat_config_status, business_open_status)
                values (?, ?, ?, 'individual', 'Retry owner', 'Retry owner', '13700000001', ?, 'pending', ?, ?)
                """, merchantId, storeId, applicationNo, "retry-onboarding-" + suffix, wechatConfigStatus, businessOpenStatus);
        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_request_no)
                values (?, ?, '中国建设银行', '广安人民路支行', '511600', '0924', '**** **** **** 0924', ?, 'pending', ?, ?)
                """, merchantId, storeId, "hash-retry-" + suffix, "retry-card-" + suffix, applicationNo);
        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);
    }
    private MerchantPrincipal principal() { return new MerchantPrincipal(0L, merchantId, storeId, "operator"); }
    private String status(String table, Long id) {
        return jdbc.queryForObject("select status from " + table + " where id = ?", String.class, id);
    }
}