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 ""; }
|
}
|
}
|