package com.talentcard.server.business.merchant; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import java.util.Map; import com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameCommand; import com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameGateway; import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway; import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties; import com.talentcard.server.business.payment.huifu.HuifuPictureUploadGateway; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.SensitiveDataCrypto; 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; /** * 微信实名认证的发起、材料校验与状态闭环(法人路径)。 * *

实名是微信支付权限的前置,也是「未完成实名不允许收款」这条决策的落点。这里钉住四件事: * 材料缺项要一次说清、微信业务未确认前不发起、未决申请不重复提交、授权是否完成只看 * {@code authorize_stat}({@code FREEZED} 也可能已经授权成功)。 */ @SpringBootTest @ActiveProfiles("test") class MerchantRealnameServiceTests { @Autowired MerchantOnboardingProgressService progressBean; @Autowired JdbcTemplate jdbc; @Autowired MerchantRealnameService service; @Autowired SensitiveDataCrypto crypto; @Autowired HuifuPaymentProperties huifuProperties; @MockitoBean HuifuMerchantRealnameGateway realnameGateway; @MockitoBean HuifuOnboardingProgressGateway progressGateway; @MockitoBean HuifuPictureUploadGateway pictureUploadGateway; @Test void missingLegalMobileIsReportedBeforeAnyProviderCall() { long applicationId = fixture(null); assertThatThrownBy(() -> service.submit(applicationId, false)) .isInstanceOf(BusinessException.class) .hasMessageContaining("法人手机号"); verifyNoInteractions(realnameGateway); } /** * 材料校验里点明了「不能用联系人手机号顶替」:那张真实商户的联系人是经办人、法人是另一个人, * 混淆会把经办人的号码报给微信审核,而实名被驳回要换业务申请编号重来。 */ @Test void theLegalMobileIsNotTakenFromTheContactMobile() { long applicationId = fixture(null, "13700000000"); assertThatThrownBy(() -> service.submit(applicationId, false)) .isInstanceOf(BusinessException.class) .hasMessageContaining("不能用联系人手机号顶替"); verifyNoInteractions(realnameGateway); } @Test void realnameIsNotSubmittedBeforeTheWechatBusinessIsConfirmed() { long applicationId = fixture("13900000000", "13700000000", null); assertThatThrownBy(() -> service.submit(applicationId, false)) .isInstanceOf(BusinessException.class) .hasMessageContaining("微信支付业务开通尚未确认生效"); verifyNoInteractions(realnameGateway); } @Test void anAcceptedApplicationIsStoredAsProcessingWithItsApplymentId() { long applicationId = fixture("13900000000"); when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000", "beneficiary_info_list","[{\"bo_name\":\"张维\"}]")); when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252449","00000000","成功")); service.submit(applicationId, false); Map saved = jdbc.queryForMap("select realname_status,realname_request_no,realname_apply_no from merchant_onboarding_application where id=?", applicationId); assertThat(saved.get("realname_status")).isEqualTo("processing"); assertThat(String.valueOf(saved.get("realname_apply_no"))).isEqualTo("2000002327252449"); // 业务申请编号按申请单号派生并带随机后缀:驳回后重发起必须能换成新的,所以不能只按申请单号派生。 String applicationNo = jdbc.queryForObject("select application_no from merchant_onboarding_application where id=?", String.class, applicationId); assertThat(String.valueOf(saved.get("realname_request_no"))).startsWith(applicationNo + "RN").hasSizeLessThanOrEqualTo(32); // 法人是受益人(受益人列表里有法人本人),owner 应取 Y。 var command = org.mockito.ArgumentCaptor.forClass(com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameCommand.class); verify(realnameGateway).submit(anyString(),eq("6666000241335656"),command.capture()); assertThat(command.getValue().legalIsBeneficiary()).isTrue(); assertThat(command.getValue().legalName()).isEqualTo("张维"); assertThat(command.getValue().legalMobile()).isEqualTo("13900000000"); } /** 未决申请不重复提交:重复点击只会去查询通道,不会再报一份实名申请。 */ @Test void aPendingApplicationIsNeverSubmittedTwice() { long applicationId = fixture("13900000000"); when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000")); when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252449","00000000","成功")); service.submit(applicationId, false); service.submit(applicationId, false); service.submit(applicationId, true); verify(realnameGateway,times(1)).submit(anyString(),anyString(),any()); } /** 通道明确拒绝才允许换新申请单号重来,且新号必须与旧号不同。 */ @Test void aRejectedApplicationCanBeResubmittedWithANewRequestNumber() { long applicationId = fixture("13900000000"); when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000")); when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(false,"","00000001","基础参数校验失败")); service.submit(applicationId, false); assertThat(jdbc.queryForObject("select realname_status from merchant_onboarding_application where id=?",String.class,applicationId)).isEqualTo("failed"); assertThatThrownBy(() -> service.submit(applicationId, false)).hasMessageContaining("上次实名申请未通过"); when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252450","00000000","成功")); service.submit(applicationId, true); var requests = org.mockito.ArgumentCaptor.forClass(String.class); verify(realnameGateway,times(2)).submit(requests.capture(),anyString(),any()); assertThat(requests.getAllValues()).doesNotHaveDuplicates(); } /** 结果未知时保持未决,不能回退成可重提交,否则会给同一商户报两份实名申请。 */ @Test void anUnknownOutcomeStaysPendingAndBlocksResubmission() { long applicationId = fixture("13900000000"); when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000")); when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); when(realnameGateway.submit(anyString(),anyString(),any())).thenThrow(new com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException("timeout",true,null)); service.submit(applicationId, false); assertThat(jdbc.queryForObject("select realname_status from merchant_onboarding_application where id=?",String.class,applicationId)).isEqualTo("processing"); service.submit(applicationId, true); verify(realnameGateway,times(1)).submit(anyString(),anyString(),any()); } /** * 个人(小微)主体的实名要带齐门店材料:官方在实名的小微路径上另收门店名称、门头照、 * 店内环境照、门店地址与省市编码、以及身份证正反面。原先这一组一个都没发, * 而且这一条路径上缺材料是被通道驳回——驳回要换业务申请编号重来。 */ @Test void anIndividualMerchantCarriesItsStoreMaterial() { long applicationId = individualFixture(); when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000")); when(pictureUploadGateway.uploadUrl(anyString(),anyString())).thenReturn("file-id"); when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252449","00000000","成功")); service.submit(applicationId, false); var command = org.mockito.ArgumentCaptor.forClass(com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameCommand.class); verify(realnameGateway).submit(anyString(),eq("6666000241335656"),command.capture()); HuifuMerchantRealnameCommand.MicroStore store = command.getValue().microStore(); assertThat(store).isNotNull(); assertThat(store.storeName()).isEqualTo("张维的餐饮店"); assertThat(store.storeAddress()).isEqualTo("成都市成华区东丽街138号"); // 门店省市编码取进件时选的经营市编码。 assertThat(store.storeAddressCode()).isEqualTo("510100"); assertThat(store.microBizType()).isEqualTo(huifuProperties.getMerchantMicroBusinessType().trim()); // 四张图各按官方文件类型上报:F22 门头照、F24 店内环境照、F40/F41 身份证正反面。 verify(pictureUploadGateway).uploadUrl(anyString(),eq("F22")); verify(pictureUploadGateway).uploadUrl(anyString(),eq("F24")); verify(pictureUploadGateway).uploadUrl(anyString(),eq("F40")); verify(pictureUploadGateway).uploadUrl(anyString(),eq("F41")); assertThat(store.storeHeaderFileId()).isEqualTo("file-id"); assertThat(store.idBackFileId()).isEqualTo("file-id"); } /** 企业主体的实名不带小微门店材料,也不该为它去传门店照片。 */ @Test void anEnterpriseMerchantCarriesNoStoreMaterial() { long applicationId = fixture("13900000000"); when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000")); when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252449","00000000","成功")); service.submit(applicationId, false); var command = org.mockito.ArgumentCaptor.forClass(com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameCommand.class); verify(realnameGateway).submit(anyString(),anyString(),command.capture()); assertThat(command.getValue().microStore()).isNull(); verifyNoInteractions(pictureUploadGateway); } /** * 个人主体缺门店材料时一次说清,不提交注定被驳回的申请——实名被驳回要换业务申请编号重来, * 而商户端下次还得从头把资料填一遍。 */ @Test void missingStoreMaterialIsReportedBeforeAnyProviderCall() { long applicationId = individualFixture(); jdbc.update("update merchant_onboarding_application set store_indoor_url=null where id=?", applicationId); assertThatThrownBy(() -> service.submit(applicationId, false)) .isInstanceOf(BusinessException.class) .hasMessageContaining("店内环境照"); verifyNoInteractions(realnameGateway); verifyNoInteractions(pictureUploadGateway); } /** * 授权是否完成只看 {@code authorize_stat}:{@code PASSED} 单独不算完成,而 {@code FREEZED} * (「该主体已完成过入驻」)也可能已经授权成功——真实样本就是 FREEZED + authorize_stat=1, * 按申请单状态判会把它显示成待办理。 */ @Test void authorizationIsDecidedByAuthorizeStatNotByTheApplicationState() { MerchantOnboardingProgressService progress = progressBean; assertThat(stageOf(progress,"APPLYMENT_STATE_PASSED","1")).isEqualTo("confirmed"); assertThat(stageOf(progress,"APPLYMENT_STATE_PASSED","0")).isEqualTo("action_required"); assertThat(stageOf(progress,"APPLYMENT_STATE_FREEZED","1")).isEqualTo("confirmed"); assertThat(stageOf(progress,"APPLYMENT_STATE_FREEZED","0")).isEqualTo("action_required"); assertThat(stageOf(progress,"APPLYMENT_STATE_NOAPPLY","0")).isEqualTo("missing"); assertThat(stageOf(progress,"APPLYMENT_STATE_REJECTED","0")).isEqualTo("failed"); assertThat(stageOf(progress,"APPLYMENT_STATE_WAITTING_FOR_AUDIT","0")).isEqualTo("pending"); } private String stageOf(MerchantOnboardingProgressService progress, String applymentStat, String authorizeStat) { long applicationId = fixture("13900000000"); 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.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat",applymentStat,"authorize_stat",authorizeStat)); Map view = progress.refresh(applicationId, false); return stageStatus(view,"realname"); } /** * 授权二维码要能直接当图片用:通道回的是**裸 base64**,且是 JPEG 还是 PNG 并不固定 * ——实测小微实名的码就是 JPEG({@code /9j/4AAQSkZJRg…})。原先这里写死了 PNG 的 magic * 并统一标成 {@code image/png},于是 JPEG 的码被判非法、返回空串,两端页面都是空白, * 而"本人扫码授权"是完成实名的唯一途径(2026-09-16 实测:申请单已受理、通道已回码,页面却空)。 */ @Test void theAuthorizationQrCodeIsExposedAsADataUrlWhicheverFormatTheChannelReturns() { assertThat(qrCodeOf("iVBORw0KGgoAAAANSUhEUg==")).startsWith("data:image/png;base64,"); assertThat(qrCodeOf("/9j/4AAQSkZJRgABAQAAAQABAAD")).startsWith("data:image/jpeg;base64,"); // 已经带前缀的也认,不重复叠加。 assertThat(qrCodeOf("data:image/jpeg;base64,/9j/4AAQSkZJRgAB")).isEqualTo("data:image/jpeg;base64,/9j/4AAQSkZJRgAB"); // 不是图片的 base64 一律不拼进 img src。 assertThat(qrCodeOf("QUJDREVG")).isEmpty(); assertThat(qrCodeOf("")).isEmpty(); } /** 走一遍刷新,取 realname 阶段实际暴露给前端的 qrCode。 */ @SuppressWarnings("unchecked") private String qrCodeOf(String qrcodeData) { long applicationId = fixture("13900000000"); 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.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_WAITTING_FOR_CONFIRM_CONTACT","authorize_stat","0","qrcode_data",qrcodeData)); Map view = progressBean.refresh(applicationId,false); for (Object item : (java.util.List) view.get("stages")) { Map stage = (Map) item; if ("realname".equals(stage.get("key"))) return String.valueOf(stage.get("qrCode")); } throw new IllegalStateException("realname stage not found"); } @SuppressWarnings("unchecked") private static String stageStatus(Map view, String key) { for (Object item : (java.util.List) view.get("stages")) { Map stage = (Map) item; if (key.equals(stage.get("key"))) return String.valueOf(stage.get("status")); } throw new IllegalStateException("stage not found: " + key); } private long fixture(String legalMobile) { return fixture(legalMobile, "13700000000", WECHAT_CONFIRMED); } private long fixture(String legalMobile, String contactMobile) { return fixture(legalMobile, contactMobile, WECHAT_CONFIRMED); } /** 进度里「微信支付」已确认;传 null 表示还没有进度记录,微信业务开通未确认。 */ private static final String WECHAT_CONFIRMED = "{\"wechat\":\"confirmed\"}"; /** * 建一张走分阶段模型的进件申请:结算卡已关联、法人资料按实测那张商户的形状填 * ——法人与联系人**不是同一个人**(联系人阳野、法人张维)。 */ private long fixture(String legalMobile, String contactMobile, String stagesJson) { String key = "RN-" + 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,subject_id_no_masked,subject_id_no_encrypted,legal_certificate_address,legal_mobile,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id) values (?,?,?,'enterprise','张维','513********4667',?,'成都市成华区东丽街138号9栋2单元16楼5号',?,'阳野',?,?,'pending','6666000241335656','original-apply',?)", merchant, store, key, crypto.encrypt("513021198109304667"), legalMobile, contactMobile, key, card); Long applicationId = jdbc.queryForObject("select id from merchant_onboarding_application where application_no=?", Long.class, key); jdbc.update("insert into merchant_onboarding_progress(application_id,stages_json) values (?,?)", applicationId, stagesJson == null ? "{}" : stagesJson); return applicationId; } /** * 建一张个人(小微)主体的进件申请:门店照片、身份证照片、经营市编码都已落库, * 商家档案上也有门店名称与地址,进度里微信支付已确认(实名的前置)。 */ private long individualFixture() { String key = "RNI-" + 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,address,status,deleted) values (?,?,?,1,0)", merchant, "张维的餐饮店", "成都市成华区东丽街138号"); 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,subject_id_no_masked,subject_id_no_encrypted,registered_city_code,registered_address,legal_mobile,contact_name,contact_mobile,store_header_url,store_indoor_url,legal_id_front_url,legal_id_back_url,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id) values (?,?,?,'individual',?,?,?,?,?,?,?,?,?,?,?,?,?,'pending','6666000241335656','original-apply',?)", merchant, store, key, "张维", "513********4667", crypto.encrypt("513021198109304667"), "510100", "成都市成华区东丽街138号9栋2单元16楼5号", "13900000000", "张维", "13700000000", "/uploads/store-header.jpg", "/uploads/store-indoor.jpg", "/uploads/id-front.jpg", "/uploads/id-back.jpg", key, card); Long applicationId = jdbc.queryForObject("select id from merchant_onboarding_application where application_no=?", Long.class, key); jdbc.update("insert into merchant_onboarding_progress(application_id,stages_json) values (?,?)", applicationId, WECHAT_CONFIRMED); return applicationId; } }