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).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); 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); 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); 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).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>) adopted.get("stages")) .anySatisfy(stage -> assertThat(stage).containsEntry("key", "delay").containsEntry("status", "missing")); } /** 后台/商户端点「刷新进度」对纳管记录同样是只查询:不会走开通步骤的那条下发链路。 */ @Test void refreshingAManagedApplicationStaysQueryOnly() { String huifuId = uniqueHuifuId(); Fixture fixture = merchantFixture("刷新只查询"); stubConvergedChannel(huifuId, "622200001234", "external-card-token"); Map adopted = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true); settlementService.refreshProgress(Long.valueOf(String.valueOf(adopted.get("id"))), false); verify(wechatConfigGateway, never()).configure(anyString(), anyString()); verify(businessOpenGateway, never()).open(anyString(), anyString(), anyString(), anyString(), anyBoolean(), anyBoolean(), any(), any(), any()); verify(gateway, never()).configureDelay(anyString(), anyString(), anyString()); // 通道返回的一切都按原样落库,进度仍是已通过。 assertThat(settlementService.refreshProgress(Long.valueOf(String.valueOf(adopted.get("id"))), false)) .containsEntry("status", "approved"); } /** 解绑清理进件记录与结算卡记录,两者都从后台列表消失。 */ @Test void unbindingRemovesTheOnboardingAndItsSettlementCard() { String huifuId = uniqueHuifuId(); Fixture fixture = merchantFixture("解绑"); stubConvergedChannel(huifuId, "622200001234", "external-card-token"); Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id"))); assertThat(adminSettlementService.bankCards(1, 20, fixture.key(), null, null, null, null, null).records()).hasSize(1); Map result = service.unbind(applicationId); assertThat(result).containsEntry("unbound", true); assertThat(adminSettlementService.onboardings(1, 20, fixture.key(), null, null, null, null, null).records()).isEmpty(); assertThat(adminSettlementService.bankCards(1, 20, fixture.key(), null, null, null, null, null).records()).isEmpty(); assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_progress where application_id = ?", Integer.class, applicationId)).isZero(); } /** 该商户号上还有已收款未结算的订单时拒绝解绑:那是正在进行的对账,不能切断。 */ @Test void unbindingIsRefusedWhileMoneyIsStillUnsettled() { String huifuId = uniqueHuifuId(); Fixture fixture = merchantFixture("待结算"); stubConvergedChannel(huifuId, "622200001234", "external-card-token"); Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id"))); insertOrder(fixture, applicationId, huifuId, /*payAmount*/ 1000, /*orderStatus*/ 1, /*refundStatus*/ 0); assertThatThrownBy(() -> service.unbind(applicationId)) .isInstanceOf(BusinessException.class) .hasMessageContaining("已收款未结算"); assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where id = ?", Integer.class, applicationId)).isEqualTo(1); } @Test void unbindingIsRefusedWhileOrdersAreStillInFlight() { String huifuId = uniqueHuifuId(); Fixture fixture = merchantFixture("在途订单"); stubConvergedChannel(huifuId, "622200001234", "external-card-token"); Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id"))); insertOrder(fixture, applicationId, huifuId, /*payAmount*/ 1000, /*orderStatus*/ 0, /*refundStatus*/ 0); assertThatThrownBy(() -> service.unbind(applicationId)) .isInstanceOf(BusinessException.class) .hasMessageContaining("在途订单"); } /** 已取消/已退款的终态订单不拦解绑:那不是在途资金。 */ @Test void settledOrRefundedOrdersDoNotBlockUnbinding() { String huifuId = uniqueHuifuId(); Fixture fixture = merchantFixture("终态订单"); stubConvergedChannel(huifuId, "622200001234", "external-card-token"); Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id"))); insertOrder(fixture, applicationId, huifuId, /*payAmount*/ 1000, /*orderStatus*/ 2, /*refundStatus*/ 0); insertOrder(fixture, applicationId, huifuId, /*payAmount*/ 1000, /*orderStatus*/ 1, /*refundStatus*/ 2); assertThat(service.unbind(applicationId)).containsEntry("unbound", true); } /** 自助进件的记录不支持解绑:那是商户自己提交的资料,删掉它是另一个性质的操作。 */ @Test void aSelfOnboardedApplicationCannotBeUnbound() { Fixture fixture = merchantFixture("自助进件"); jdbc.update(""" insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, contact_name, contact_mobile, idempotency_key, status, source) values (?, ?, ?, 'enterprise', '自助主体', '自助联系人', '13700000000', ?, 'pending', 'self') """, fixture.merchantId(), fixture.storeId(), "MO-SELF-" + fixture.key(), fixture.key()); Long applicationId = jdbc.queryForObject("select id from merchant_onboarding_application where merchant_id = ?", Long.class, fixture.merchantId()); assertThatThrownBy(() -> service.unbind(applicationId)) .isInstanceOf(BusinessException.class) .hasMessageContaining("不支持解绑"); } /** 解绑只解除我方绑定:商户端登录账号与门店资料都不动,之后可以重新纳管。 */ @Test void unbindingKeepsTheMerchantAccountAndAllowsAdoptingAgain() { String huifuId = uniqueHuifuId(); Fixture fixture = merchantFixture("可重新纳管"); jdbc.update("insert into merchant_operator(merchant_id, account, password_hash, display_name, role_code, status, deleted) values (?, ?, 'x', '店主', 'owner', 1, 0)", fixture.merchantId(), "t01op" + Math.abs(System.nanoTime())); stubConvergedChannel(huifuId, "622200001234", "external-card-token"); Long applicationId = Long.valueOf(String.valueOf(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true).get("id"))); service.unbind(applicationId); Map again = service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true); assertThat(again).containsEntry("status", "approved"); assertThat(again.get("id")).isNotEqualTo(String.valueOf(applicationId)); assertThat(jdbc.queryForObject("select count(*) from merchant_operator where merchant_id = ? and status = 1 and deleted = 0", Integer.class, fixture.merchantId())).isEqualTo(1); } /** 该商家还没有商户端登录账号时纳管照做,但要提示运营,否则商户登不进来。 */ @Test void adoptionWarnsWhenTheMerchantCannotSignInYet() { String huifuId = uniqueHuifuId(); Fixture fixture = merchantFixture("未开登录"); jdbc.update("update merchant set can_login_merchant_app = 0 where id = ?", fixture.merchantId()); stubConvergedChannel(huifuId, "622200001234", "external-card-token"); Map preview = service.preview(fixture.merchantId(), huifuId); assertThat(String.valueOf(preview.get("loginNotice"))).contains("未开启商户端登录"); // 提示不阻挡纳管:纳管只管收款绑定,账号由「商家查询」负责。 assertThat(service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true)).containsEntry("status", "approved"); } /** * 登录开着、账号也在的商家**一条提示都不该有**。 * *

