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 +++++++++++++++++++++++++++++----------
 1 files changed, 58 insertions(+), 20 deletions(-)

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