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 项通过。
| | |
| | | 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; |
| | |
| | | |
| | | 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"); |
| | |
| | | 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 保持关闭——支付即核销的到店消费订单不该被延迟。 |
| | |
| | | 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() { |
| | |
| | | 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; |
| | | |
| | |
| | | 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. */ |
| | |
| | | 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 仅用于刻意跳过通道能力确认的部署。 |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |