From 242917765d2711b723e98764396054acef2a7297 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期三, 16 九月 2026 00:47:50 +0800
Subject: [PATCH] feat(merchant): 分阶段开通对所有商户生效

---
 talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantSettlementCallbackIdempotencyTests.java |   78 +++++++++++++++++++------
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java          |   38 +++++++++---
 talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingRetryReleaseTests.java        |   59 ++++++++++++++-----
 3 files changed, 129 insertions(+), 46 deletions(-)

diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
index 07fe25b..69ba5eb 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
@@ -16,8 +16,6 @@
 /** Six independent evidence-backed stages; provider calls never hold application row locks. */
 @Service
 public class MerchantOnboardingProgressService {
-    /** 分阶段进度的灰度门槛;03 票把它换成通用规则时一并删除。 */
-    public static final String RECOVERY_HUIFU_ID = "6666000241335656";
     private static final Map<String,String> LABELS = new LinkedHashMap<>();
     static {
         LABELS.put("basic", "基础审核"); LABELS.put("wechat", "微信支付");
@@ -29,20 +27,28 @@
     public MerchantOnboardingProgressService(JdbcTemplate jdbc, HuifuOnboardingProgressGateway gateway) { this.jdbc=jdbc; this.gateway=gateway; }
 
     /**
-     * 分阶段进度当前只对指定存量商户放行。
+     * 分阶段进度适用于「已关联结算银行卡、且未被整体拒绝」的申请。
      *
-     * <p><b>这仍是一道灰度门槛,不是通用规则。</b>把判断换成「有结算卡且未被拒绝」会同时给所有商户
-     * 切换行为:进件失败不再释放银行卡(`MerchantOnboardingRetryReleaseTests`)、回调改走分阶段刷新
-     * (`MerchantSettlementCallbackIdempotencyTests`),共 5 个测试会红。那是策略变更,属于 03 票
-     * 「分阶段手动推进 + 局部失败保留原商户号和银行卡」,不在 04 的清理范围内。
+     * <p>原先这里按 huifu_id 白名单只放行一张指定存量商户,新商户走不进这套视图。现在按申请自身的
+     * 状态判断:银行卡关联是分阶段视图核实原卡的前提({@code application()} 也用它做条件);
+     * 而被拒绝的申请仍留在旧的单状态模型,商户端才有重新提交的入口。
+     *
+     * <p>这一条同时决定「进件失败后不重建商户与银行卡」的适用范围:支持的申请走局部刷新与重试,
+     * 不支持的才允许关闭重来。旧策略里「通道配置失败就释放银行卡、让商户整单重提」已被规格
+     * 明确取代——基本进件成功后要保留原商户号和银行卡。
      */
-    public boolean supports(Map<String,Object> application) { return RECOVERY_HUIFU_ID.equals(text(application.get("providerHuifuId"))); }
+    public boolean supports(Map<String,Object> application) {
+        return application.get("initialBankCardId") != null && !"rejected".equals(text(application.get("status")));
+    }
 
     public Map<String,Object> view(Long id) { return view(application(id)); }
 
     @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED)
     public Map<String,Object> refresh(Long applicationId, boolean retry) {
         Map<String,Object> application=application(applicationId);
+        // 被拒绝的申请不归分阶段模型管:刷下去会把它从 rejected 翻回 pending,商户端的
+        // 「重新提交」入口就没了(supports() 对 rejected 返回 false,正是为了让出那个入口)。
+        if (!supports(application)) return view(application);
         jdbc.update("insert ignore into merchant_onboarding_progress(application_id,stages_json) values (?, '{}')", applicationId);
         String owner=UUID.randomUUID().toString();
         if(jdbc.update("update merchant_onboarding_progress set sync_owner=?,sync_until=date_add(now(),interval 5 minute) where application_id=? and (sync_owner is null or sync_until<now())", owner, applicationId)==0) return view(application);
@@ -123,11 +129,17 @@
                     advanceDelay(applicationId,text(application.get("providerHuifuId")),owner,stages,retry);
                 }
                 jdbc.update("update merchant_onboarding_progress set stages_json=? where application_id=? and sync_owner=?",JSON.toJSONString(stages),applicationId,owner);
+                // 基本资料被通道驳回时要落到 rejected:一是让商户端出现「重新提交」入口(supports() 对
+                // rejected 返回 false,交回旧的单状态模型),二是别把一次明确拒绝一直显示成「审核中」。
+                // 后续配置失败不算拒绝——那种情况保留原商户号和银行卡,局部重试即可。
+                boolean basicRejected = "failed".equals(text(stages.get("basic")));
+                String applicationStatus = basicRejected ? "rejected" : complete(stages) ? "approved" : "pending";
                 jdbc.update("""
                         update merchant_onboarding_application a join merchant_onboarding_progress p on p.application_id=a.id
                         set a.status=?,a.reviewed_at=case when ? then now() else null end
                         where a.id=? and p.sync_owner=?
-                        """,complete(stages) ? "approved" : "pending",complete(stages),applicationId,owner);
+                        """,applicationStatus,!"pending".equals(applicationStatus),applicationId,owner);
+                if (basicRejected) jdbc.update("update merchant_onboarding_application set reject_reason=? where id=?","基本资料审核未通过,请核对资料后重新提交",applicationId);
             }
         } catch (RuntimeException exception) {
             jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?","查询结果暂未确认,已保留最后有效进度,请稍后刷新",applicationId,owner);
@@ -225,10 +237,14 @@
             progress.add(Map.of("key",entry.getKey(),"label",entry.getValue(),"status",status,"reason",stageReason(entry.getKey(),status),
                     "qrCode", "realname".equals(entry.getKey()) ? text(stages.get("realnameQr")) : ""));
         }
+        // 被通道明确驳回基本资料的申请仍显示 rejected(并带通道给的驳回原因),不能被阶段计算
+        // 盖成 pending——那样商户端看不到「重新提交」。
+        boolean rejected = "rejected".equals(text(application.get("status")));
         result.put("stages",progress); result.put("reason","请刷新进度,核实原申请与各项能力");
         result.put("retryable", "failed".equals(stages.get("delay")));
-        result.put("status",complete(stages) ? "approved" : "pending");
-        result.put("reason",complete(stages) ? "收款能力已核实,沿用原商户号和结算银行卡" : "请刷新进度核实各项能力,并按待办理项补齐缺失能力");
+        result.put("status",rejected ? "rejected" : complete(stages) ? "approved" : "pending");
+        result.put("reason",rejected ? text(application.get("rejectReason"))
+                : complete(stages) ? "收款能力已核实,沿用原商户号和结算银行卡" : "请刷新进度核实各项能力,并按待办理项补齐缺失能力");
         return result;
     }
 
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
index 55ece67..8281e05 100644
--- 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
@@ -1,25 +1,28 @@
 package com.talentcard.server.business.merchant;
 
 import static org.assertj.core.api.Assertions.assertThat;
-
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
 import java.util.Map;
-
 import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
+import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
 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.bean.override.mockito.MockitoBean;
 
 /**
- * 通道侧配置失败的进件,重新提交时要连银行卡一起收口。
+ * 通道侧配置失败的进件**不再关单重来**:保留原商户号和银行卡,失败的步骤由刷新重试。
  *
- * <p>只把进件标成 rejected 不够:银行卡校验是独立的一条,`status='pending'` 的卡本身就足以拦住复用,
- * 报「该银行卡已有审核中的进件或已通过的申请,请勿重复提交」。那张卡之所以停在 pending,
- * 正是这条失败的进件留下的——实际就踩到过这个坑,所以两者必须一起收口。
+ * <p>原先的做法是把进件和银行卡一起标成 rejected,好让商户用同一张卡整单重提;规格已明确改成
+ * 「基本进件成功后的局部失败保留原商户号和银行卡」。这类申请现在走分阶段模型,
+ * {@code closeRetryableApplication} 对它只刷新进度、不释放任何东西。
  *
- * <p>反过来,**进行中**的申请(配置还在跑或还没开始)不能被动到,否则正常流程会被重复提交搅乱。
+ * <p>例外是被通道**明确驳回基本资料**的申请:它交回旧的单状态模型({@code supports()} 对
+ * {@code rejected} 返回 false),商户端才拿得到「重新提交」入口。
  */
 @SpringBootTest
 @ActiveProfiles("test")
@@ -30,6 +33,11 @@
 
     @Autowired
     private MerchantSettlementService settlementService;
+
+    // 分阶段刷新会先推进开通步骤,这几个网关都要挡住,否则测试会真的打到汇付。
+    @MockitoBean private HuifuOnboardingProgressGateway progressGateway;
+    @MockitoBean private com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway wechatConfigGateway;
+    @MockitoBean private com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway businessOpenGateway;
 
     private Long merchantId;
     private Long storeId;
@@ -47,24 +55,27 @@
     }
 
     @Test
-    void aFailedProvisioningReleasesBothTheApplicationAndItsBankCard() {
+    void aFailedProvisioningKeepsTheApplicationAndItsBankCard() {
         seedApplication("failed", "not_started");
+        stubProvider();
 
         settlementService.closeRetryableApplication(principal());
 
-        assertThat(status("merchant_onboarding_application", applicationId)).isEqualTo("rejected");
-        // 关键:卡也要放行,否则商户用同一张卡重新提交仍会被拦。
-        assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("rejected");
+        // 关键变化:不再释放。进件与银行卡都留着,失败的开通步骤由刷新去重试——
+        // 释放掉就等于让商户整单重来,与「保留原商户号和银行卡」相悖。
+        assertThat(status("merchant_onboarding_application", applicationId)).isEqualTo("pending");
+        assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("pending");
     }
 
     @Test
-    void aFailedBusinessOpenAlsoReleasesTheCard() {
+    void aFailedBusinessOpenKeepsTheCardForRetry() {
         seedApplication("approved", "failed");
+        stubProvider();
 
         settlementService.closeRetryableApplication(principal());
 
-        assertThat(status("merchant_onboarding_application", applicationId)).isEqualTo("rejected");
-        assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("rejected");
+        assertThat(status("merchant_onboarding_application", applicationId)).isEqualTo("pending");
+        assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("pending");
     }
 
     @Test
@@ -77,8 +88,26 @@
         assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("pending");
     }
 
+    /** 被驳回的申请回到旧的单状态模型,刷新不再接管它——否则会被翻回 pending,重提入口消失。 */
+    @Test
+    void aRejectedApplicationIsLeftToTheSingleStatusModel() {
+        seedApplication("failed", "failed");
+        jdbc.update("update merchant_onboarding_application set status='rejected', reject_reason='基本资料审核未通过' where id=?", applicationId);
+
+        Map<String,Object> view = settlementService.refreshProgress(applicationId, false);
+
+        assertThat(view).containsEntry("status", "rejected");
+        verifyNoInteractions(progressGateway);
+    }
+
+    private void stubProvider() {
+        when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
+        when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"));
+        when(progressGateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000"));
+    }
+
     private void seedApplication(String wechatConfigStatus, String businessOpenStatus) {
-        String applicationNo = "MO-RT-" + suffix;
+        String applicationNo = "MO-RT-" + suffix + "-" + wechatConfigStatus;
         jdbc.update("""
                 insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name,
                                                             contact_name, contact_mobile, idempotency_key, status,
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantSettlementCallbackIdempotencyTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantSettlementCallbackIdempotencyTests.java
index 0d686ca..5798be4 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantSettlementCallbackIdempotencyTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantSettlementCallbackIdempotencyTests.java
@@ -1,6 +1,8 @@
 package com.talentcard.server.business.merchant;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
 
 import java.util.HashMap;
 import java.util.Map;
@@ -11,6 +13,7 @@
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
 
 /** Duplicated and out-of-order Huifu callbacks must not corrupt an already decided onboarding. */
 @SpringBootTest
@@ -22,6 +25,13 @@
 
     @Autowired
     private MerchantSettlementService settlementService;
+
+    private static final String HUIFU_ID = "6666000000000002";
+
+    // 回调现在触发分阶段刷新,刷新又会先推进开通步骤——这几个网关都要挡住,否则测试会打到汇付。
+    @MockitoBean private com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway progressGateway;
+    @MockitoBean private com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway wechatConfigGateway;
+    @MockitoBean private com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway businessOpenGateway;
 
     private Long merchantId;
     private Long storeId;
@@ -40,9 +50,9 @@
         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)