这条钉的是一个真出现过的静默 bug:库行经 {@code DatabaseRows.normalize} 后键是 camelCase, * 通道回执是 snake_case,两种 map 用同一个 {@code get()} 取,于是把列名写成 snake_case 去读库行会 * 取到 null(不报错),`!"1".equals("")` 恒真——登录明明开着,界面上却永远挂着「未开启商户端登录」。 * 只有「正向不提示」的断言能发现它,只测「没开时要提示」是测不出来的。 */ @Test void noLoginNoticeAtAllWhenTheMerchantCanAlreadySignIn() { String huifuId = uniqueHuifuId(); Fixture fixture = merchantFixture("登录开着"); jdbc.update(""" insert into merchant_operator(merchant_id, account, password_hash, display_name, role_code, status, deleted) values (?, ?, 'x', '店主', 'owner', 1, 0) """, fixture.merchantId(), "t01ok" + Math.abs(System.nanoTime())); stubConvergedChannel(huifuId, "622200001234", "external-card-token"); assertThat(service.preview(fixture.merchantId(), huifuId).get("loginNotice")).isEqualTo(""); } /** * 通道没给联系人/手机号时,这些非空字段要从**我们自己的商家记录**取,不能留空、也不能顶成别的字段。 * *

与上一条同源:它覆盖的正是「拿 snake_case 的键去读库行」会静默取到 null 的那几处。 */ @Test void missingChannelContactFieldsFallBackToOurMerchantRecord() { String huifuId = uniqueHuifuId(); Fixture fixture = merchantFixture("通道缺联系人"); Map detail = channelDetail(huifuId, "622200001234", "external-card-token", "Y", true); // 个人主体:company_type 取我们商家档案上的经营类型;通道侧的联系人三项全部不给。 detail.put("cust_type", "2"); detail.remove("contact_name"); detail.remove("contact_mobile_no"); detail.remove("legal_mobile_no"); when(gateway.queryCapabilities(huifuId)).thenReturn(detail); when(gateway.queryApplication(anyString(), anyString())).thenReturn(Map.of( "resp_code", "00000000", "apply_status", "Y", "reg_status", "S", "bind_card_status", "S")); service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true); // merchantFixture 的商家记录:contact_name=联系人、contact_mobile=13700000001、merchant_type=餐饮服务 assertThat(column(fixture, "contact_name")).isEqualTo("联系人"); assertThat(column(fixture, "contact_mobile")).isEqualTo("13700000001"); assertThat(column(fixture, "company_type")).isEqualTo("餐饮服务"); assertThat(column(fixture, "subject_type")).isEqualTo("individual"); } private String column(Fixture fixture, String name) { return jdbc.queryForObject("select " + name + " from merchant_onboarding_application where merchant_id = ?", String.class, fixture.merchantId()); } /** 通道没给出可用结算卡时不纳管:没有结算卡,进度不会推进、闸门也不会放行。 */ @Test void adoptingIsRejectedWhenTheChannelReturnsNoUsableCard() { String huifuId = uniqueHuifuId(); Fixture fixture = merchantFixture("无结算卡"); Map detail = channelDetail(huifuId, "622200001234", "token", "Y", true); detail.put("qry_cash_card_info_list", List.of(Map.of("card_no", "6222****1234", "token_no", "masked-token"))); when(gateway.queryCapabilities(huifuId)).thenReturn(detail); assertThat(service.preview(fixture.merchantId(), huifuId).get("blockers")) .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class)) .anySatisfy(blocker -> assertThat(blocker).contains("结算银行卡")); assertThatThrownBy(() -> service.adopt(fixture.merchantId(), huifuId, APPLY_NO, true)) .isInstanceOf(BusinessException.class) .hasMessageContaining("结算银行卡"); } private Long adoptedApplicationId(Long merchantId, String huifuId) { return jdbc.queryForObject("select id from merchant_onboarding_application where merchant_id = ? and provider_huifu_id = ?", Long.class, merchantId, huifuId); } /** 通道侧一切就绪(六阶段 + 结算卡 + 商户状态正常)的商户详情回执。 */ private void stubConvergedChannel(String huifuId, String cardNumber, String token) { stubConvergedChannel(huifuId, cardNumber, token, "攀枝花测试餐饮有限公司", "测试餐饮"); } private void stubConvergedChannel(String huifuId, String cardNumber, String token, String registeredName, String shortName) { when(gateway.queryCapabilities(huifuId)).thenReturn(channelDetail(huifuId, cardNumber, token, "Y", true, registeredName, shortName)); when(gateway.queryApplication(anyString(), anyString())).thenReturn(Map.of( "resp_code", "00000000", "apply_status", "Y", "reg_status", "S", "bind_card_status", "S")); when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code", "00000000", "atu_config_bo_list", List.of(Map.of("fee_type", "02", "wx_config_list", 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")); } /** * 商户详细信息查询的回执:名称、主体类型、结算卡、微信场景、结算配置与延迟配置。 * *

{@code delayFlag} 传 {@code N} 就是「通道侧还没开延迟入账」——自助进件在这一点上会下发一次 * 配置,纳管不允许下发。 */ private Map channelDetail(String huifuId, String cardNumber, String token, String delayFlag, boolean settlementConfigured) { return channelDetail(huifuId, cardNumber, token, delayFlag, settlementConfigured, "攀枝花测试餐饮有限公司", "测试餐饮"); } private Map channelDetail(String huifuId, String cardNumber, String token, String delayFlag, boolean settlementConfigured, String registeredName, String shortName) { Map detail = new LinkedHashMap<>(); detail.put("resp_code", "00000000"); detail.put("huifu_id", huifuId); detail.put("reg_name", registeredName); detail.put("short_name", shortName); detail.put("cust_type", "1"); detail.put("ent_type", "3"); detail.put("legal_name", "测试法人"); detail.put("legal_mobile_no", "13700000009"); detail.put("contact_name", "测试联系人"); detail.put("contact_mobile_no", "13700000008"); detail.put("mer_stat", "NORMAL"); detail.put("delay_flag", delayFlag); detail.put("forced_delay_flag", "N"); detail.put("qry_cash_card_info_list", List.of(Map.of( "card_no", cardNumber, "token_no", token, "status", "N", "is_settle_default", "Y", "bank_name", "中国工商银行", "branch_name", "中国工商银行攀枝花东区支行", "branch_code", "102290026507", "area_id", "510400", "card_name", registeredName))); List> settleConfigs = settlementConfigured ? List.of(Map.of("settle_status", "1", "settle_cycle", "D1", "settle_pattern", "P0", "min_amt", "0", "remained_amt", "0", "token_no", token)) : List.of(); detail.put("qry_settle_config_list", settleConfigs); detail.put("qry_wx_conf_list", wechatScenes()); return detail; } private static List> wechatScenes() { return List.of("1", "2", "3").stream().map(scene -> Map.of( "pay_scene", scene, "fee_rate", "0.30", "fee_min_amt", "0.00", "fee_charge_type", "1", "out_fee_flag", "2", "wx_mer_infos", List.of(Map.of("bank_mer_code", "922609119", "pay_channel_id", "00016904", "is_default", "1")) )).toList(); } /** 一笔处于某种状态的订单;纳管记录会被订单上的收款绑定指回来。 */ private void insertOrder(Fixture fixture, Long applicationId, String huifuId, long payAmount, int orderStatus, int refundStatus) { jdbc.update(""" insert into benefit_order(order_no, talent_id, merchant_id, store_id, order_type, pay_amount, order_status, verify_status, refund_status, collect_onboarding_id, collect_huifu_id) values (?, 1, ?, ?, 'STORE_CONSUME', ?, ?, 0, ?, ?, ?) """, "T01O-" + fixture.key() + "-" + payAmount + orderStatus + refundStatus, fixture.merchantId(), fixture.storeId(), payAmount, orderStatus, refundStatus, applicationId, huifuId); } private Fixture merchantFixture(String label) { String key = "T01" + label + System.nanoTime(); jdbc.update(""" insert into merchant(merchant_name, merchant_type, contact_name, contact_mobile, can_login_merchant_app, status, deleted) values (?, '餐饮服务', '联系人', '13700000001', 1, 1, 0) """, key); Long merchantId = 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)", merchantId, key + "门店"); Long storeId = jdbc.queryForObject("select id from merchant_store where merchant_id = ?", Long.class, merchantId); return new Fixture(key, merchantId, storeId); } /** 每个用例用自己的商户号,避免共享测试库上互相串。 */ private static String uniqueHuifuId() { return "6666" + String.format("%014d", Math.abs(System.nanoTime() % 100_000_000_000_000L)); } private record Fixture(String key, Long merchantId, Long storeId) { } }