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