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());
|
// 进件场景:线下 OFFLINE(线上 ONLINE)。线下属「有实体场所」口径,经营详细地址随之变成必填,
|
// 所以 detail_addr 取商户填的经营地址,不再用证件地址顶替(见 legal_addr 与 detail_addr 的分离)。
|
request.put("scene_type", properties.getMerchantOnboardingSceneType());
|
request.put("prov_id", command.provinceCode());
|
request.put("area_id", command.cityCode());
|
request.put("district_id", command.districtCode());
|
request.put("detail_addr", command.businessAddress());
|
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());
|
// 线下经营的门店材料是一组三张:门头照(F22)、内景照(F24)、收银台(F105)。通道的
|
// 「商户材料校验服务」在进件这一步按 F-code 逐张核对,缺一张就整笔驳回——报错原文
|
// 「缺失图片:F22-店铺门头照图片;F24-店铺内景图片;F105-店铺收银台」(2026-09-29 实测)。
|
// 字段名 store_indoor_pic / store_cashier_desk_pic 取自通道自己的报错原文。
|
putIfPresent(request, "store_indoor_pic", command.storeIndoorPictureId());
|
putIfPresent(request, "store_cashier_desk_pic", command.storeCashierPictureId());
|
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 ""; }
|
}
|
}
|