talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantBusinessOpenGateway.java
@@ -8,6 +8,7 @@ import com.talentcard.server.common.ErrorCode; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -53,33 +54,45 @@ return new HuifuMerchantBusinessOpenGateway(properties).buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant); } private Map<String, Object> buildRequest(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant) { Map<String, Object> wechat = new LinkedHashMap<>(); wechat.put("pay_scene", "5"); // 企业微信业务使用汇付确认的 fee_rule_id;仅在未配置规则 ID 时兼容旧的 mcc 路径。 // 不得把参考商户的规则 ID 猜测套用到其它行业。 if (!properties.getMerchantWechatFeeRuleId().isBlank()) { wechat.put("fee_rule_id", properties.getMerchantWechatFeeRuleId()); } else { // mcc:文档注明「个体工商户、小微商户必填」——个人主体属于小微商户,所以必须带上。 wechat.put("mcc", mcc); } wechat.put("fee_rate", properties.getMerchantWechatFeeRate().trim()); // 实名认证由通道在业务开通成功后另行发起;取 N 表示本次不发起。 // 改成 Y 之前要先确认实名所需的材料(含 F22/F24 照片)已就绪。 wechat.put("is_check_real_name", "N"); if (individualMerchant) { String microBizType = properties.getMerchantMicroBusinessType(); if (!microBizType.isBlank()) { Map<String, Object> realName = new LinkedHashMap<>(); realName.put("micro_biz_type", microBizType); wechat.put("wx_real_name_info", JSON.toJSONString(realName)); } } /** * 线上微信业务场景:4=线上公众号、5=线上小程序、12=线上反扫。 * * <p>三个场景一起开:平台的小程序、H5 公众号与反扫共用同一个子商户号,只开小程序会让另外两条 * 支付路径在通道侧没有配置。汇付《微信费率规则ID》表里场景 12 没有独立行,它与 4/5 共用同一条 * 线上规则——参考商户 {@code 6666000226048687} 线上三场景费率同为 0.70% 且真实生效。 */ private static final List<String> ONLINE_WECHAT_SCENES = List.of("4", "5", "12"); Map<String, Object> media = new LinkedHashMap<>(); media.put("media_type", "S3"); media.put("media_name", properties.getMerchantOnlineMediaName().trim()); private Map<String, Object> buildRequest(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant) { String feeRuleId = properties.getMerchantWechatFeeRuleId(); List<Map<String, Object>> wechatList = new ArrayList<>(); for (String scene : ONLINE_WECHAT_SCENES) { Map<String, Object> wechat = new LinkedHashMap<>(); wechat.put("pay_scene", scene); // 企业微信业务使用汇付确认的 fee_rule_id;仅在未配置规则 ID 时兼容旧的 mcc 路径。 // 不得把参考商户的规则 ID 猜测套用到其它行业。 if (!feeRuleId.isBlank()) { wechat.put("fee_rule_id", feeRuleId); } else { // mcc:文档注明「个体工商户、小微商户必填」——个人主体属于小微商户,所以必须带上。 wechat.put("mcc", mcc); } wechat.put("fee_rate", properties.getMerchantWechatFeeRate().trim()); // 最低收取手续费留空:通道按不设最低收费处理,详情查询读回 fee_min_amt=0.00 // (参考商户线上三场景实测就是 0.00)。该字段可选且写明「大于 0」,不要主动送 0。 // 实名认证由通道在业务开通成功后另行发起;取 N 表示本次不发起。 // 改成 Y 之前要先确认实名所需的材料(含 F22/F24 照片)已就绪。 wechat.put("is_check_real_name", "N"); if (individualMerchant) { String microBizType = properties.getMerchantMicroBusinessType(); if (!microBizType.isBlank()) { Map<String, Object> realName = new LinkedHashMap<>(); realName.put("micro_biz_type", microBizType); wechat.put("wx_real_name_info", JSON.toJSONString(realName)); } } wechatList.add(wechat); } Map<String, Object> agreement = new LinkedHashMap<>(); agreement.put("agreement_type", "3"); @@ -91,12 +104,19 @@ request.put("huifu_id", huifuId); request.put("upper_huifu_id", properties.getOnboardingUpperHuifuId().trim()); request.put("short_name", shortName); request.put("wx_conf_list", JSON.toJSONString(List.of(wechat))); request.put("wx_conf_list", JSON.toJSONString(wechatList)); request.put("online_busi_type", properties.getMerchantOnlineBusinessType().trim()); request.put("online_media_info_list", JSON.toJSONString(List.of(media))); // 运营媒介主体与商户主体是否一致。平台用一个运营媒介为众多子商户开通,主体天然不一致, // 而且它是顶层必填——不传会被通道拒(实测报「media_merCommon_flag…不能为空,或格式非法」)。 putIfPresent(request, "media_mer_common_flag", properties.getMerchantMediaSubjectSame()); // 运营媒介只在开通快捷/网银/余额支付Pro/分账/大额转账/代扣时才需要,本网关只开线上微信, // 因此默认不下发。要下发就把 merchant-online-media-name 配上;注意声明 // media_mer_common_flag=N(媒介主体与商户主体不一致)时,通道会一并索要授权或开发证明材料。 String mediaName = properties.getMerchantOnlineMediaName().trim(); if (!mediaName.isBlank()) { Map<String, Object> media = new LinkedHashMap<>(); media.put("media_type", "S3"); media.put("media_name", mediaName); putIfPresent(media, "media_mer_common_flag", properties.getMerchantMediaSubjectSame()); request.put("online_media_info_list", JSON.toJSONString(List.of(media))); } request.put("agreement_info", JSON.toJSONString(agreement)); // 进件时就把延迟入账能力配好:先付钱后核销的订单必须把钱留在通道「待确认」, // 核销确认后才分配给商户。forced_delay_flag 保持关闭——支付即核销的到店消费订单不该被延迟。 @@ -112,7 +132,7 @@ private void ensureConfigured() { if (!properties.isEnabled()) throw new BusinessException(ErrorCode.CONFLICT, "Huifu payment is disabled"); if (blank(properties.getSystemId()) || blank(properties.getProductId()) || blank(properties.getOnboardingUpperHuifuId()) || blank(properties.getMerchantWechatFeeRate()) || blank(properties.getMerchantOnlineBusinessType()) || blank(properties.getMerchantOnlineMediaName()) || blank(properties.getMerchantAgreementUrl()) || blank(properties.getPrivateKey()) || blank(properties.getPlatformPublicKey())) throw new BusinessException(ErrorCode.CONFLICT, "Huifu merchant business opening configuration is incomplete"); if (blank(properties.getSystemId()) || blank(properties.getProductId()) || blank(properties.getOnboardingUpperHuifuId()) || blank(properties.getMerchantWechatFeeRate()) || blank(properties.getMerchantOnlineBusinessType()) || blank(properties.getMerchantAgreementUrl()) || blank(properties.getPrivateKey()) || blank(properties.getPlatformPublicKey())) throw new BusinessException(ErrorCode.CONFLICT, "Huifu merchant business opening configuration is incomplete"); } private void initializeSdk() throws Exception { @@ -138,7 +158,9 @@ String code = value(data, "sub_resp_code", "resp_code", "code"); return new Result(success(code), value(data, "apply_no", "applyNo"), code, value(data, "sub_resp_desc", "resp_desc", "message")); } private static boolean success(String code) { return "00000000".equals(code) || "00000100".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code); } // 90000000 = 审核中:业务开通已被受理、微信子商户号当场就入驻成功,但整张申请单仍在审核。 // 它与两个进件网关的判定保持一致;漏掉它会把一次成功受理记成 business_open_status=failed。 private static boolean success(String code) { return "00000000".equals(code) || "00000100".equals(code) || "90000000".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code); } private static String value(Map<String, Object> data, String... keys) { for (String key : keys) if (data.get(key) != null) return String.valueOf(data.get(key)).trim(); return ""; } } } talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentProperties.java
@@ -37,11 +37,12 @@ /** * 商户业务开通的 {@code use_chains_flag}:是否用上级(渠道商)的微信商户号发起交易。 * * <p>我们是平台自有的一个小程序服务多个商户,没有实现「微信特约商户进件」给每个商户单独 * 申请微信商户号,所以保持 {@code 3}——仅微信、复用上级商户号。留空则不下发该字段, * 由商户使用自己的微信商户号。 * <p>留空(默认)即不下发该字段,子商户会在渠道下拿到自己的微信子商户号。 * 不要下发 {@code 3}:那是「复用上级微信商户号」,要求上级本身有商户级微信配置,而我们的上级是 * 渠道商(没有商户级配置,查询返回「huifuId不存在」),实测会被通道拒: * 「失败:复用交易信息,上级商户号为空」。 */ private String merchantUseChainsFlag = "3"; private String merchantUseChainsFlag = ""; private String merchantWechatFeeRate = "0.60"; /** * 企业微信业务开通使用的汇付费率规则 ID。企业场景应优先填写该值, talent-card-server/src/main/resources/application.yml
@@ -88,15 +88,23 @@ # 进件只做「渠道商 → 普通商户」两级:不再用 head_office_flag=0 + head_huifu_id 把商户 # 建成平台自己的下级商户。实测那条路没有收益(建完照样 20002 数据权限认证失败), # 还会按总部客群类型索要 F725/F726/F727 材料。 # 商户业务开通是否复用上级(渠道商)的微信商户号:3=仅微信;留空=不下发该字段。 merchant-use-chains-flag: ${HUIFU_MERCHANT_USE_CHAINS_FLAG:3} merchant-wechat-fee-rate: ${HUIFU_MERCHANT_WECHAT_FEE_RATE:0.60} # 企业微信开通应填写汇付按实际行业确认的规则 ID;留空时沿用 mcc 兼容路径。 merchant-wechat-fee-rule-id: ${HUIFU_MERCHANT_WECHAT_FEE_RULE_ID:} # 是否复用上级(渠道商)的微信商户号:1=微信+支付宝,2=不使用,3=仅微信,4=支付宝;留空=不下发。 # 渠道商 6666000220591054 本身没有商户级微信配置(查询返回「huifuId不存在」),实测下发 3 会被拒: # 「失败:复用交易信息,上级商户号为空」。不下发时,子商户在渠道 00016904 下拿到自己的微信 # 子商户号(实测 sub_mer_id=W15737921333057628112,sub_mer_name 即商户注册名)。 merchant-use-chains-flag: ${HUIFU_MERCHANT_USE_CHAINS_FLAG:} merchant-wechat-fee-rate: ${HUIFU_MERCHANT_WECHAT_FEE_RATE:0.70} # 企业微信业务的费率规则号(cid)。756 是汇付《微信费率规则ID》表里「企业 + fee_type=01(线上)」 # 唯一的一条规则,细分行业含「就业信息平台/其他信息服务平台」,与本平台业务对应; # 场景 12 在该表没有独立行,与线上公众号/小程序共用这一条。留空则退回 mcc 兼容路径。 merchant-wechat-fee-rule-id: ${HUIFU_MERCHANT_WECHAT_FEE_RULE_ID:756} merchant-online-business-type: ${HUIFU_MERCHANT_ONLINE_BUSINESS_TYPE:H5311L} merchant-online-media-name: ${HUIFU_MERCHANT_ONLINE_MEDIA_NAME:东区人才卡} # 运营媒介主体与商户主体是否一致。平台用一个运营媒介给众多子商户开通,主体天然不一致。 # 这是商户业务开通的顶层必填,缺了会被通道拒(实测报 media_merCommon_flag 不能为空)。 # 运营媒介(online_media_info_list)。文档写明它只在开通快捷/网银/余额支付Pro/分账/ # 大额转账/代扣时才需要,且是「基本信息入驻接口」的必填项,不是「商户业务开通」的必填项; # 本平台只开线上微信,所以默认留空即不下发。要下发时再配媒介名称。 merchant-online-media-name: ${HUIFU_MERCHANT_ONLINE_MEDIA_NAME:} # 媒介主体与商户主体是否一致;它是 online_media_info_list 的**元素字段**,不是顶层参数。 # 声明 N(不一致)时通道会一并索要授权或开发证明材料,因此不下发媒介时它也用不上。 merchant-media-subject-same: ${HUIFU_MERCHANT_MEDIA_SUBJECT_SAME:N} # 小微经营类型,个人商户开通微信业务时必填;三种取值都要求 F22 门头照 + F24 内景照。 merchant-micro-business-type: ${HUIFU_MERCHANT_MICRO_BUSINESS_TYPE:MICRO_TYPE_ONLINE} talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuBusinessOpenRequiredFieldsTests.java
@@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -14,9 +15,9 @@ * <p>以汇付《商户业务开通》(v2.merchant.busi.open) 的请求规则为准逐项核对后发现两处缺口: * * <ul> * <li>{@code media_mer_common_flag}(顶层):运营媒介主体与商户主体是否一致。平台用一个运营媒介 * 给众多子商户开通,主体天然不一致。实测缺它会被拒: * 「【media_merCommon_flag-媒介主体与商户主体是否一致】不能为空,或格式非法」。</li> * <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> @@ -43,11 +44,30 @@ } @Test void theMediaSubjectFlagIsAlwaysSent() { void theMediaSubjectFlagIsInsideTheMediaElement() { Map<String, Object> request = request(false); // 平台媒介主体与商户主体不一致,取 N;关键是必须出现,否则通道判参数非法。 assertThat(request).containsEntry("media_mer_common_flag", "N"); // 平台媒介主体与商户主体不一致,取 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 @@ -55,9 +75,9 @@ Map<String, Object> request = request(true); List<Map<String, Object>> wechat = wechatConfig(request); assertThat(wechat).hasSize(1); // 个人主体属于小微商户,mcc 也是必填。 assertThat(wechat.get(0)).containsKeys("mcc", "wx_real_name_info"); 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 里,不在元素顶层。 assertThat(wechat.get(0)).doesNotContainKey("micro_biz_type"); @@ -71,7 +91,7 @@ Map<String, Object> request = request(false); List<Map<String, Object>> wechat = wechatConfig(request); assertThat(wechat.get(0)).doesNotContainKey("wx_real_name_info"); assertThat(wechat).allSatisfy(entry -> assertThat(entry).doesNotContainKey("wx_real_name_info")); } @Test @@ -82,8 +102,32 @@ List<Map<String, Object>> wechat = wechatConfig(HuifuMerchantBusinessOpenGateway.buildRequestForTest( properties, "BO-TEST-RULE", "6666000241335656", "成都喜望科技", "7372", false)); assertThat(wechat.get(0)).containsEntry("fee_rule_id", "756"); assertThat(wechat.get(0)).doesNotContainKey("mcc"); 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"); }); } /** @@ -100,4 +144,12 @@ 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); } } talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuBusinessOpenResultTests.java
New file @@ -0,0 +1,36 @@ package com.talentcard.server.business.payment.huifu; import static org.assertj.core.api.Assertions.assertThat; import java.util.Map; import org.junit.jupiter.api.Test; /** * 商户业务开通的回执判定。 * * <p>{@code 90000000=审核中} 表示申请已被受理(微信子商户号当场就入驻成功),整张申请单仍在审核, * 它与两个进件网关的判定一致。漏掉它会把一次成功受理记成 {@code business_open_status=failed}, * 两端页面就会把已经开好的商户显示成失败。 */ class HuifuBusinessOpenResultTests { @Test void aSubmittedApplicationUnderReviewCountsAsAccepted() { HuifuMerchantBusinessOpenGateway.Result result = HuifuMerchantBusinessOpenGateway.Result.from( Map.of("resp_code", "90000000", "resp_desc", "审核中", "apply_no", "2026091530554624")); assertThat(result.accepted()).isTrue(); assertThat(result.applyNo()).isEqualTo("2026091530554624"); assertThat(result.code()).isEqualTo("90000000"); } @Test void aRejectedApplicationIsNotAccepted() { HuifuMerchantBusinessOpenGateway.Result result = HuifuMerchantBusinessOpenGateway.Result.from( Map.of("resp_code", "00000001", "resp_desc", "基础参数校验失败")); assertThat(result.accepted()).isFalse(); assertThat(result.applyNo()).isEmpty(); } } talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingMerchantLevelTests.java
@@ -47,26 +47,29 @@ } /** * 商户自己没有微信商户号(我们没实现「微信特约商户进件」),开通时仍然复用上级商户号发起交易, * 否则子商户根本没有可用的微信商户号。留空该配置即不下发字段。 * 默认不下发 {@code use_chains_flag}:子商户在渠道下取得自己的微信子商户号。 * * <p>下发 {@code 3}(复用上级微信商户号)曾是默认值,但上级是渠道商、没有商户级微信配置, * 通道会拒:「失败:复用交易信息,上级商户号为空」。2026-09-15 的真实补开在不带该字段时被受理, * 线上公众号/小程序/反扫三场景各自入驻成功并返回自己的 {@code sub_mer_id}。 */ @Test void businessOpenReusesTheSuperiorWeChatMerchantNumber() { void businessOpenLeavesTheUseChainsFlagOutByDefault() { Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest( properties(), "BO-TEST-1", "6666000241335656", "成都喜望科技", "5812", false); assertThat(request).containsEntry("use_chains_flag", "3"); assertThat(request).doesNotContainKey("use_chains_flag"); } @Test void aBlankUseChainsFlagKeepsTheFieldOutOfTheRequest() { void aConfiguredUseChainsFlagIsSentAsIs() { HuifuPaymentProperties properties = properties(); properties.setMerchantUseChainsFlag(" "); properties.setMerchantUseChainsFlag("3"); Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest( properties, "BO-TEST-1", "6666000241335656", "成都喜望科技", "5812", false); assertThat(request).doesNotContainKey("use_chains_flag"); assertThat(request).containsEntry("use_chains_flag", "3"); } private static HuifuPaymentProperties properties() {