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.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; @SpringBootTest @ActiveProfiles("test") class MerchantExistingOnboardingTests { @Autowired JdbcTemplate jdbc; @Autowired MerchantSettlementService service; @MockitoBean HuifuOnboardingProgressGateway gateway; // 刷新现在会先推进开通步骤(微信配置、业务开通),所以这两个网关也要挡住, // 否则测试会真的打到汇付。 @MockitoBean com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway wechatConfigGateway; @MockitoBean com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway businessOpenGateway; @Autowired org.springframework.web.context.WebApplicationContext context; @Autowired com.talentcard.server.business.admin.auth.AdminAuthService adminAuth; @Test void successfulDelayCallbackIgnoresLateFailureForTheSameAttempt() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N")); when(gateway.configureDelay(anyString(),anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_no","delay-apply")); service.refreshOnboarding(principal,false); var number=org.mockito.ArgumentCaptor.forClass(String.class); var date=org.mockito.ArgumentCaptor.forClass(String.class); verify(gateway).configureDelay(anyString(),number.capture(),date.capture()); assertThat(service.handleBusinessOpenCallback(Map.of("huifu_id","6666000241335656","req_seq_id",number.getValue(),"req_date",date.getValue(),"audit_status","Y","sub_resp_code","00000000"))).isTrue(); assertThat(service.handleBusinessOpenCallback(Map.of("huifu_id","6666000241335656","req_seq_id",number.getValue(),"req_date",date.getValue(),"audit_status","N","sub_resp_code","99999999"))).isTrue(); assertThat(service.refreshOnboarding(principal,true)).containsEntry("retryable",false); verify(gateway,times(1)).configureDelay(anyString(),anyString(),anyString()); } @Test void completesOnlyAfterEveryCapabilityAndTheOriginalCardAreConfirmed() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); jdbc.update("update merchant_bank_card set card_hash=? where merchant_id=?",sha256("622200001234"),principal.merchantId()); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S","bind_card_status","S")); var scenes=java.util.List.of("4","5","12").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.70","fee_min_amt","0.00","fee_charge_type","1","out_fee_flag","2", "wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","wx-merchant","pay_channel_id","00016904","is_default","1")))).toList(); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N","qry_wx_conf_list",scenes, "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","original-card-token")), "qry_cash_card_info_list",java.util.List.of(Map.of("card_no","622200001234","token_no","original-card-token")))); when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list",java.util.List.of(Map.of("fee_type","01","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179")))))); when(gateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_PASSED","authorize_stat","1")); assertThat(service.refreshOnboarding(principal,false)).containsEntry("status","approved"); assertThat(service.onboardingStatus(principal)).containsEntry("status","approved"); assertThat(service.bankCards(principal)).hasSize(1); verify(gateway,never()).configureDelay(anyString(),anyString(),anyString()); } 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 (Exception exception) { throw new IllegalStateException(exception); } } @Test void concurrentRefreshCannotCreateASecondDelayAttempt() throws Exception { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N")); var entered=new java.util.concurrent.CountDownLatch(1); var release=new java.util.concurrent.CountDownLatch(1); when(gateway.configureDelay(anyString(),anyString(),anyString())).thenAnswer(call -> { entered.countDown(); if (!release.await(10,java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("Test release timed out"); return Map.of("resp_code","00000000","apply_no","concurrent-apply"); }); var executor=java.util.concurrent.Executors.newSingleThreadExecutor(); try { var first=executor.submit(() -> service.refreshOnboarding(principal,false)); assertThat(entered.await(10,java.util.concurrent.TimeUnit.SECONDS)).isTrue(); service.refreshOnboarding(principal,true); release.countDown(); first.get(10,java.util.concurrent.TimeUnit.SECONDS); verify(gateway,times(1)).configureDelay(anyString(),anyString(),anyString()); } finally { release.countDown(); executor.shutdownNow(); } } @Test void retriesOnlyAnExplicitlyFailedDelayAttemptWithANewRequestNumber() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N")); when(gateway.configureDelay(anyString(),anyString(),anyString())).thenReturn(Map.of("resp_code","20002"),Map.of("resp_code","00000000","apply_no","retry-apply")); assertThat(service.refreshOnboarding(principal,false)).containsEntry("retryable",true); service.refreshOnboarding(principal,false); service.refreshOnboarding(principal,true); var requests=org.mockito.ArgumentCaptor.forClass(String.class); verify(gateway,times(2)).configureDelay(eq("6666000241335656"),requests.capture(),anyString()); assertThat(requests.getAllValues()).doesNotHaveDuplicates(); } @Test void oldBasicFailureCallbackCannotRejectAnExistingVerifiedMerchantOrCard() { Map fixture=fixture(); MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N")); service.refreshOnboarding(principal,false); service.handleOnboardingCallback(Map.of("req_seq_id",fixture.get("applicationNo"),"huifu_id","6666000241335656","audit_status","N","sub_resp_code","99999999")); assertThat(service.bankCards(principal)).allSatisfy(card -> assertThat(card.get("status")).isEqualTo("approved")); assertThat(service.onboardingStatus(principal)).containsEntry("status","pending"); } @Test void unknownDelayUpdateIsQueriedBeforeRetryAndNeverSubmittedTwice() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N")); when(gateway.configureDelay(anyString(),anyString(),anyString())).thenThrow(new com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException("timeout",true,null)); service.refreshOnboarding(principal,false); Map result=service.refreshOnboarding(principal,true); verify(gateway,times(1)).configureDelay(eq("6666000241335656"),matches("[a-f0-9]{32}"),matches("[0-9]{8}")); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","delay").containsEntry("status","pending")); assertThat(result).containsEntry("retryable",false).containsEntry("status","pending"); } @Test void failedRealnameQueryPreservesConfirmedStateAndReportsQueryFailure() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N")); when(gateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_PASSED","authorize_stat","1"),Map.of("resp_code","20002")); service.refreshOnboarding(principal,false); Map result=service.refreshOnboarding(principal,false); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","realname").containsEntry("status","confirmed")); assertThat(result.get("syncError")).isEqualTo("汇付查询权限不足(20002),请联系平台运营核实接口权限"); } @Test void realnamePassedStillNeedsAuthorizationAndControlledAppIdIsNotReady() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N")); when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list",java.util.List.of(Map.of("fee_type","01","wx_applet_app_id","wxcfee477acb54d179","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179","control_flag","Y")))))); when(gateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_PASSED","authorize_stat","0")); Map result=service.refreshOnboarding(principal,false); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","appId").containsEntry("status","action_required")); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","realname").containsEntry("status","action_required")); assertThat(result).containsEntry("status","pending"); } @Test void ordinaryReadsExposeSameProgressWithoutCallingHuifu() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","N","forced_delay_flag","N")); Map refreshed=service.refreshOnboarding(principal,false); clearInvocations(gateway); assertThat(service.onboardingStatus(principal).get("stages")).isEqualTo(refreshed.get("stages")); verifyNoInteractions(gateway); } @Test void refreshRequiresSettlementEditPermissionEvenForAnAuthenticatedAdmin() throws Exception { String account="t02admin"+System.nanoTime(); jdbc.update("insert into admin_operator(account,password_hash,display_name,role_code,status,deleted) values (?,?,?,'t02-no-rights',1,0)",account, com.talentcard.server.business.merchant.auth.MerchantPasswordHasher.encode("TestPass123"),account); String token=String.valueOf(adminAuth.login(new com.talentcard.server.business.admin.auth.AdminLoginRequest(account,"TestPass123"),account).get("accessToken")); org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup(context).build().perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/admin/management/merchant-settlement/onboardings/449/progress/refresh") .header("Authorization","Bearer "+token)).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isForbidden()); verifyNoInteractions(gateway); } @Test void configuredWechatScenesStillRequireTalentAppAndAuthorization() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); var scenes=java.util.List.of("4","5","12").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.70","fee_min_amt","0.00","fee_charge_type","1","out_fee_flag","2", "wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","wx-merchant","pay_channel_id","00016904","is_default","1")))).toList(); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N","qry_wx_conf_list",scenes)); Map result=service.refreshOnboarding(principal,false); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","wechat").containsEntry("status","confirmed")); assertThat(result).containsEntry("status","pending"); } /** * 通道**不返回** {@code fee_min_amt} 时,微信支付阶段同样要算已生效。 * *

该字段是可选的,文档写明「大于 0」,不设最低收费时我们本来就不下发它,通道也就不返回它。 * 判定原先要求它必须出现且为 0,于是真实商户的微信业务明明已经开好(三场景、费率 0.70、内扣、 * 子商户号与默认标识都在),阶段却永远停在未确认——2026-09-16 拿通道回执逐项核对才发现。 */ @Test void anAbsentMinimumFeeStillCountsAsWechatConfirmed() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); // 与真实回执同形:三个场景都不带 fee_min_amt。 var scenes=java.util.List.of("4","5","12").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.70","fee_charge_type","1","out_fee_flag","2", "wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","922609119","pay_channel_id","00016904","is_default","1")))).toList(); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N","qry_wx_conf_list",scenes)); Map result=service.refreshOnboarding(principal,false); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","wechat").containsEntry("status","confirmed")); } /** 但真设了最低收费(非 0)时仍不算生效——放宽的只是"字段缺失",不是"值不对"。 */ @Test void aNonZeroMinimumFeeKeepsTheWechatStageUnconfirmed() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); var scenes=java.util.List.of("4","5","12").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.70","fee_min_amt","1.00","fee_charge_type","1","out_fee_flag","2", "wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","922609119","is_default","1")))).toList(); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N","qry_wx_conf_list",scenes)); Map result=service.refreshOnboarding(principal,false); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","wechat").containsEntry("status","missing")); } @Test void confirmsD1OnlyFromEffectiveZeroThresholdBatchSettlement() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N", "qry_settle_config_list","[{\"settle_status\":\"1\",\"settle_cycle\":\"D1\",\"settle_pattern\":\"P0\",\"min_amt\":0,\"remained_amt\":0}]")); Map result=service.refreshOnboarding(principal,false); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","settlement").containsEntry("status","confirmed")); assertThat(result).containsEntry("status","pending"); } @Test void submittingAgainCannotReplaceDesignatedExistingMerchantAfterConfigFailure() { Map application=fixture(); jdbc.update("update merchant_onboarding_application set wechat_config_status='failed' where application_no=?",application.get("applicationNo")); Map result=service.submitOnboarding((MerchantPrincipal)application.get("principal"),null,"T02-retry-"+System.nanoTime()); assertThat(result.get("applicationNo")).isEqualTo(application.get("applicationNo")); } @Test void missingDelayFieldsKeepLastConfirmedEvidenceAndReportIncompleteQuery() { MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"),Map.of("resp_code","00000000")); service.refreshOnboarding(principal,false); Map result=service.refreshOnboarding(principal,false); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","delay").containsEntry("status","confirmed")); assertThat(result.get("syncError")).isEqualTo("汇付返回缺少延迟配置字段,已保留最后有效状态"); } @Test void capabilityQueryErrorKeepsConfirmedDelayAndReportsSyncError() { Map application=fixture(); MerchantPrincipal principal=(MerchantPrincipal)application.get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"),Map.of("resp_code","20002","resp_desc","数据权限认证失败")); service.refreshOnboarding(principal,false); Map result=service.refreshOnboarding(principal,false); assertThat(result.get("syncError")).isEqualTo("汇付查询权限不足(20002),请联系平台运营核实接口权限"); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","delay").containsEntry("status","confirmed")); } @Test void approvedBasicApplicationIsNotCompleteWithoutWechatAndFundsEvidence() { Map application=fixture(); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S","bind_card_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL")); Map result=service.refreshOnboarding((MerchantPrincipal)application.get("principal"),false); assertThat(result).containsEntry("status","pending"); assertThat((java.util.List>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","basic").containsEntry("status","confirmed")); assertThat(result.get("lastSyncedAt")).isNotEqualTo(""); } private Map fixture() { String key="T02-"+System.nanoTime(); jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)",key); Long merchant=jdbc.queryForObject("select id from merchant where merchant_name=?",Long.class,key); jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)",merchant,key); Long store=jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant); 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) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key); Long card=jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key); jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id,wechat_config_status,business_open_status) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?,'approved','approved')",merchant,store,key,key,card); return Map.of("principal",new MerchantPrincipal(1L,merchant,store,"owner"),"applicationNo",key); } /** * 通道侧已经生效的步骤不再重复提交。微信商户配置是**添加**接口(成功后不支持改删、每种参数 * 最多 5 组),业务开通是申请型接口,重复提交会在通道留下重复配置或第二份申请。 */ @Test void refreshingSkipsStepsAlreadyEffectiveOnTheChannel() { Map fixture=provisioningFixture(); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N", "qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","4","fee_rate","0.70")))); when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list", java.util.List.of(Map.of("fee_type","01","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179")))))); service.refreshOnboarding((MerchantPrincipal)fixture.get("principal"),false); assertThat(statusOf("wechat_config_status",String.valueOf(fixture.get("applicationNo")))).isEqualTo("approved"); assertThat(statusOf("business_open_status",String.valueOf(fixture.get("applicationNo")))).isEqualTo("approved"); verify(wechatConfigGateway,never()).configure(anyString(),anyString()); verify(businessOpenGateway,never()).open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString()); } /** * 顺序与闸门:先做业务开通;微信小程序配置要等**通道侧真的有微信子商户号**才发。 * *

