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