yeg
2026-09-15 c3922ab8f016b3664738c908a34eb049bcdfd080
feat(huifu): 按汇付规则补齐商户业务开通的两个必填字段

以最新线上文档(https://paas.huifu.com/partners/api/doc/ 的《商户业务开通》)为准逐项对账,
发现两处该发而未发的字段:

1. media_mer_common_flag(顶层):运营媒介主体与商户主体是否一致。平台用一个运营媒介为众多
子商户开通业务,主体天然不一致,取 N。这是顶层必填——不传会被通道拒,实测报
「【media_merCommon_flag-媒介主体与商户主体是否一致】不能为空,或格式非法」。
2. micro_biz_type(wx_conf_list 元素内的 wx_real_name_info):文档写明「个人商户需要填写」,
且三种取值(门店场所/流动经营/线上商品服务交易)都要求 F22 店铺门头照 + F24 店铺内景照。

层级依据:文档里 wx_real_name_info 既是父字段列、又有同名子表;按本接口的写法约定
(与 online_media_info_list 相同的写法)它是嵌套 jsonObject,因此 micro_biz_type 放进
wx_conf_list 元素里,不平铺到顶层。企业主体不塞这个字段。

顺带把请求组装从 open() 里拆成 buildRequest(),与发送解耦,便于直接断言字段与层级。

照片(F22/F24)尚未准备,本次只补字段取值;缺什么材料由通道在参数校验阶段点名,
避免现在拿不到权限时靠猜。配置项:
app.payment.huifu.merchant-media-subject-same(默认 N)
app.payment.huifu.merchant-micro-business-type(默认 MICRO_TYPE_ONLINE)

新增 HuifuBusinessOpenRequiredFieldsTests 3 项,连同相关测试共 64 项通过。
1个文件已添加
4个文件已修改
177 ■■■■■ 已修改文件
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantBusinessOpenGateway.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentProperties.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/application.yml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuBusinessOpenRequiredFieldsTests.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -448,7 +448,7 @@
            Long merchantId = ((Number) application.get("merchantId")).longValue();
            jdbc.update("update merchant_onboarding_application set status='pending', business_open_status='processing', business_open_request_no=?, reject_reason=null, reviewed_at=null where id=?", requestNo, application.get("id"));
            try {
                HuifuMerchantBusinessOpenGateway.Result result = merchantBusinessOpenGateway.open(requestNo, huifuId, shortName, mcc(merchantId));
                HuifuMerchantBusinessOpenGateway.Result result = merchantBusinessOpenGateway.open(requestNo, huifuId, shortName, mcc(merchantId), "individual".equalsIgnoreCase(clean(application.get("subjectType"))));
                if (!result.accepted()) {
                    jdbc.update("update merchant_onboarding_application set business_open_status='failed', business_open_apply_no=nullif(?,''), business_open_response_code=?, business_open_response_message=?, reject_reason=? where id=?", clean(result.applyNo()), result.code(), result.message(), "商户业务开通失败:" + providerReason(result.code(), result.message()), application.get("id"));
                    return;
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantBusinessOpenGateway.java
@@ -23,13 +23,53 @@
    public HuifuMerchantBusinessOpenGateway(HuifuPaymentProperties properties) { this.properties = properties; }
    public Result open(String requestNo, String huifuId, String shortName, String mcc) {
    public Result open(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant) {
        ensureConfigured();
        Map<String, Object> request = buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant);
        try {
            synchronized (SDK_LOCK) {
                initializeSdk();
                Map<String, Object> response = BasePayClient.request(request, "v2.merchant.busi.open", false);
                log.info("Huifu merchant business open response: requestNo={}, huifuId={}, response={}", requestNo, huifuId, JSON.toJSONString(response));
                return Result.from(response);
            }
        } catch (BusinessException exception) {
            throw exception;
        } catch (Exception exception) {
            log.error("Huifu merchant business open request error: requestNo={}, huifuId={}", requestNo, huifuId, exception);
            throw new HuifuPaymentGatewayException("Huifu merchant business open request failed", outcomeUnknown(exception), exception);
        }
    }
    /**
     * 组装「商户业务开通」请求体。与发送解耦,便于直接断言字段与层级
     * (见 {@code HuifuBusinessOpenRequiredFieldsTests})。
     *
     * <p>{@code individualMerchant} 为真时把 {@code micro_biz_type} 塞进
     * {@code wx_conf_list} 元素里的 {@code wx_real_name_info}——文档写明个人商户需要填写,
     * 而该字段在文档中属于 wx_real_name_info 子表(父字段列 + 同名子表的写法约定即为嵌套对象)。
     */
    static Map<String, Object> buildRequestForTest(HuifuPaymentProperties properties, String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant) {
        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");
        // 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));
            }
        }
        Map<String, Object> media = new LinkedHashMap<>();
        media.put("media_type", "S3");
@@ -48,6 +88,9 @@
        request.put("wx_conf_list", JSON.toJSONString(List.of(wechat)));
        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());
        request.put("agreement_info", JSON.toJSONString(agreement));
        // 进件时就把延迟入账能力配好:先付钱后核销的订单必须把钱留在通道「待确认」,
        // 核销确认后才分配给商户。forced_delay_flag 保持关闭——支付即核销的到店消费订单不该被延迟。
@@ -59,19 +102,7 @@
        if (properties.isOnboardingUnderParentMerchant()) request.put("use_chains_flag", "3");
        putIfPresent(request, "async_return_url", properties.getMerchantBusinessOpenNotifyUrl());
        putIfPresent(request, "busi_async_return_url", properties.getMerchantBusinessOpenNotifyUrl());
        try {
            synchronized (SDK_LOCK) {
                initializeSdk();
                Map<String, Object> response = BasePayClient.request(request, "v2.merchant.busi.open", false);
                log.info("Huifu merchant business open response: requestNo={}, huifuId={}, response={}", requestNo, huifuId, JSON.toJSONString(response));
                return Result.from(response);
            }
        } catch (BusinessException exception) {
            throw exception;
        } catch (Exception exception) {
            log.error("Huifu merchant business open request error: requestNo={}, huifuId={}", requestNo, huifuId, exception);
            throw new HuifuPaymentGatewayException("Huifu merchant business open request failed", outcomeUnknown(exception), exception);
        }
        return request;
    }
    private void ensureConfigured() {
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentProperties.java
@@ -36,6 +36,22 @@
    private String merchantWechatFeeRate = "0.60";
    private String merchantOnlineBusinessType = "H5311L";
    private String merchantOnlineMediaName = "东区人才卡";
    /**
     * 运营媒介主体与商户主体是否一致({@code media_mer_common_flag},商户业务开通的顶层参数)。
     *
     * <p>平台用同一个运营媒介为众多子商户开通业务,媒介主体(平台)与商户主体天然不一致,
     * 因此填 {@code N}。不填会被通道拒——实测报
     * 「【media_merCommon_flag-媒介主体与商户主体是否一致】不能为空,或格式非法」。
     */
    private String merchantMediaSubjectSame = "N";
    /**
     * 小微经营类型({@code micro_biz_type},个人商户开通微信业务时必填)。
     *
     * <p>MICRO_TYPE_STORE 门店场所 / MICRO_TYPE_MOBILE 流动经营 / MICRO_TYPE_ONLINE 线上商品服务交易。
     * 文档注明「个人商户需要填写」,且**三种取值都需要 F22 店铺门头照与 F24 店铺内景照**,
     * 补充资料挂在 {@code extended_material_list} 里。
     */
    private String merchantMicroBusinessType = "MICRO_TYPE_ONLINE";
    private String merchantAgreementUrl = "https://cloudpnrcdn.oss-cn-shanghai.aliyuncs.com/opps/api/prod/download_file/PaymentServiceAgreement.htm";
    private Boolean merchantCapabilityRequired;
@@ -95,6 +111,10 @@
    public void setMerchantOnlineBusinessType(String merchantOnlineBusinessType) { this.merchantOnlineBusinessType = merchantOnlineBusinessType; }
    public String getMerchantOnlineMediaName() { return merchantOnlineMediaName; }
    public void setMerchantOnlineMediaName(String merchantOnlineMediaName) { this.merchantOnlineMediaName = merchantOnlineMediaName; }
    public String getMerchantMediaSubjectSame() { return merchantMediaSubjectSame == null ? "N" : merchantMediaSubjectSame.trim(); }
    public void setMerchantMediaSubjectSame(String merchantMediaSubjectSame) { this.merchantMediaSubjectSame = merchantMediaSubjectSame; }
    public String getMerchantMicroBusinessType() { return merchantMicroBusinessType == null ? "" : merchantMicroBusinessType.trim(); }
    public void setMerchantMicroBusinessType(String merchantMicroBusinessType) { this.merchantMicroBusinessType = merchantMicroBusinessType; }
    public String getMerchantAgreementUrl() { return merchantAgreementUrl; }
    public void setMerchantAgreementUrl(String merchantAgreementUrl) { this.merchantAgreementUrl = merchantAgreementUrl; }
    /** Provider-side capability confirmation is on whenever the provider integration is enabled. */
talent-card-server/src/main/resources/application.yml
@@ -93,6 +93,11 @@
      merchant-wechat-fee-rate: ${HUIFU_MERCHANT_WECHAT_FEE_RATE:0.60}
      merchant-online-business-type: ${HUIFU_MERCHANT_ONLINE_BUSINESS_TYPE:H5311L}
      merchant-online-media-name: ${HUIFU_MERCHANT_ONLINE_MEDIA_NAME:东区人才卡}
      # 运营媒介主体与商户主体是否一致。平台用一个运营媒介给众多子商户开通,主体天然不一致。
      # 这是商户业务开通的顶层必填,缺了会被通道拒(实测报 media_merCommon_flag 不能为空)。
      merchant-media-subject-same: ${HUIFU_MERCHANT_MEDIA_SUBJECT_SAME:N}
      # 小微经营类型,个人商户开通微信业务时必填;三种取值都要求 F22 门头照 + F24 内景照。
      merchant-micro-business-type: ${HUIFU_MERCHANT_MICRO_BUSINESS_TYPE:MICRO_TYPE_ONLINE}
      merchant-agreement-url: ${HUIFU_MERCHANT_AGREEMENT_URL:https://cloudpnrcdn.oss-cn-shanghai.aliyuncs.com/opps/api/prod/download_file/PaymentServiceAgreement.htm}
      # 收款能力只要求「斗拱子商户正常 + 取现可用」:未核销的钱由延迟户兜住,
      # 自动结算开着不再影响收款。设 false 仅用于刻意跳过通道能力确认的部署。
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuBusinessOpenRequiredFieldsTests.java
New file
@@ -0,0 +1,91 @@
package com.talentcard.server.business.payment.huifu;
import static org.assertj.core.api.Assertions.assertThat;
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}(顶层):运营媒介主体与商户主体是否一致。平台用一个运营媒介
 *       给众多子商户开通,主体天然不一致。实测缺它会被拒:
 *       「【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 theMediaSubjectFlagIsAlwaysSent() {
        Map<String, Object> request = request(false);
        // 平台媒介主体与商户主体不一致,取 N;关键是必须出现,否则通道判参数非法。
        assertThat(request).containsEntry("media_mer_common_flag", "N");
    }
    @Test
    void anIndividualMerchantCarriesTheMicroBusinessTypeInsideTheWeChatConfig() {
        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");
        // 层级:micro_biz_type 在 wx_real_name_info 这个嵌套 jsonObject 里,不在元素顶层。
        assertThat(wechat.get(0)).doesNotContainKey("micro_biz_type");
        Map<String, Object> realName = JSON.parseObject(String.valueOf(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.get(0)).doesNotContainKey("wx_real_name_info");
    }
    /**
     * 直接驱动组装逻辑。网关的 {@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);
    }
}