微信子商户号由业务开通创建,但入驻是**异步**的——业务开通回 {@code 90000000}(审核中) * 并不代表子商户号已经建好,此时发配置会被「根据渠道号或微信子商户号未查到入驻记录」驳回。 * 所以闸门取通道证据({@code qry_wx_conf_list} 里出现线上场景),不取本地受理状态。 * 2026-09-16 用本地状态当闸门的真实代价就是这个报错。 */ @Test void theWechatConfigWaitsUntilTheChannelConfirmsTheWechatBusiness() { Map fixture=provisioningFixture(); String applicationNo=String.valueOf(fixture.get("applicationNo")); MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal"); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N")); when(businessOpenGateway.open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway.Result(true,"BO-APPLY","90000000","审核中")); // 第一轮:通道侧还没有微信业务 → 只提交业务开通,配置这一步根本不碰。 service.refreshOnboarding(principal,false); assertThat(statusOf("business_open_status",applicationNo)).isEqualTo("processing"); verify(wechatConfigGateway,never()).configure(anyString(),anyString()); // 保持 not_started,不写成 failed:这一步根本没试过。 assertThat(statusOf("wechat_config_status",applicationNo)).isEqualTo("not_started"); // 第二轮:通道侧已经开通线上场景 → 业务开通按已开通收口(不重复提交),配置才发出去。 when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N", "qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","4")))); when(wechatConfigGateway.configure(anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway.Result(true,"00000000","成功")); service.refreshOnboarding(principal,false); // 业务开通只提交过一次——通道已开通时不再重复提交,避免第二份申请。 verify(businessOpenGateway,times(1)).open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString()); verify(wechatConfigGateway,times(1)).configure(anyString(),anyString()); assertThat(statusOf("business_open_status",applicationNo)).isEqualTo("approved"); assertThat(statusOf("wechat_config_status",applicationNo)).isEqualTo("approved"); } /** 微信配置失败不影响业务开通的推进与收口:两步各自记状态、各自可重试。 */ @Test void aFailedWechatConfigDoesNotHoldBackTheBusinessOpen() { Map fixture=provisioningFixture(); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N", "qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","4")))); when(wechatConfigGateway.configure(anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway.Result(false,"00000001","参数非法")); service.refreshOnboarding((MerchantPrincipal)fixture.get("principal"),false); String applicationNo=String.valueOf(fixture.get("applicationNo")); // 业务开通照样收口为已开通(通道证据),不被另一步的失败牵连。 assertThat(statusOf("business_open_status",applicationNo)).isEqualTo("approved"); assertThat(statusOf("wechat_config_status",applicationNo)).isEqualTo("failed"); assertThat(statusOf("reject_reason",applicationNo)).contains("参数非法"); } /** * 延迟入账失败时把通道给的描述一起落库。 * *

