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; /** * 通道侧配置失败的进件**不再关单重来**:保留原商户号和银行卡,失败的步骤由刷新重试。 * *

原先的做法是把进件和银行卡一起标成 rejected,好让商户用同一张卡整单重提;规格已明确改成 * 「基本进件成功后的局部失败保留原商户号和银行卡」。这类申请现在走分阶段模型, * {@code closeRetryableApplication} 对它只刷新进度、不释放任何东西。 * *

例外是被通道**明确驳回基本资料**的申请:它交回旧的单状态模型({@code supports()} 对 * {@code rejected} 返回 false),商户端才拿得到「重新提交」入口。 */ @SpringBootTest @ActiveProfiles("test") class MerchantOnboardingRetryReleaseTests { @Autowired private JdbcTemplate jdbc; @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; private Long applicationId; private Long bankCardId; private String suffix; @BeforeEach void prepare() { suffix = String.valueOf(System.nanoTime()); jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "Retry merchant " + suffix); merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class); jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, "Retry store " + suffix); storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class); } @Test void aFailedProvisioningKeepsTheApplicationAndItsBankCard() { seedApplication("failed", "not_started"); stubProvider(); settlementService.closeRetryableApplication(principal()); // 关键变化:不再释放。进件与银行卡都留着,失败的开通步骤由刷新去重试—— // 释放掉就等于让商户整单重来,与「保留原商户号和银行卡」相悖。 assertThat(status("merchant_onboarding_application", applicationId)).isEqualTo("pending"); assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("pending"); } @Test void aFailedBusinessOpenKeepsTheCardForRetry() { seedApplication("approved", "failed"); stubProvider(); settlementService.closeRetryableApplication(principal()); assertThat(status("merchant_onboarding_application", applicationId)).isEqualTo("pending"); assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("pending"); } @Test void provisioningStillInFlightIsLeftAlone() { seedApplication("processing", "not_started"); settlementService.closeRetryableApplication(principal()); assertThat(status("merchant_onboarding_application", applicationId)).isEqualTo("pending"); 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 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 + "-" + 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, wechat_config_status, business_open_status) values (?, ?, ?, 'individual', 'Retry owner', 'Retry owner', '13700000001', ?, 'pending', ?, ?) """, merchantId, storeId, applicationNo, "retry-onboarding-" + suffix, wechatConfigStatus, businessOpenStatus); 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, card_hash, status, idempotency_key, provider_request_no) values (?, ?, '中国建设银行', '广安人民路支行', '511600', '0924', '**** **** **** 0924', ?, 'pending', ?, ?) """, merchantId, storeId, "hash-retry-" + suffix, "retry-card-" + suffix, applicationNo); bankCardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class); jdbc.update("update merchant_onboarding_application set initial_bank_card_id=? where id=?", bankCardId, applicationId); } private MerchantPrincipal principal() { return new MerchantPrincipal(0L, merchantId, storeId, "operator"); } private String status(String table, Long id) { return jdbc.queryForObject("select status from " + table + " where id = ?", String.class, id); } }