talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantBusinessOpenGateway.java
@@ -24,14 +24,26 @@
    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) {
@@ -51,7 +63,11 @@
     * 而该字段在文档中属于 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);
    }
    /**
@@ -63,18 +79,23 @@
     */
    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());
@@ -88,7 +109,13 @@
                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);
@@ -105,6 +132,15 @@
        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 配上;注意声明
@@ -146,7 +182,32 @@
        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"); }