这一条失败回的是 {@code 99999999}(通道的「失败:<原因>」通用码),原先只存 code, * 描述一丢就完全看不出为什么失败——2026-09-16 排查时正是卡在这里。 */ @Test void aFailedDelayAttemptKeepsTheChannelMessage() { Map fixture=fixture(); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N")); when(gateway.configureDelay(anyString(),anyString(),anyString())).thenReturn(Map.of("resp_code","99999999","sub_resp_desc","复用交易信息,上级商户号为空")); service.refreshOnboarding((MerchantPrincipal)fixture.get("principal"),false); String message=jdbc.queryForObject(""" select t.response_message from merchant_onboarding_attempt t join merchant_onboarding_application a on a.id=t.application_id where a.application_no=? and t.step='delay' """,String.class,String.valueOf(fixture.get("applicationNo"))); assertThat(message).isEqualTo("复用交易信息,上级商户号为空"); } /** * 通道按流水号去重:上一步被通道**明确驳回**后重试必须换新号,沿用旧号会被当成重复请求。 * 进件与实名走的是同一条规则,业务开通与微信配置原本是复用——2026-09-16 被驳回后要重发时才发现。 */ @Test void aFailedBusinessOpenIsRetriedWithANewRequestNumber() { Map fixture=provisioningFixture(); String applicationNo=String.valueOf(fixture.get("applicationNo")); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N")); when(businessOpenGateway.open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway.Result(false,"","00000001","wx_conf_list格式错误")); MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal"); service.refreshOnboarding(principal,false); assertThat(statusOf("business_open_status",applicationNo)).isEqualTo("failed"); service.refreshOnboarding(principal,false); var requests=org.mockito.ArgumentCaptor.forClass(String.class); verify(businessOpenGateway,times(2)).open(requests.capture(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString()); assertThat(requests.getAllValues()).doesNotHaveDuplicates(); assertThat(requests.getAllValues().get(0)).isEqualTo(applicationNo+"BO"); assertThat(statusOf("business_open_request_no",applicationNo)).isEqualTo(requests.getAllValues().get(1)); } @Test void aFailedWechatConfigIsRetriedWithANewRequestNumber() { Map fixture=provisioningFixture(); String applicationNo=String.valueOf(fixture.get("applicationNo")); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); // 通道侧已开通线上场景,配置这一步才有东西可挂(见 advanceWechatConfig 的闸门)。 when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N", "qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","4")))); when(wechatConfigGateway.configure(anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway.Result(false,"00000001","参数非法")); MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal"); service.refreshOnboarding(principal,false); assertThat(statusOf("wechat_config_status",applicationNo)).isEqualTo("failed"); service.refreshOnboarding(principal,false); var requests=org.mockito.ArgumentCaptor.forClass(String.class); verify(wechatConfigGateway,times(2)).configure(requests.capture(),anyString()); assertThat(requests.getAllValues()).doesNotHaveDuplicates(); assertThat(requests.getAllValues().get(0)).isEqualTo(applicationNo+"WC"); assertThat(statusOf("wechat_config_request_no",applicationNo)).isEqualTo(requests.getAllValues().get(1)); } private String statusOf(String column,String applicationNo) { return jdbc.queryForObject("select "+column+" from merchant_onboarding_application where application_no=?",String.class,applicationNo); } /** * 分阶段模型下重复提交不新建进件,也就不会在通道上多出一家商户。唯一的例外是补齐个人主体的 * 门店照片:那是开通微信支付与发起微信实名的硬材料,而商户端没有别的补图入口——真实开发库里 * 已经有拿到汇付商户号的个人进件停在这个形态上(有商户号、门店照片为空、业务开通没做过)。 */ @Test void resubmittingSuppliesTheMissingStorePhotosWithoutCreatingANewApplication() { Map fixture = individualProvisioningFixture(); MerchantPrincipal principal = (MerchantPrincipal) fixture.get("principal"); String applicationNo = String.valueOf(fixture.get("applicationNo")); service.submitOnboarding(principal, request("https://cdn.example.com/store-header.jpg","https://cdn.example.com/store-indoor.jpg"), "T02-store-"+System.nanoTime()); assertThat(statusOf("store_header_url",applicationNo)).isEqualTo("https://cdn.example.com/store-header.jpg"); assertThat(statusOf("store_indoor_url",applicationNo)).isEqualTo("https://cdn.example.com/store-indoor.jpg"); assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where application_no=?",Integer.class,applicationNo)).isEqualTo(1); // 没有重新进件,原汇付商户号与银行卡关系都保持。 assertThat(statusOf("provider_huifu_id",applicationNo)).isEqualTo("6666000241335656"); assertThat(jdbc.queryForObject("select initial_bank_card_id from merchant_onboarding_application where application_no=?",Long.class,applicationNo)).isNotNull(); } /** 已经有值的门店照片不会被重新提交覆盖。 */ @Test void resubmittingDoesNotOverwriteAnExistingStorePhoto() { Map fixture = individualProvisioningFixture(); MerchantPrincipal principal = (MerchantPrincipal) fixture.get("principal"); String applicationNo = String.valueOf(fixture.get("applicationNo")); jdbc.update("update merchant_onboarding_application set store_header_url='https://cdn.example.com/original.jpg' where application_no=?", applicationNo); service.submitOnboarding(principal, request("https://cdn.example.com/other.jpg","https://cdn.example.com/store-indoor.jpg"), "T02-store-"+System.nanoTime()); assertThat(statusOf("store_header_url",applicationNo)).isEqualTo("https://cdn.example.com/original.jpg"); assertThat(statusOf("store_indoor_url",applicationNo)).isEqualTo("https://cdn.example.com/store-indoor.jpg"); } private Map individualProvisioningFixture() { String key="T02I-"+System.nanoTime(); jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)",key); Long merchant=jdbc.queryForObject("select id from merchant where merchant_name=?",Long.class,key); jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)",merchant,key); Long store=jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant); 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) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key); Long card=jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key); jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id,business_open_status) values (?, ?, ?, 'individual','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?,'failed')",merchant,store,key,key,card); return Map.of("principal",new MerchantPrincipal(1L,merchant,store,"owner"),"applicationNo",key); } /** 补齐门店照片这一步只读这两项,其余字段留空即可。 */ private static MerchantOnboardingRequest request(String storeHeaderUrl,String storeIndoorUrl) { return new MerchantOnboardingRequest("individual","Owner",null,null,null,null,null,null,null,null, null,null,"510100",null,null,null,null,null,null,null,null,null,null,null,null,null, "Owner","13700000000",null,null,"510100",null,null,null,storeHeaderUrl,storeIndoorUrl); } /** 建一张还停在 not_started 的进件,用来观察刷新会不会去推进开通步骤。 */ private Map provisioningFixture() { String key="T02P-"+System.nanoTime(); jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)",key); Long merchant=jdbc.queryForObject("select id from merchant where merchant_name=?",Long.class,key); jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)",merchant,key); Long store=jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant); 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) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key); Long card=jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key); jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?)",merchant,store,key,key,card); return Map.of("principal",new MerchantPrincipal(1L,merchant,store,"owner"),"applicationNo",key); } @Test void queryPermissionErrorPreservesOriginalApplicationAndCard() { String key = "T02-" + System.nanoTime(); jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)", key); Long merchant = jdbc.queryForObject("select id from merchant where merchant_name=?", Long.class,key); jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)",merchant,key); Long store = jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant); 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) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key); Long card = jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key); jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id,wechat_config_status,business_open_status) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?,'approved','approved')",merchant,store,key,key,card); when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","20002","resp_desc","数据权限认证失败")); Map result=service.refreshOnboarding(new MerchantPrincipal(1L,merchant,store,"owner"),false); assertThat(result).containsEntry("applicationNo",key).containsEntry("status","pending").containsEntry("syncError","汇付查询权限不足(20002),请联系平台运营核实接口权限"); assertThat(service.bankCards(new MerchantPrincipal(1L,merchant,store,"owner"))).isNotNull(); assertThat(jdbc.queryForObject("select initial_bank_card_id from merchant_onboarding_application where application_no=?",Long.class,key)).isEqualTo(card); } }