-                values (?, ?, ?, 'individual', 'Callback owner', 'Callback owner', '13700000000', ?, 'pending')
-                """, merchantId, storeId, applicationNo, "callback-onboarding-" + suffix);
+                                                            contact_name, contact_mobile, idempotency_key, status, provider_huifu_id)
+                values (?, ?, ?, 'individual', 'Callback owner', 'Callback owner', '13700000000', ?, 'pending', ?)
+                """, merchantId, storeId, applicationNo, "callback-onboarding-" + suffix, HUIFU_ID);
         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,
@@ -53,10 +63,19 @@
         jdbc.update("update merchant_onboarding_application set initial_bank_card_id=? where id=?", bankCardId, applicationId);
     }
 
+    /**
+     * 重复的审核回调不会改变状态。
+     *
+     * <p>分阶段模型下回调不再直接写状态,而是触发一次进度刷新——状态由**通道证据**推导。所以幂等性
+     * 不再来自「只处理未决申请」,而是来自「刷新是只读且可重复的」;而一旦刷新把基本资料判成被驳回,
+     * 申请就交回旧的单状态模型,后续重复回调被那条「已决定就不再动」的守卫挡掉。
+     */
     @Test
     void repeatedAuditCallbacksKeepTheSameOnboardingState() {
+        stubBasicAudit("N", "N");
         Map<String, Object> callback = new HashMap<>();
         callback.put("req_seq_id", applicationNo);
+        callback.put("huifu_id", HUIFU_ID);
         callback.put("audit_status", "N");
         callback.put("sub_resp_code", "10000001");
         callback.put("audit_desc", "证照不清晰");
@@ -66,53 +85,72 @@
         assertThat(first).containsEntry("status", "rejected");
         assertThat(first.get("rejectReason")).isNotNull();
 
-        // The provider retries the identical notification before the late approval arrives.
         assertThat(settlementService.handleOnboardingCallback(new HashMap<>(callback))).isTrue();
         assertThat(onboardingState()).isEqualTo(first);
     }
 
