package com.talentcard.server.business.admin;
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 com.talentcard.server.business.merchant.MerchantPaymentReadinessService;
import com.talentcard.server.business.merchant.MerchantSettlementService;
import com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway;
import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
import com.talentcard.server.business.payment.huifu.HuifuPictureUploadGateway;
import com.talentcard.server.common.BusinessException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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;
/**
* 外部进件商户的纳管与解绑(接口级,真实测试库 + 模拟汇付响应)。
*
*
断言只落在对外可见的行为上:纳管与解绑的输入校验与结果、纳管后该商家能通过下单闸门、
* 共用同一商户号的多个商家各自可用、重复纳管幂等、解绑的在途资金拦截,以及「纳管全程只查询不下发」。
* 不测内部 SQL 与私有方法调用顺序。
*/
@SpringBootTest
@ActiveProfiles("test")
class MerchantManagedOnboardingTests {
private static final String APPLY_NO = "HF-EXTERNAL-APPLY-001";
@Autowired JdbcTemplate jdbc;
@Autowired MerchantManagedOnboardingService service;
@Autowired MerchantSettlementService settlementService;
@Autowired MerchantPaymentReadinessService readinessService;
@Autowired MerchantSettlementAdminService adminSettlementService;
@MockitoBean HuifuOnboardingProgressGateway gateway;
// 纳管全程只查询不下发:这三个下发网关全部挡在这里,任何一次调用都会被断言抓住。
@MockitoBean HuifuMerchantWechatConfigGateway wechatConfigGateway;
@MockitoBean HuifuMerchantBusinessOpenGateway businessOpenGateway;
@MockitoBean HuifuPictureUploadGateway pictureUploadGateway;
@Test
void adoptingAChannelMerchantLetsTheStoreCollectThroughTheOrderGate() {
String huifuId = uniqueHuifuId();
Fixture fixture = merchantFixture("纳管成功");
stubConvergedChannel(huifuId, "622200001234", "external-card-token");
Map adopted = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
assertThat(adopted).containsEntry("status", "approved");
assertThat(jdbc.queryForObject("""
select source from merchant_onboarding_application where merchant_id = ? and provider_huifu_id = ?
""", String.class, fixture.merchantId(), huifuId)).isEqualTo("managed");
assertThat(jdbc.queryForObject("""
select provider_apply_no from merchant_onboarding_application where merchant_id = ? and provider_huifu_id = ?
""", String.class, fixture.merchantId(), huifuId)).isEqualTo(APPLY_NO);
// 结算卡是硬前提:卡与绑卡序列号都从通道查回来,闸门才放行。
assertThat(jdbc.queryForObject("""
select provider_token_no from merchant_bank_card where merchant_id = ? and status = 'approved'
""", String.class, fixture.merchantId())).isEqualTo("external-card-token");
// 与自助进件走完全相同的收款就绪口径。
assertThat(readinessService.requireCollectingMerchant(fixture.merchantId(), fixture.storeId()).huifuId())
.isEqualTo(huifuId);
}
/**
* 进度刷新要能吃下「只有汇付侧入驻申请单号」的进件。
*
* 通道的申请单状态查询把 {@code apply_no} 列为必填,外部进件商户没有我方发起过的申请单号;
* 纳管时把汇付侧那个号一起录入,刷新流程无需改动即可跑通,不会永远停在未确认。
*/
@Test
void progressConvergesFromTheChannelApplyNumberAlone() {
String huifuId = uniqueHuifuId();
Fixture fixture = merchantFixture("通道申请单号");
stubConvergedChannel(huifuId, "622200001234", "external-card-token");
service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
// 两次:一次是建记录前的配对核验,一次是进度刷新本身——两处都只拿这个通道申请单号定位。
verify(gateway, times(2)).queryApplication(eq(huifuId), eq(APPLY_NO));
assertThat(adoptedApplicationId(fixture.merchantId(), huifuId)).isNotNull();
}
/** 通道查不到 = 该商户不在我们渠道商的管辖范围内,纳管必须明确拒绝并给出原因。 */
@Test
void aMerchantTheChannelCannotQueryIsRejectedWithItsReason() {
String huifuId = uniqueHuifuId();
Fixture fixture = merchantFixture("通道查不到");
when(gateway.queryCapabilities(huifuId)).thenReturn(Map.of("resp_code", "20002", "resp_desc", "数据权限认证失败"));
assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("通道查不到该商户")
.hasMessageContaining("数据权限认证失败");
assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where merchant_id = ?",
Integer.class, fixture.merchantId())).isZero();
}
/** 没勾确认框不受理:名称核对是唯一防绑错的那道人工闸门。 */
@Test
void adoptingWithoutTheManualConfirmationIsRejected() {
String huifuId = uniqueHuifuId();
Fixture fixture = merchantFixture("未确认");
stubConvergedChannel(huifuId, "622200001234", "external-card-token");
assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, APPLY_NO, false))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("勾选确认");
}
/** 入驻申请单号必填:取不到就不纳管(不做不依赖申请单号的备用判定分支)。 */
@Test
void adoptingWithoutTheChannelApplyNumberIsRejected() {
String huifuId = uniqueHuifuId();
Fixture fixture = merchantFixture("缺申请单号");
assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, " ", true))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("入驻申请单号");
}
/**
* 主体名不符**只警示不硬拒**:我们这边只有一个商家名(常是简称或服务点名),通道那边是工商全称,
* 形态不对应,规则化匹配必然误判——误拒会把运营卡死且无路可绕。
*/
@Test
void aMerchantNameSharingNotASingleCharacterWarnsButStillAdopts() {
String huifuId = uniqueHuifuId();
Fixture fixture = merchantFixture("A甲乙丙丁戊己");
stubConvergedChannel(huifuId, "622200001234", "external-card-token", "上海汇付支付服务有限公司", "汇付支付");
Map preview = service.preview(fixture.merchantId(), huifuId, APPLY_NO);
assertThat(String.valueOf(preview.get("nameWarning"))).contains("没有任何相同字符");
assertThat(preview).containsEntry("channelMerchantName", "上海汇付支付服务有限公司");
// 警示不阻挡提交。
assertThat(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true)).containsEntry("status", "approved");
}
/** 名称有重合字符时不提示,避免天天喊狼来了。 */
@Test
void overlappingNamesProduceNoWarning() {
assertThat(MerchantManagedOnboardingService.nameWarning("汇付餐饮店", "上海汇付支付服务有限公司", "汇付支付")).isEmpty();
assertThat(MerchantManagedOnboardingService.nameWarning("测试商家", "上海汇付支付服务有限公司", "汇付支付")).isNotEmpty();
}
/** 同一个汇付商户号可以被多个商家分别纳管(连锁各门店共用一个收款主体),只提示不拦截。 */
@Test
void aHuifuIdAlreadyBoundToAnotherMerchantIsReportedButNotBlocked() {
String huifuId = uniqueHuifuId();
Fixture first = merchantFixture("连锁A店");
Fixture second = merchantFixture("连锁B店");
stubConvergedChannel(huifuId, "622200001234", "external-card-token");
service.adopt(first.merchantId(), huifuId, APPLY_NO, true);
// 门店名与商家名一样时不重复印一遍——多数门店就是这样,列表里不该出现「某某商户(某某商户)」。
jdbc.update("update merchant_store set store_name = ? where id = ?", first.key(), first.storeId());
Map preview = service.preview(second.merchantId(), huifuId, APPLY_NO);
assertThat(preview.get("boundMerchants"))
.asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class))
.containsExactly(first.key());
assertThat((List>) preview.get("blockers")).as("已被其他商家绑定只是提示,不能拦").isEmpty();
assertThat(service.adopt(second.merchantId(), huifuId, APPLY_NO, true)).containsEntry("status", "approved");
assertThat(readinessService.requireCollectingMerchant(second.merchantId(), second.storeId()).huifuId()).isEqualTo(huifuId);
}
/** 重复纳管同一商家是幂等的,不会产生第二条进件记录。 */
@Test
void adoptingTheSameMerchantTwiceIsIdempotent() {
String huifuId = uniqueHuifuId();
Fixture fixture = merchantFixture("幂等");
stubConvergedChannel(huifuId, "622200001234", "external-card-token");
Map first = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
Map second = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
assertThat(second.get("id")).isEqualTo(first.get("id"));
assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where merchant_id = ?",
Integer.class, fixture.merchantId())).isEqualTo(1);
assertThat(jdbc.queryForObject("select count(*) from merchant_bank_card where merchant_id = ?",
Integer.class, fixture.merchantId())).isEqualTo(1);
// 已经纳管过的商户号在核验里只说明会复用原记录,不能报成「门店已有进件」这种硬拦截。
Map preview = service.preview(fixture.merchantId(), huifuId, APPLY_NO);
assertThat(preview).containsEntry("alreadyManaged", true);
assertThat((List>) preview.get("blockers")).isEmpty();
}
/**
* 命中幂等时不能报一句「纳管成功」却什么都没发生。
*
* 被通道驳回的记录刷新救不回来({@code supports()} 对 rejected 返回 false),所以必须明确让运营
* 去解绑后重来;否则运营会以为纳管生效了,而这条记录永远收不了款。
*/
@Test
void readoptingAChannelRejectedApplicationAsksTheOperatorToUnbindFirst() {
String huifuId = uniqueHuifuId();
Fixture fixture = merchantFixture("已驳回重纳管");
stubConvergedChannel(huifuId, "622200001234", "external-card-token");
service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
jdbc.update("update merchant_onboarding_application set status = 'rejected' where merchant_id = ?", fixture.merchantId());
assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("已被通道驳回")
.hasMessageContaining("解绑");
assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where merchant_id = ?",
Integer.class, fixture.merchantId())).isEqualTo(1);
}
/** 同一门店已经有进行中或已通过的进件时拒绝:两条收款主体会在下单时打架。 */
@Test
void adoptingASecondSubMerchantForTheSameStoreIsRejected() {
String firstHuifuId = uniqueHuifuId();
String secondHuifuId = uniqueHuifuId();
Fixture fixture = merchantFixture("门店冲突");
stubConvergedChannel(firstHuifuId, "622200001234", "external-card-token");
stubConvergedChannel(secondHuifuId, "622200001235", "second-card-token");
service.adopt(fixture.merchantId(), firstHuifuId, APPLY_NO, true);
assertThat(service.preview(fixture.merchantId(), secondHuifuId, APPLY_NO).get("blockers"))
.asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class))
.anySatisfy(blocker -> assertThat(blocker).contains("同一门店出现两个收款主体"));
assertThatThrownBy(() -> service.adopt(fixture.merchantId(), secondHuifuId, APPLY_NO, true))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("同一门店出现两个收款主体");
}
/**
* 纳管全程只查询、不下发:通道侧延迟入账没配好时,纳管**不会**替它配一次。
*
*
下发类操作不幂等(改支付场景会重建微信子商户号),且连锁多店共用同一个汇付商户号时各自
* 下发会重复下发。缺什么由界面按阶段如实显示,处理另走自助进件的既有路径。
*/
@Test
void adoptionNeverSendsAnyProviderChange() {
String huifuId = uniqueHuifuId();
Fixture fixture = merchantFixture("只查询不下发");
// 通道侧其他能力都正常,只有延迟入账没开——自助进件在这一点上会下发一次 delay 配置。
stubConvergedChannel(huifuId, "622200001234", "external-card-token");
when(gateway.queryCapabilities(huifuId)).thenReturn(channelDetail(huifuId, "622200001234", "external-card-token", "N", false));
Map adopted = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true);
verify(gateway, never()).configureDelay(anyString(), anyString(), anyString());
verify(wechatConfigGateway, never()).configure(anyString(), anyString());
verify(businessOpenGateway, never()).open(anyString(), anyString(), anyString(), anyString(), anyBoolean(), anyBoolean(), any(), any(), any());
verifyNoInteractions(pictureUploadGateway);
// 如实反映「这一步还没配好」,而不是伪造成功状态。
assertThat(adopted).containsEntry("status", "pending");
assertThat((List