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;
|
|
/**
|
* 微信实名认证的发起、材料校验与状态闭环(法人路径)。
|
*
|
* <p>实名是微信支付权限的前置,也是「未完成实名不允许收款」这条决策的落点。这里钉住四件事:
|
* 材料缺项要一次说清、微信业务未确认前不发起、未决申请不重复提交、授权是否完成只看
|
* {@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<String,Object> 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<String,Object> 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<String,Object> view = progressBean.refresh(applicationId,false);
|
for (Object item : (java.util.List<Object>) view.get("stages")) {
|
Map<String,Object> stage = (Map<String,Object>) 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<String,Object> view, String key) {
|
for (Object item : (java.util.List<Object>) view.get("stages")) {
|
Map<String,Object> stage = (Map<String,Object>) 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;
|
}
|
}
|