| | |
| | | |
| | | public HuifuMerchantBusinessOpenGateway(HuifuPaymentProperties properties) { this.properties = properties; } |
| | | |
| | | public Result open(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant) { |
| | | /** |
| | | * 提交商户业务开通申请。 |
| | | * |
| | | * @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<String, Object> request = buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant); |
| | | Map<String, Object> request = buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant, mccRequired, storeHeaderFileId, storeIndoorFileId); |
| | | 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)); |
| | | // 连 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) { |
| | |
| | | * 而该字段在文档中属于 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); |
| | | return buildRequestForTest(properties, requestNo, huifuId, shortName, mcc, individualMerchant, individualMerchant, "", ""); |
| | | } |
| | | |
| | | static Map<String, Object> 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); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | private static final List<String> ONLINE_WECHAT_SCENES = List.of("4", "5", "12"); |
| | | |
| | | private Map<String, Object> buildRequest(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant) { |
| | | String feeRuleId = properties.getMerchantWechatFeeRuleId(); |
| | | private Map<String, Object> buildRequest(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant, boolean mccRequired, String storeHeaderFileId, String storeIndoorFileId) { |
| | | String feeRuleId = feeRuleId(individualMerchant, mccRequired); |
| | | 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 猜测套用到其它行业。 |
| | | // 企业用汇付确认的 fee_rule_id 选费率规则。 |
| | | if (!feeRuleId.isBlank()) { |
| | | wechat.put("fee_rule_id", feeRuleId); |
| | | } else { |
| | | // mcc:文档注明「个体工商户、小微商户必填」——个人主体属于小微商户,所以必须带上。 |
| | | } |
| | | // 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()); |
| | |
| | | if (!microBizType.isBlank()) { |
| | | Map<String, Object> realName = new LinkedHashMap<>(); |
| | | realName.put("micro_biz_type", microBizType); |
| | | wechat.put("wx_real_name_info", JSON.toJSONString(realName)); |
| | | // 嵌套 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); |
| | |
| | | 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<Map<String, Object>> 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 配上;注意声明 |
| | |
| | | BasePay.initWithMerConfig(config); |
| | | } |
| | | |
| | | /** |
| | | * 微信费率规则号(CID)**按主体类型**取:官方《微信费率规则ID》对照表用的是 |
| | | * 「支付场景 × 业务开通类型 × 主体类型」三维定位,本平台只开线上公众号/小程序/线上反扫 |
| | | * (场景 4/5/12)、业务开通类型恒为 01,所以只剩主体类型这一个变量: |
| | | * 企业 756、个体工商户 761、个人(小微)775。 |
| | | * |
| | | * <p>用错号不是"费率算错"而是直接被拒——通道回 |
| | | * 「微信费率规则号填写不正确,请检查!」(2026-09-16 拿企业的 756 给小微商户实测)。 |
| | | * |
| | | * <p>个体工商户落的是企业进件接口({@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<String, Object> request, String key, String value) { if (!blank(value)) request.put(key, value.trim()); } |
| | | private static void putMaterial(List<Map<String, Object>> materials, String fileType, String fileId) { |
| | | if (blank(fileId)) return; |
| | | Map<String, Object> 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"); } |
| | | |