yeg
2026-09-15 5664368e6a90c6a0a9a09be711b47f84d2a63f1c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
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.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; }
 
    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");
        // 企业微信业务使用汇付确认的 fee_rule_id;仅在未配置规则 ID 时兼容旧的 mcc 路径。
        // 不得把参考商户的规则 ID 猜测套用到其它行业。
        if (!properties.getMerchantWechatFeeRuleId().isBlank()) {
            wechat.put("fee_rule_id", properties.getMerchantWechatFeeRuleId());
        } else {
            // 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");
        media.put("media_name", properties.getMerchantOnlineMediaName().trim());
 
        Map<String, Object> agreement = new LinkedHashMap<>();
        agreement.put("agreement_type", "3");
        agreement.put("agreement_url", properties.getMerchantAgreementUrl().trim());
 
        Map<String, Object> 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(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 保持关闭——支付即核销的到店消费订单不该被延迟。
        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.getMerchantOnlineMediaName()) || 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);
    }
 
    private static void putIfPresent(Map<String, Object> request, String key, String value) { if (!blank(value)) request.put(key, value.trim()); }
    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<String, Object> response) {
            Object raw = response.get("data");
            Map<String, Object> data = raw instanceof Map<?, ?> value ? (Map<String, Object>) 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"));
        }
        private static boolean success(String code) { return "00000000".equals(code) || "00000100".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code); }
        private static String value(Map<String, Object> data, String... keys) { for (String key : keys) if (data.get(key) != null) return String.valueOf(data.get(key)).trim(); return ""; }
    }
}