package cc.mrbird.febs.pay.service;
|
|
import cc.mrbird.febs.common.enumerates.OrderStatusEnum;
|
import cc.mrbird.febs.common.utils.RedisUtils;
|
import cc.mrbird.febs.mall.controller.dependentStation.constant.OrderConstants;
|
import cc.mrbird.febs.mall.entity.DataDictionaryCustom;
|
import cc.mrbird.febs.mall.entity.MallOrderInfo;
|
import cc.mrbird.febs.mall.mapper.DataDictionaryCustomMapper;
|
import cc.mrbird.febs.mall.mapper.MallOrderInfoMapper;
|
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.crypto.SecureUtil;
|
import com.alibaba.fastjson.JSONObject;
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import lombok.extern.slf4j.Slf4j;
|
import okhttp3.MediaType;
|
import okhttp3.OkHttpClient;
|
import okhttp3.Request;
|
import okhttp3.RequestBody;
|
import okhttp3.Response;
|
import org.springframework.stereotype.Service;
|
|
import javax.annotation.Resource;
|
import java.math.BigDecimal;
|
import java.math.RoundingMode;
|
import java.util.Date;
|
import java.util.Map;
|
import java.util.TreeMap;
|
|
/**
|
* BSPAY (巴西PIX) 支付服务
|
* <p>
|
* 处理 BSPAY 下单(PIX支付)和回调通知
|
* <p>
|
* BSPAY API 说明:
|
* - 传输方式:HTTPS,POST,JSON
|
* - 签名算法:参数按 ASCII 字典序排序 → key=value 拼接 → 末尾追加 &key={secret} → MD5 → 大写
|
* - 下单接口:POST /api/pay/applyOrder (Content-Type: application/json)
|
* - 支付回调:JSON POST,收到后需响应 "SUCCESS"
|
* - 交易类型:trade_type=201 (巴西PIX)
|
* - 货币类型:fee_type=BRL
|
* - 金额单位:分 (1元=100分)
|
*
|
* @author auto-generated
|
*/
|
@Slf4j
|
@Service
|
public class BsPayService {
|
|
@Resource
|
private MallOrderInfoMapper mallOrderInfoMapper;
|
@Resource
|
private DataDictionaryCustomMapper dataDictionaryCustomMapper;
|
@Resource
|
private RedisUtils redisUtils;
|
|
/** BSPAY 配置字典 type */
|
private static final String DICT_TYPE = "BSPAY_CONFIG";
|
/** BSPAY 下单接口路径 */
|
private static final String APPLY_ORDER_PATH = "/api/pay/applyOrder";
|
/** OkHttp JSON MediaType */
|
private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json;charset=UTF-8");
|
|
/** 交易类型:巴西PIX */
|
private static final String TRADE_TYPE_PIX = "201";
|
/** 货币类型:巴西雷亚尔 */
|
private static final String FEE_TYPE_BRL = "BRL";
|
/** 签名类型 */
|
private static final String SIGN_TYPE_MD5 = "MD5";
|
|
// ==================== 下单接口 ====================
|
|
/**
|
* 创建 BSPAY PIX 支付订单 → 获取支付跳转 URL
|
*
|
* @param order 订单实体
|
* @return BSPAY 支付页面 URL (mweb_url)
|
*/
|
public String createPayment(MallOrderInfo order) {
|
String memberId = getConfigValue("MEMBER_ID");
|
String secretKey = getConfigValue("SECRET_KEY");
|
String notifyUrl = getConfigValue("NOTIFY_URL");
|
String apiBaseUrl = getConfigValue("API_BASE_URL");
|
|
if (StrUtil.isBlank(memberId) || StrUtil.isBlank(secretKey)) {
|
throw new RuntimeException("BSPAY 商户号/秘钥未配置");
|
}
|
if (StrUtil.isBlank(apiBaseUrl)) {
|
throw new RuntimeException("BSPAY API基础地址未配置");
|
}
|
if (StrUtil.isBlank(notifyUrl)) {
|
throw new RuntimeException("BSPAY 回调地址未配置");
|
}
|
|
// 金额转 BRL 分 (cents)
|
String totalFee = getBRLAmountInCents(order.getAmount());
|
|
// 构建参数(TreeMap 自动按 ASCII 字典序排序)
|
TreeMap<String, String> params = new TreeMap<>();
|
params.put("member_id", memberId);
|
params.put("out_trade_no", order.getOrderNo());
|
params.put("body", "日用品-商品订单-" + order.getOrderNo());
|
params.put("total_fee", totalFee);
|
params.put("fee_type", FEE_TYPE_BRL);
|
params.put("notify_url", notifyUrl);
|
params.put("trade_type", TRADE_TYPE_PIX);
|
params.put("sign_type", SIGN_TYPE_MD5);
|
|
// 生成签名
|
String sign = generateSign(params, secretKey);
|
params.put("sign", sign);
|
|
log.info("BSPAY 下单请求: memberId={}, orderNo={}, amount={}, totalFee={}",
|
memberId, order.getOrderNo(), order.getAmount(), totalFee);
|
|
// 发送 POST JSON 请求
|
String requestUrl = apiBaseUrl + APPLY_ORDER_PATH;
|
String response = postJson(requestUrl, params);
|
log.info("BSPAY 下单响应: {}", response);
|
|
if (StrUtil.isBlank(response)) {
|
throw new RuntimeException("BSPAY 响应为空");
|
}
|
|
// 解析响应
|
JSONObject json = JSONObject.parseObject(response);
|
|
// 检查通信状态
|
String returnCode = json.getString("return_code");
|
if (!"SUCCESS".equals(returnCode)) {
|
String returnMsg = json.getString("return_msg");
|
String errCode = json.getString("err_code");
|
String errCodeDes = json.getString("err_code_des");
|
throw new RuntimeException("BSPAY 下单失败: " + returnMsg
|
+ (errCode != null ? " [" + errCode + ":" + errCodeDes + "]" : ""));
|
}
|
|
// 验证签名(sign 不参与签名)
|
String respSign = json.getString("sign");
|
if (StrUtil.isNotBlank(respSign)) {
|
TreeMap<String, String> verifyParams = new TreeMap<>();
|
for (String key : json.keySet()) {
|
if (!"sign".equals(key)) {
|
String val = json.getString(key);
|
if (StrUtil.isNotBlank(val)) {
|
verifyParams.put(key, val);
|
}
|
}
|
}
|
if (!verifySign(verifyParams, secretKey, respSign)) {
|
log.warn("BSPAY 响应签名验证失败,但仍继续处理");
|
}
|
}
|
|
// 检查业务结果:部分版本有 result_code,部分版本只有 return_code
|
String resultCode = json.getString("result_code");
|
if (StrUtil.isNotBlank(resultCode) && !"SUCCESS".equals(resultCode)) {
|
throw new RuntimeException("BSPAY 下单业务失败: " + json.getString("err_code_des"));
|
}
|
|
String mwebUrl = json.getString("mweb_url");
|
if (StrUtil.isBlank(mwebUrl)) {
|
throw new RuntimeException("BSPAY 下单未返回支付链接");
|
}
|
|
// 记录平台支付订单号
|
String prepayId = json.getString("prepay_id");
|
if (StrUtil.isNotBlank(prepayId)) {
|
mallOrderInfoMapper.update(
|
null,
|
Wrappers.lambdaUpdate(MallOrderInfo.class)
|
.set(MallOrderInfo::getPayOrderNo, prepayId)
|
.eq(MallOrderInfo::getId, order.getId())
|
);
|
}
|
|
return mwebUrl;
|
}
|
|
// ==================== 回调处理 ====================
|
|
/**
|
* 处理 BSPAY 支付结果通知
|
* <p>
|
* 回调参数为 JSON 格式:
|
* return_code, return_msg, member_id, sign, result_code, trade_type,
|
* total_fee, transaction_id, out_trade_no, time_end
|
*
|
* @param params 回调参数 Map
|
* @return true-处理成功, false-处理失败
|
*/
|
public boolean handleCallback(Map<String, String> params) {
|
String secretKey = getConfigValue("SECRET_KEY");
|
if (StrUtil.isBlank(secretKey)) {
|
log.error("BSPAY 秘钥未配置,无法验签");
|
return false;
|
}
|
|
// 1. 验证签名(sign 不参与签名)
|
String receivedSign = params.get("sign");
|
if (StrUtil.isBlank(receivedSign)) {
|
log.error("BSPAY 回调缺少 sign 参数");
|
return false;
|
}
|
|
TreeMap<String, String> signParams = new TreeMap<>();
|
for (Map.Entry<String, String> entry : params.entrySet()) {
|
if (!"sign".equals(entry.getKey()) && StrUtil.isNotBlank(entry.getValue())) {
|
signParams.put(entry.getKey(), entry.getValue());
|
}
|
}
|
if (!verifySign(signParams, secretKey, receivedSign)) {
|
log.error("BSPAY 回调签名验证失败: params={}", params);
|
return false;
|
}
|
|
String returnCode = params.get("return_code");
|
String resultCode = params.get("result_code");
|
String outTradeNo = params.get("out_trade_no");
|
String totalFee = params.get("total_fee");
|
String transactionId = params.get("transaction_id");
|
String tradeType = params.get("trade_type");
|
|
log.info("BSPAY 回调: out_trade_no={}, return_code={}, result_code={}, total_fee={}, transaction_id={}",
|
outTradeNo, returnCode, resultCode, totalFee, transactionId);
|
|
// 2. 判断通信状态
|
if (!"SUCCESS".equals(returnCode)) {
|
log.warn("BSPAY 回调通信失败: out_trade_no={}, return_code={}", outTradeNo, returnCode);
|
return true;
|
}
|
|
// 3. 判断支付结果
|
if (!"SUCCESS".equals(resultCode)) {
|
log.warn("BSPAY 支付未成功: out_trade_no={}, result_code={}", outTradeNo, resultCode);
|
return true;
|
}
|
|
// 4. 查询订单
|
MallOrderInfo order = mallOrderInfoMapper.selectOne(
|
Wrappers.lambdaQuery(MallOrderInfo.class)
|
.eq(MallOrderInfo::getOrderNo, outTradeNo)
|
);
|
if (order == null) {
|
log.error("BSPAY 回调订单不存在: out_trade_no={}", outTradeNo);
|
return false;
|
}
|
|
// 5. 幂等检查(避免重复回调)
|
if (OrderStatusEnum.WAIT_PAY.getValue() != order.getStatus()) {
|
log.info("BSPAY 订单已处理: out_trade_no={}, status={}", outTradeNo, order.getStatus());
|
return true;
|
}
|
|
// 6. 更新订单状态为待发货
|
mallOrderInfoMapper.update(
|
null,
|
Wrappers.lambdaUpdate(MallOrderInfo.class)
|
.set(MallOrderInfo::getStatus, OrderStatusEnum.WAIT_SHIPPING.getValue())
|
.set(MallOrderInfo::getTradeHash, transactionId)
|
.set(MallOrderInfo::getPayTime, new Date())
|
.set(MallOrderInfo::getPayMethod, "BSPAY支付(PIX)")
|
.set(MallOrderInfo::getPayResult, "1")
|
.eq(MallOrderInfo::getId, order.getId())
|
);
|
|
// 清理 Redis 中的金额匹配 key
|
String amountKey = OrderConstants.TRC20_ORDER_KEY + order.getAmount();
|
redisUtils.del(amountKey);
|
|
log.info("BSPAY 支付成功: out_trade_no={}, transaction_id={}, total_fee={}",
|
outTradeNo, transactionId, totalFee);
|
return true;
|
}
|
|
// ==================== 金额转换 ====================
|
|
/**
|
* 将订单金额转换为 BRL 分(cents)
|
* 1. 先按汇率转换为 BRL
|
* 2. 再乘以 100 转换为分
|
*
|
* @param amount 订单金额
|
* @return BRL 金额(分,不含小数)
|
*/
|
private String getBRLAmountInCents(BigDecimal amount) {
|
DataDictionaryCustom dataDictionaryCustom = dataDictionaryCustomMapper.selectDicDataByTypeAndCode(
|
"MONEY_CHANGE", "BRL"
|
);
|
BigDecimal rate = new BigDecimal("5.18");
|
if (dataDictionaryCustom != null) {
|
rate = new BigDecimal(dataDictionaryCustom.getValue());
|
}
|
// 转 BRL → 转分 → 取整数
|
return amount.multiply(rate)
|
.multiply(new BigDecimal("100"))
|
.setScale(0, RoundingMode.DOWN)
|
.toString();
|
}
|
|
// ==================== 签名工具 ====================
|
|
/**
|
* 生成 MD5 签名(大写)
|
* <p>
|
* 1. 参数按 ASCII 字典序排序 (TreeMap)
|
* 2. key=value&key=value 拼接
|
* 3. 末尾追加 &key={secretKey}
|
* 4. MD5 → 大写
|
*/
|
private String generateSign(TreeMap<String, String> params, String secretKey) {
|
String signStr = buildSignString(params) + "&key=" + secretKey;
|
log.info("BSPAY 待签名字符串: {}", signStr);
|
return SecureUtil.md5(signStr).toUpperCase();
|
}
|
|
/**
|
* 验证签名
|
*/
|
private boolean verifySign(TreeMap<String, String> params, String secretKey, String expectedSign) {
|
String calculatedSign = generateSign(params, secretKey);
|
boolean valid = calculatedSign.equalsIgnoreCase(expectedSign);
|
if (!valid) {
|
log.warn("BSPAY 签名不匹配: expected={}, calculated={}", expectedSign, calculatedSign);
|
}
|
return valid;
|
}
|
|
/**
|
* 将参数拼接为 key=value&key=value 格式(不含 &key=)
|
*/
|
private String buildSignString(TreeMap<String, String> params) {
|
StringBuilder sb = new StringBuilder();
|
for (Map.Entry<String, String> entry : params.entrySet()) {
|
if (StrUtil.isNotBlank(entry.getValue())) {
|
if (sb.length() > 0) {
|
sb.append("&");
|
}
|
sb.append(entry.getKey()).append("=").append(entry.getValue());
|
}
|
}
|
return sb.toString();
|
}
|
|
// ==================== 配置读取 ====================
|
|
/**
|
* 从数据字典获取 BSPAY 配置
|
*/
|
public String getConfigValue(String code) {
|
DataDictionaryCustom dict = dataDictionaryCustomMapper.selectDicDataByTypeAndCode(DICT_TYPE, code);
|
return dict != null ? dict.getValue() : null;
|
}
|
|
// ==================== HTTP 请求工具 ====================
|
|
/**
|
* POST JSON 请求
|
*/
|
private String postJson(String url, TreeMap<String, String> params) {
|
try {
|
String jsonBody = JSONObject.toJSONString(params);
|
log.info("BSPAY POST JSON: url={}, body={}", url, jsonBody);
|
|
OkHttpClient client = new OkHttpClient();
|
RequestBody body = RequestBody.create(JSON_MEDIA_TYPE, jsonBody);
|
Request request = new Request.Builder()
|
.url(url)
|
.post(body)
|
.build();
|
|
try (Response response = client.newCall(request).execute()) {
|
if (response.body() != null) {
|
return response.body().string();
|
}
|
return null;
|
}
|
} catch (Exception e) {
|
log.error("BSPAY POST JSON 异常: url={}", url, e);
|
return null;
|
}
|
}
|
}
|