package com.talentcard.server.business.payment.huifu; import com.alibaba.fastjson.JSON; import com.huifu.bspay.sdk.opps.client.BasePayClient; import com.huifu.bspay.sdk.opps.core.BasePay; import com.huifu.bspay.sdk.opps.core.config.MerConfig; import com.talentcard.server.common.BusinessException; 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; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @Service public class HuifuMerchantBusinessOpenGateway { private static final Object SDK_LOCK = new Object(); private static final Logger log = LoggerFactory.getLogger(HuifuMerchantBusinessOpenGateway.class); private final HuifuPaymentProperties properties; public HuifuMerchantBusinessOpenGateway(HuifuPaymentProperties properties) { this.properties = properties; } /** * 提交商户业务开通申请。 * * @param individualMerchant 个人(小微)主体:决定 {@code micro_biz_type} 与门店材料 * @param mccRequired 必须下发 {@code mcc}:个体工商户与小微商户(个人主体)都算。 * 与 {@code individualMerchant} 分开,因为个体工商户走的是企业进件接口, * 它不是"个人主体",但官方同样要求 mcc 必填 */ public Result open(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant, boolean mccRequired, String storeHeaderFileId, String storeIndoorFileId) { ensureConfigured(); Map request = buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant, mccRequired, storeHeaderFileId, storeIndoorFileId); try { synchronized (SDK_LOCK) { initializeSdk(); Map response = BasePayClient.request(request, "v2.merchant.busi.open", false); // 连 wx_conf_list 一起记:通道对这个字段的报错只有一句「wx_conf_list格式错误」, // 不打出实际报文就只能靠手工反推(2026-09-16 为此多烧了两轮真实请求)。 // 只记这个字段——extended_material_list 里是图片标识,按高敏感数据不入日志。 log.info("Huifu merchant business open response: requestNo={}, huifuId={}, wxConfList={}, response={}", requestNo, huifuId, request.get("wx_conf_list"), 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})。 * *

{@code individualMerchant} 为真时把 {@code micro_biz_type} 塞进 * {@code wx_conf_list} 元素里的 {@code wx_real_name_info}——文档写明个人商户需要填写, * 而该字段在文档中属于 wx_real_name_info 子表(父字段列 + 同名子表的写法约定即为嵌套对象)。 */ static Map buildRequestForTest(HuifuPaymentProperties properties, String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant) { return buildRequestForTest(properties, requestNo, huifuId, shortName, mcc, individualMerchant, individualMerchant, "", ""); } static Map buildRequestForTest(HuifuPaymentProperties properties, String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant, boolean mccRequired, String storeHeaderFileId, String storeIndoorFileId) { return new HuifuMerchantBusinessOpenGateway(properties).buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant, mccRequired, storeHeaderFileId, storeIndoorFileId); } /** * 线上微信业务场景:4=线上公众号、5=线上小程序、12=线上反扫。 * *

三个场景一起开:平台的小程序、H5 公众号与反扫共用同一个子商户号,只开小程序会让另外两条 * 支付路径在通道侧没有配置。汇付《微信费率规则ID》表里场景 12 没有独立行,它与 4/5 共用同一条 * 线上规则——参考商户 {@code 6666000226048687} 线上三场景费率同为 0.70% 且真实生效。 */ private static final List ONLINE_WECHAT_SCENES = List.of("4", "5", "12"); private Map buildRequest(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant, boolean mccRequired, String storeHeaderFileId, String storeIndoorFileId) { String feeRuleId = feeRuleId(individualMerchant, mccRequired); List> wechatList = new ArrayList<>(); for (String scene : ONLINE_WECHAT_SCENES) { Map wechat = new LinkedHashMap<>(); wechat.put("pay_scene", scene); // 企业用汇付确认的 fee_rule_id 选费率规则。 if (!feeRuleId.isBlank()) { wechat.put("fee_rule_id", feeRuleId); } // mcc:官方写明「个体工商户、小微商户必填」——个人主体与个体工商户属于这两类, // **必须带上**,不能因为配了 fee_rule_id 就省掉。官方请求示例里 mcc 与 fee_rule_id 也是同时出现的 // (`"mcc":"111"` 与 `"fee_rule_id":"758"` 在同一个元素里)。 // 原先这条判断写在 fee_rule_id 的 else 分支里,配了规则号就永远发不出 mcc—— // 2026-09-16 个人商户业务开通被通道回 `wx_conf_list格式错误` 就是这个原因。 // 企业主体没有这项要求(文档原文:企业使用 fee_rule_id 代替原有 mcc,mcc 可不填),不下发。 if (mccRequired || feeRuleId.isBlank()) { 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 realName = new LinkedHashMap<>(); realName.put("micro_biz_type", microBizType); // 嵌套 jsonObject,**不能**再 JSON.toJSONString 一次。官方字段目录里 // `request.data.wx_conf_list[].wx_real_name_info` 的类型列是 `Object`、子字段按 // `wx_real_name_info.micro_biz_type` 逐层展开(对照:`wx_conf_list[]` 本身才是 // `String`,整个数组按 jsonArray字符串 发)。双重编码发出去是一段字符串, // 通道解析时按对象取字段取不到,回的就是「wx_conf_list格式错误」—— // 2026-09-16 个人商户业务开通实测。 wechat.put("wx_real_name_info", realName); } } wechatList.add(wechat); } Map agreement = new LinkedHashMap<>(); agreement.put("agreement_type", "3"); agreement.put("agreement_url", properties.getMerchantAgreementUrl().trim()); Map request = new LinkedHashMap<>(); request.put("req_seq_id", requestNo); request.put("req_date", LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)); 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(wechatList)); // 个人(小微)商户填了 micro_biz_type,就同时欠这两张门店照片:官方在 micro_biz_type 的说明里 // 写明「需要提供 F22:店铺门头照图片;F24:店铺内景图片,补充资料填写在 extended_material_list // 对象中」。企业主体没有这项要求,不下发(避免给企业商户凭空加材料要求)。 List> extendedMaterials = new ArrayList<>(); if (individualMerchant) { putMaterial(extendedMaterials, "F22", storeHeaderFileId); putMaterial(extendedMaterials, "F24", storeIndoorFileId); } if (!extendedMaterials.isEmpty()) request.put("extended_material_list", JSON.toJSONString(extendedMaterials)); request.put("online_busi_type", properties.getMerchantOnlineBusinessType().trim()); // 运营媒介只在开通快捷/网银/余额支付Pro/分账/大额转账/代扣时才需要,本网关只开线上微信, // 因此默认不下发。要下发就把 merchant-online-media-name 配上;注意声明 // media_mer_common_flag=N(媒介主体与商户主体不一致)时,通道会一并索要授权或开发证明材料。 String mediaName = properties.getMerchantOnlineMediaName().trim(); if (!mediaName.isBlank()) { Map 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 保持关闭——支付即核销的到店消费订单不该被延迟。 request.put("delay_flag", "Y"); request.put("forced_delay_flag", "N"); // 子商户复用上级(渠道商)的微信商户号发起交易:3 = 仅微信。 // 文档:使用上级微信、支付宝商户号需对应开通微信、支付宝功能。 putIfPresent(request, "use_chains_flag", properties.getMerchantUseChainsFlag()); putIfPresent(request, "async_return_url", properties.getMerchantBusinessOpenNotifyUrl()); putIfPresent(request, "busi_async_return_url", properties.getMerchantBusinessOpenNotifyUrl()); return request; } 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.getMerchantAgreementUrl()) || blank(properties.getPrivateKey()) || blank(properties.getPlatformPublicKey())) throw new BusinessException(ErrorCode.CONFLICT, "Huifu merchant business opening configuration is incomplete"); } private void initializeSdk() throws Exception { BasePay.debug = false; BasePay.prodMode = "prod".equalsIgnoreCase(properties.getEnvironment()) ? BasePay.MODE_PROD : BasePay.MODE_TEST; MerConfig config = new MerConfig(); config.setSysId(properties.getSystemId().trim()); config.setProductId(properties.getProductId().trim()); config.setRsaPrivateKey(properties.getPrivateKey().trim()); config.setRsaPublicKey(properties.getPlatformPublicKey().trim()); BasePay.initWithMerConfig(config); } /** * 微信费率规则号(CID)**按主体类型**取:官方《微信费率规则ID》对照表用的是 * 「支付场景 × 业务开通类型 × 主体类型」三维定位,本平台只开线上公众号/小程序/线上反扫 * (场景 4/5/12)、业务开通类型恒为 01,所以只剩主体类型这一个变量: * 企业 756、个体工商户 761、个人(小微)775。 * *

用错号不是"费率算错"而是直接被拒——通道回 * 「微信费率规则号填写不正确,请检查!」(2026-09-16 拿企业的 756 给小微商户实测)。 * *

个体工商户落的是企业进件接口({@code individualMerchant=false}),所以判据要用 * {@code mccRequired}——它覆盖的正是「个体工商户与小微商户」这一组。 */ private String feeRuleId(boolean individualMerchant, boolean mccRequired) { if (individualMerchant) return properties.getMerchantWechatFeeRuleIdIndividual(); if (mccRequired) return properties.getMerchantWechatFeeRuleIdSoleProprietor(); return properties.getMerchantWechatFeeRuleId(); } private static void putIfPresent(Map request, String key, String value) { if (!blank(value)) request.put(key, value.trim()); } private static void putMaterial(List> materials, String fileType, String fileId) { if (blank(fileId)) return; Map material = new LinkedHashMap<>(); material.put("file_id", fileId.trim()); material.put("file_type", fileType); materials.add(material); } private static boolean blank(String value) { return value == null || value.isBlank(); } private boolean outcomeUnknown(Exception exception) { String message = exception.getMessage() == null ? "" : exception.getMessage().toLowerCase(); return message.contains("timeout") || message.contains("timed out") || message.contains("no response"); } @SuppressWarnings("unchecked") public record Result(boolean accepted, String applyNo, String code, String message) { static Result from(Map response) { Object raw = response.get("data"); Map data = raw instanceof Map value ? (Map) value : raw instanceof String json && !json.isBlank() ? JSON.parseObject(json, Map.class) : response; 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")); } // 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 data, String... keys) { for (String key : keys) if (data.get(key) != null) return String.valueOf(data.get(key)).trim(); return ""; } } }