+    /** 一条迟到的失败通知不能把通道已确认的结果降级:状态以刷新时的通道证据为准。 */
     @Test
-    void aLateNotificationCannotOverwriteADecidedOnboarding() {
-        jdbc.update("update merchant_onboarding_application set status='approved', provider_huifu_id='6666000000000001', reject_reason='' where id=?", applicationId);
+    void aLateNotificationCannotOverwriteAnOnboardingConfirmedByTheChannel() {
+        stubBasicAudit("Y", "S");
 
         Map<String, Object> lateRejection = new HashMap<>();
         lateRejection.put("req_seq_id", applicationNo);
+        lateRejection.put("huifu_id", HUIFU_ID);
         lateRejection.put("audit_status", "N");
         lateRejection.put("sub_resp_code", "10000001");
         lateRejection.put("audit_desc", "证照不清晰");
         assertThat(settlementService.handleOnboardingCallback(lateRejection)).isTrue();
 
-        assertThat(onboardingState())
-                .containsEntry("status", "approved")
-                .containsEntry("providerHuifuId", "6666000000000001")
-                .containsEntry("rejectReason", "");
+        Map<String, Object> state = onboardingState();
+        assertThat(state.get("status")).isNotEqualTo("rejected");
+        assertThat(java.util.Objects.toString(state.get("rejectReason"), "")).isEmpty();
     }
 
+    /**
+     * 绑卡结果以通道查询为准,重复的通过通知不会用另一个 token 覆盖已经生效的那个。
+     *
+     * <p>分阶段模型下 token 由刷新时的商户详情核对得出(卡号 hash 与 {@code qry_cash_card_info_list}
+     * 匹配上才落 token),不再由回调直接写入——回调只负责触发刷新。
+     */
     @Test
-    void anApprovalCallbackBindsTheTokenOnlyOnce() {
+    void aRepeatedApprovalCannotReplaceTheTokenConfirmedByTheChannel() {
+        stubBasicAudit("Y", "S");
+        // 卡的核对要以通道返回的绑卡状态为前提:bind_card_status=S 才会去比对卡号 hash。
+        when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S","bind_card_status","S"));
+        jdbc.update("update merchant_bank_card set card_hash=? where id=?", sha256("622200001234"), bankCardId);
+        when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N",
+                "qry_cash_card_info_list",java.util.List.of(Map.of("card_no","622200001234","token_no","TOKEN-0001")),
+                "qry_settle_config_list",java.util.List.of(Map.of("settle_status","1","settle_cycle","D1","settle_pattern","P0","min_amt","0","remained_amt","0","token_no","TOKEN-0001"))));
+
         Map<String, Object> approval = new HashMap<>();
         approval.put("req_seq_id", applicationNo);
+        approval.put("huifu_id", HUIFU_ID);
         approval.put("audit_status", "Y");
         approval.put("sub_resp_code", "00000000");
-        approval.put("resp_desc", "成功");
-        approval.put("huifu_id", "6666000000000002");
         approval.put("apply_no", "APPLY-0001");
-        approval.put("token_no", "TOKEN-0001");
-
+        approval.put("token_no", "TOKEN-9999");
         assertThat(settlementService.handleOnboardingCallback(approval)).isTrue();
-        assertThat(onboardingState())
-                .containsEntry("providerHuifuId", "6666000000000002")
-                .containsEntry("status", "pending");
         assertThat(cardState()).containsEntry("status", "approved").containsEntry("providerTokenNo", "TOKEN-0001");
 
-        // A duplicated approval carrying a different token must not replace the stored token.
         Map<String, Object> repeated = new HashMap<>(approval);
-        repeated.put("token_no", "TOKEN-9999");
+        repeated.put("token_no", "TOKEN-8888");
         assertThat(settlementService.handleOnboardingCallback(repeated)).isTrue();
 
         assertThat(cardState()).containsEntry("status", "approved").containsEntry("providerTokenNo", "TOKEN-0001");
     }
 
+    private void stubBasicAudit(String applyStatus, String regStatus) {
+        when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status",applyStatus,"reg_status",regStatus));
+        when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"));
+        when(progressGateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000"));
+    }
+
+    private static String sha256(String value) {
+        try { return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); }
+        catch (java.security.NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
+    }
+
     @Test
     void cardBindingCallbackOnlyMovesAPendingCardOnce() {
         String requestNo = jdbc.queryForObject("select provider_request_no from merchant_bank_card where id=?", String.class, bankCardId);

--
Gitblit v1.7.1