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