From 0d9afc5aadbe4dcb1b934523d7bf90c834fad407 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期一, 14 九月 2026 21:21:58 +0800
Subject: [PATCH] fix(merchant): 进件重试时把银行卡一起收口,否则同一张卡仍被拦

---
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java           |   12 ++++--
 talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingRetryReleaseTests.java |  103 +++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 111 insertions(+), 4 deletions(-)

diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
index 70cb9a3..9e55e3d 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
+++ b/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"))); }
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingRetryReleaseTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingRetryReleaseTests.java
new file mode 100644
index 0000000..55ece67
--- /dev/null
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingRetryReleaseTests.java
@@ -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);
+    }
+}

--
Gitblit v1.7.1