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
145
146
147
148
149
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 HuifuIndividualOnboardingGateway {
    private static final Object SDK_LOCK = new Object();
    private static final Logger log = LoggerFactory.getLogger(HuifuIndividualOnboardingGateway.class);
    private final HuifuPaymentProperties properties;
 
    public HuifuIndividualOnboardingGateway(HuifuPaymentProperties properties) { this.properties = properties; }
 
    public Result create(HuifuIndividualOnboardingCommand command) {
        ensureConfigured();
        Map<String, Object> request = buildRequest(command);
        try {
            synchronized (SDK_LOCK) {
                initializeSdk();
                Map<String, Object> response = BasePayClient.request(request, "v2.merchant.basicdata.indv", false);
                log.info("Huifu individual merchant onboarding response: applicationNo={}, upperHuifuId={}, response={}", command.applicationNo(), properties.getOnboardingUpperHuifuId(), JSON.toJSONString(response));
                return Result.from(response);
            }
        } catch (BusinessException exception) {
            throw exception;
        } catch (Exception exception) {
            log.error("Huifu individual merchant onboarding request error: applicationNo={}, upperHuifuId={}", command.applicationNo(), properties.getOnboardingUpperHuifuId(), exception);
            throw new HuifuPaymentGatewayException("Huifu individual merchant onboarding request failed", outcomeUnknown(exception), exception);
        }
    }
 
    /**
     * 组装个人进件请求体,与发送解耦,便于直接断言商户归属
     * (见 {@code HuifuOnboardingMerchantLevelTests})。
     */
    static Map<String, Object> buildRequestForTest(HuifuPaymentProperties properties, HuifuIndividualOnboardingCommand command) {
        return new HuifuIndividualOnboardingGateway(properties).buildRequest(command);
    }
 
    private Map<String, Object> buildRequest(HuifuIndividualOnboardingCommand command) {
        Map<String, Object> card = new LinkedHashMap<>();
        card.put("card_type", "1");
        card.put("card_name", command.name());
        card.put("card_no", command.cardNumber());
        card.put("prov_id", command.bankProvinceCode());
        card.put("area_id", command.bankCityCode());
        card.put("bank_code", command.bankCode());
        card.put("cert_type", "00");
        card.put("cert_no", command.idCardNo());
        card.put("cert_validity_type", command.certificateValidityType());
        card.put("cert_begin_date", command.certificateBeginDate());
        // 长期有效(cert_validity_type=1)时截止日期留空——官方写明「当 cert_validity_type=1 时为空」。
        if (!"1".equals(command.certificateValidityType()) && !blank(command.certificateEndDate())) card.put("cert_end_date", command.certificateEndDate());
        card.put("mp", command.mobileNo());
        card.put("is_settle_default", "Y");
 
        Map<String, Object> cash = new LinkedHashMap<>();
        cash.put("cash_type", "DM");
        cash.put("fix_amt", "0.00");
        cash.put("fee_rate", "");
 
        Map<String, Object> request = new LinkedHashMap<>();
        request.put("req_seq_id", command.applicationNo());
        request.put("req_date", LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE));
        request.put("upper_huifu_id", properties.getOnboardingUpperHuifuId().trim());
        request.put("reg_name", command.name());
        request.put("short_name", command.name());
        request.put("mcc", command.mcc());
        request.put("scene_type", "ONLINE");
        request.put("prov_id", command.provinceCode());
        request.put("area_id", command.cityCode());
        request.put("district_id", command.districtCode());
        request.put("detail_addr", command.address());
        request.put("legal_cert_no", command.idCardNo());
        request.put("legal_cert_validity_type", command.certificateValidityType());
        request.put("legal_cert_begin_date", command.certificateBeginDate());
        // 证件「长期有效」(validity_type=1)时不填截止日期——官方口径已确认:长期有效无需填写。
        if (!"1".equals(command.certificateValidityType())) putIfPresent(request, "legal_cert_end_date", command.certificateEndDate());
        request.put("legal_addr", command.address());
        request.put("legal_cert_front_pic", command.legalFrontPictureId());
        request.put("legal_cert_back_pic", command.legalBackPictureId());
        request.put("contact_name", command.name());
        request.put("contact_mobile_no", command.mobileNo());
        request.put("contact_email", command.email());
        request.put("login_name", loginName(command.applicationNo()));
        request.put("card_info", JSON.toJSONString(card));
        request.put("settle_card_front_pic", command.settlementCardFrontPictureId());
        // 店铺照片(F22):个人(小微)商户进件的必填项,三种经营类型都要传,只是图片内容要求不同——
        // 线上商品/服务交易传店铺首页截图、流动经营/便民服务传经营现场照、门店场所传实体门头照。
        // 这里只负责把商户交的图带上,内容按经营类型由商户端提示。
        request.put("store_header_pic", command.storeHeaderPictureId());
        request.put("cash_config", JSON.toJSONString(List.of(cash)));
        // 与商户主体一致:进件即打开自动结算,核销确认后的钱由斗拱按结算周期自动划到结算卡。
        request.put("settle_config", JSON.toJSONString(HuifuEnterpriseOnboardingGateway.settleConfig()));
        putIfPresent(request, "async_return_url", properties.getMerchantOnboardingNotifyUrl());
        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.getPrivateKey()) || blank(properties.getPlatformPublicKey())) throw new BusinessException(ErrorCode.CONFLICT, "Huifu merchant onboarding 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 String loginName(String applicationNo) { return ("tc" + applicationNo).replaceAll("[^0-9A-Za-z_]", ""); }
    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 huifuId, String applyNo, String tokenNo, 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, "huifu_id", "huifuId"), value(data, "apply_no", "applyNo"), value(data, "token_no", "tokenNo"), code, value(data, "sub_resp_desc", "resp_desc", "message"));
        }
        /**
         * Merchant onboarding return codes: {@code 00000000} means processed, {@code 90000000} means
         * the application was accepted and is under provider review. Treating the review status as a
         * failure would reject an application the provider actually accepted, so both are accepted
         * here; an accepted application without a merchant number or card token stays pending.
         */
        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<String, Object> data, String... keys) { for (String key : keys) if (data.get(key) != null) return String.valueOf(data.get(key)).trim(); return ""; }
    }
}