package com.talentcard.server.business.payment.huifu;
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
import java.util.ArrayList;
|
import java.util.List;
|
import java.util.Map;
|
|
import com.alibaba.fastjson.JSON;
|
import org.junit.jupiter.api.Test;
|
|
/**
|
* 商户业务开通请求里那两个"文档要求、我们原来没发"的字段。
|
*
|
* <p>以汇付《商户业务开通》(v2.merchant.busi.open) 的请求规则为准逐项核对后发现两处缺口:
|
*
|
* <ul>
|
* <li>{@code media_mer_common_flag}({@code online_media_info_list} 的元素字段):运营媒介主体与商户主体
|
* 是否一致。平台用一个运营媒介给众多子商户开通,主体天然不一致,取 N。它属于媒介**元素**而不是顶层参数,
|
* 放顶层会被拒:「【media_merCommon_flag-媒介主体与商户主体是否一致】不能为空,或格式非法」。</li>
|
* <li>{@code micro_biz_type}(wx_conf_list 元素内的 wx_real_name_info):个人商户开通微信业务必填,
|
* 且三种取值都要求 F22 店铺门头照 + F24 店铺内景照。</li>
|
* </ul>
|
*
|
* <p>层级依据:文档里 {@code wx_real_name_info} 既是父字段列、又有同名子表,按本接口的写法约定
|
* 它是嵌套 jsonObject(同样的写法可见 {@code online_media_info_list}),所以 micro_biz_type
|
* 必须放进 wx_conf_list 的元素里,不能平铺到顶层。
|
*/
|
class HuifuBusinessOpenRequiredFieldsTests {
|
|
private static HuifuPaymentProperties properties() {
|
HuifuPaymentProperties properties = new HuifuPaymentProperties();
|
properties.setEnabled(true);
|
properties.setSystemId("6666000226048687");
|
properties.setProductId("KAZX");
|
properties.setPrivateKey("x");
|
properties.setPlatformPublicKey("y");
|
properties.setMerchantWechatFeeRate("0.60");
|
properties.setMerchantOnlineBusinessType("H5311L");
|
properties.setMerchantOnlineMediaName("东区人才卡");
|
properties.setMerchantAgreementUrl("https://example.com/agreement.htm");
|
properties.setOnboardingUpperHuifuId("6666000220591054");
|
return properties;
|
}
|
|
@Test
|
void theMediaSubjectFlagIsInsideTheMediaElement() {
|
Map<String, Object> request = request(false);
|
|
// 平台媒介主体与商户主体不一致,取 N。它是 online_media_info_list 的**元素字段**,
|
// 不是顶层参数——放顶层会被通道判「不能为空,或格式非法」。
|
assertThat(request).doesNotContainKey("media_mer_common_flag");
|
|
Map<String, Object> media = onlineMedia(request);
|
assertThat(media).containsEntry("media_mer_common_flag", "N");
|
}
|
|
/**
|
* 运营媒介只在开通快捷/网银/余额支付Pro/分账/大额转账/代扣时才需要,本平台只开线上微信,
|
* 默认不下发;不下发媒介,通道也就不会再索要「授权或开发证明材料」。
|
*/
|
@Test
|
void aBlankMediaNameKeepsTheMediaDeclarationOutOfTheRequest() {
|
HuifuPaymentProperties properties = properties();
|
properties.setMerchantOnlineMediaName(" ");
|
|
Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest(
|
properties, "BO-TEST-1", "6666000241390821", "阳野", "5812", false);
|
|
assertThat(request).doesNotContainKey("online_media_info_list");
|
}
|
|
@Test
|
void anIndividualMerchantCarriesTheMicroBusinessTypeInsideTheWeChatConfig() {
|
Map<String, Object> request = request(true);
|
|
List<Map<String, Object>> wechat = wechatConfig(request);
|
assertThat(wechat).hasSize(3);
|
// 个人主体属于小微商户,mcc 也是必填;三个线上场景都要带。
|
assertThat(wechat).allSatisfy(entry -> assertThat(entry).containsKeys("mcc", "wx_real_name_info"));
|
|
// 层级与形态:micro_biz_type 在 wx_real_name_info 这个**嵌套 jsonObject** 里,不在元素顶层,
|
// 也不能是 JSON 字符串。官方字段目录里该字段类型是 `Object`(对照 wx_conf_list[] 本身才是
|
// `String` 的 jsonArray字符串)——双重编码发出去通道会回「wx_conf_list格式错误」。
|
assertThat(wechat.get(0)).doesNotContainKey("micro_biz_type");
|
assertThat(wechat.get(0).get("wx_real_name_info")).isInstanceOf(Map.class);
|
Map<String, Object> realName = JSON.parseObject(JSON.toJSONString(wechat.get(0).get("wx_real_name_info")), Map.class);
|
assertThat(realName).containsEntry("micro_biz_type", "MICRO_TYPE_ONLINE");
|
}
|
|
@Test
|
void anEnterpriseMerchantDoesNotCarryTheMicroBusinessType() {
|
// 文档写明「个人商户需要填写」,企业主体不需要,不要硬塞。
|
Map<String, Object> request = request(false);
|
|
List<Map<String, Object>> wechat = wechatConfig(request);
|
assertThat(wechat).allSatisfy(entry -> assertThat(entry).doesNotContainKey("wx_real_name_info"));
|
}
|
|
/**
|
* 个人(小微)商户填了 {@code micro_biz_type},就欠这两张门店照片:官方在 micro_biz_type
|
* 的说明里写明「需要提供 F22:店铺门头照图片;F24:店铺内景图片,补充资料填写在
|
* extended_material_list 对象中」。原先只有 micro_biz_type、没有材料,开通是缺材料提交。
|
*/
|
@Test
|
void anIndividualMerchantCarriesTheStorePhotosAsExtendedMaterial() {
|
Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest(
|
properties(), "BO-TEST-MATERIAL", "6666000241390821", "阳野", "5812", true, true, "file-f22", "file-f24");
|
|
List<Map<String, Object>> materials =
|
JSON.parseObject(String.valueOf(request.get("extended_material_list")), List.class);
|
assertThat(materials).hasSize(2);
|
assertThat(materials.get(0)).containsEntry("file_type", "F22").containsEntry("file_id", "file-f22");
|
assertThat(materials.get(1)).containsEntry("file_type", "F24").containsEntry("file_id", "file-f24");
|
}
|
|
/** 门店照片缺一张就只发一张:宁可让通道明确报缺哪一项,也不要塞占位图顶上。 */
|
@Test
|
void aMissingStorePhotoIsNotReplacedByAPlaceholder() {
|
Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest(
|
properties(), "BO-TEST-MATERIAL-1", "6666000241390821", "阳野", "5812", true, true, "file-f22", "");
|
|
List<Map<String, Object>> materials =
|
JSON.parseObject(String.valueOf(request.get("extended_material_list")), List.class);
|
assertThat(materials).hasSize(1);
|
assertThat(materials.get(0)).containsEntry("file_type", "F22");
|
}
|
|
/** 企业主体没有这项材料要求,不要凭空给企业商户加附件。 */
|
@Test
|
void anEnterpriseMerchantCarriesNoExtendedMaterial() {
|
Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest(
|
properties(), "BO-TEST-MATERIAL-2", "6666000241390821", "成都喜望科技", "5812", false, false, "file-f22", "file-f24");
|
|
assertThat(request).doesNotContainKey("extended_material_list");
|
}
|
|
/**
|
* 个体工商户走企业进件接口,所以不是「个人主体」,但官方把「个体工商户、小微商户」并列为
|
* {@code mcc} 必填的一类。配了 {@code fee_rule_id} 也不能把 mcc 省掉——官方请求示例里
|
* {@code mcc} 与 {@code fee_rule_id} 本来就出现在同一个元素里。
|
*
|
* <p>原先这条判断写在 {@code fee_rule_id} 的 else 分支里,平台配了规则号 756 之后
|
* 小微/个体工商户的 mcc 永远发不出去,业务开通被通道回 {@code wx_conf_list格式错误}。
|
*
|
* <p>费率规则号也要换成该主体类型的那一个:同表「线上、类型 01」下个体工商户是 761。
|
*/
|
@Test
|
void anIndividualBusinessCarriesTheMccAndItsOwnFeeRule() {
|
HuifuPaymentProperties properties = properties();
|
properties.setMerchantWechatFeeRuleId("756");
|
|
List<Map<String, Object>> wechat = wechatConfig(HuifuMerchantBusinessOpenGateway.buildRequestForTest(
|
properties, "BO-TEST-INDV", "6666000241390821", "阳野餐饮店", "5812", false, true, "", ""));
|
|
assertThat(wechat).hasSize(3);
|
assertThat(wechat).allSatisfy(entry -> {
|
assertThat(entry).containsEntry("fee_rule_id", "761");
|
assertThat(entry).containsEntry("mcc", "5812");
|
});
|
}
|
|
/**
|
* 个人(小微)主体的费率规则号必须换成小微那一行。官方的《微信费率规则ID》对照表按
|
* 「支付场景 × 业务开通类型 × 主体类型」定位 CID:线上(场景 4/5、类型 01)下企业是 756、
|
* 小微是 775。拿企业的 756 发给小微,通道回的是「微信费率规则号填写不正确,请检查!」——
|
* 2026-09-16 真实个人商户实测(该次报文的格式已经正确,纯粹是号选错)。
|
*/
|
@Test
|
void aMicroMerchantUsesTheMicroFeeRuleNotTheEnterpriseOne() {
|
HuifuPaymentProperties properties = properties();
|
properties.setMerchantWechatFeeRuleId("756");
|
|
List<Map<String, Object>> wechat = wechatConfig(HuifuMerchantBusinessOpenGateway.buildRequestForTest(
|
properties, "BO-TEST-MICRO", "6666000241804122", "阳野", "5812", true));
|
|
assertThat(wechat).allSatisfy(entry ->
|
assertThat(entry).containsEntry("fee_rule_id", "775").containsEntry("mcc", "5812"));
|
}
|
|
@Test
|
void anEnterpriseCanUseAProviderConfirmedFeeRuleInsteadOfMcc() {
|
HuifuPaymentProperties properties = properties();
|
properties.setMerchantWechatFeeRuleId("756");
|
|
List<Map<String, Object>> wechat = wechatConfig(HuifuMerchantBusinessOpenGateway.buildRequestForTest(
|
properties, "BO-TEST-RULE", "6666000241335656", "成都喜望科技", "7372", false));
|
|
assertThat(wechat).allSatisfy(entry -> {
|
assertThat(entry).containsEntry("fee_rule_id", "756");
|
assertThat(entry).doesNotContainKey("mcc");
|
});
|
}
|
|
/**
|
* 线上公众号、小程序、反扫三个场景一起开。只开小程序会让另外两条支付路径在通道侧缺配置,
|
* 而平台的小程序、H5 公众号与反扫共用同一个子商户号。
|
*/
|
@Test
|
void theOnlineWeChatScenesAreOpenedTogether() {
|
List<Map<String, Object>> wechat = wechatConfig(request(false));
|
|
List<String> scenes = new ArrayList<>();
|
for (Map<String, Object> entry : wechat) scenes.add(String.valueOf(entry.get("pay_scene")));
|
assertThat(scenes).containsExactly("4", "5", "12");
|
|
assertThat(wechat).allSatisfy(entry -> {
|
assertThat(entry).containsEntry("fee_rate", "0.60");
|
// 最低收取手续费可选、文档写明「大于 0」:留空让通道按不设最低收费处理,
|
// 详情查询读回 fee_min_amt=0.00(参考商户线上三场景实测即 0.00),不要主动送 0。
|
assertThat(entry).doesNotContainKey("fee_min_amt");
|
// 不填子渠道号即入驻到渠道默认配置的微信渠道号下。
|
assertThat(entry).doesNotContainKey("pay_channel_id");
|
});
|
}
|
|
/**
|
* 直接驱动组装逻辑。网关的 {@code open()} 会真的调用通道,所以这里只构建请求体:
|
* 复用网关内部的组装顺序没有意义,改为断言"网关是否会包含这些字段"的等价物——
|
* 用同一份 properties 构造,并让网关暴露一个纯组装的包级方法。
|
*/
|
private Map<String, Object> request(boolean individualMerchant) {
|
return HuifuMerchantBusinessOpenGateway.buildRequestForTest(
|
properties(), "BO-TEST-1", "6666000241390821", "阳野", "5812", individualMerchant);
|
}
|
|
@SuppressWarnings("unchecked")
|
private List<Map<String, Object>> wechatConfig(Map<String, Object> request) {
|
return JSON.parseObject(String.valueOf(request.get("wx_conf_list")), List.class);
|
}
|
|
@SuppressWarnings("unchecked")
|
private Map<String, Object> onlineMedia(Map<String, Object> request) {
|
List<Map<String, Object>> media =
|
JSON.parseObject(String.valueOf(request.get("online_media_info_list")), List.class);
|
assertThat(media).hasSize(1);
|
return media.get(0);
|
}
|
}
|