yeg
2026-09-16 242917765d2711b723e98764396054acef2a7297
feat(merchant): 分阶段开通对所有商户生效

放开 04 保留的那道灰度门槛:supports() 从「huifu_id 等于某张指定商户」改为
「已关联结算银行卡且未被整体拒绝」。新商户从此走进同一套分阶段视图。

这是策略变更而非清理,连带处理三件事:

- 基本资料被通道驳回时落到 rejected。分阶段刷新原来只会写 pending/approved,
永远不会写 rejected——那样审核被拒的商户拿不到「重新提交」入口(supports() 对
rejected 返回 false 正是为了让出旧模型的那个入口)。后续配置失败不算拒绝,
仍然保留原商户号和银行卡做局部重试。
- refresh 对不支持的申请直接返回视图,不接管。否则一条迟到或重复的刷新会把它从
rejected 翻回 pending,重提入口随之消失。
- 视图的 status 不再只看阶段计算:已 rejected 的申请显示 rejected 并带通道给的
驳回原因,不能被阶段计算盖成 pending。

测试随之改写——原来那 5 项断言的是「通道配置失败就释放银行卡、让商户整单重来」的
旧策略,与规格「基本进件成功后的局部失败保留原商户号和银行卡」相悖:
- MerchantOnboardingRetryReleaseTests:改为断言进件与银行卡都被保留、失败的步骤由
刷新重试;新增一项钉住「被拒绝的申请交回旧模型」。
- MerchantSettlementCallbackIdempotencyTests:回调现在只触发刷新、状态由通道证据
推导,幂等性来自刷新的可重复性;token 也不再由回调写入,而是刷新时按卡号 hash
与通道卡列表核对得出。三项断言相应改写,保证本身(重复通知不改变状态、迟到通知
不降级、token 不被覆盖)都保留。

验证:全量 214 项,仅 docs/agents/testing.md 所列 3 项基线失败,无新增。
3个文件已修改
175 ■■■■■ 已修改文件
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingRetryReleaseTests.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantSettlementCallbackIdempotencyTests.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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;
    }
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,
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);