feat: 新增vm支付

This commit is contained in:
old burden 2026-01-23 11:21:07 +08:00
parent 497b3b9c80
commit cbd461a1c4
9 changed files with 752 additions and 0 deletions

View File

@ -5,6 +5,7 @@ import com.ruoyi.ai.service.IAiRechargeService;
import com.ruoyi.ai.service.IJinShaService;
import com.ruoyi.ai.service.IKaDaService;
import com.ruoyi.ai.service.IYuZhouService;
import com.ruoyi.ai.service.IVmService;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.domain.AjaxResult;
import io.swagger.annotations.Api;
@ -25,6 +26,7 @@ public class PayController {
private final IKaDaService kaDaService;
private final IAiRechargeService aiRechargeService;
private final IYuZhouService yuZhouService;
private final IVmService vmService;
/**
* jinsha请求支付
@ -85,4 +87,22 @@ public class PayController {
return s;
}
/**
* VM请求支付
*/
@PostMapping("/vm-pay")
@ApiOperation("VM请求支付")
public AjaxResult vmPay(@RequestBody VmPayReq req) throws Exception {
PayResVO payResVO = vmService.vmPay(req.getGearId(), req.getVmCardInfo());
return AjaxResult.success(payResVO);
}
@PostMapping("/vm-callBack")
@ApiOperation("VM支付回调")
@Anonymous
public AjaxResult vmCallBack(@RequestBody VmCallBackReq req) {
AjaxResult ajaxResult = vmService.vmCallBack(req);
return ajaxResult;
}
}

View File

@ -244,6 +244,18 @@ yuzhou:
redirectUrl: www.google.com
callbackUrl: www.google.com
# VM支付配置
vm:
url: http://payment-api.togame.top
mchNo: M1768983012
appId: 697089e4f41a4f456f159408
secret: 120tzr4snoq11yus8la9gx7cbutw1uore4pervckvqmsswrt1hl9qkd0ug5r6twwv94jex03ajpsmsky2za4x1kghd2l54z4nn7t5fcy4gewsvwjjxrce5q1f7u2yeqj
notifyUrl: www.google.com
# 支付方式固定为BUZHI_VM国际VM卡支付
wayCode: BUZHI_VM
# 货币代码默认USD
currency: USD
# 汇率服务配置
exchange-rate:
enabled: true

View File

@ -0,0 +1,88 @@
package com.ruoyi.ai.domain;
import lombok.Data;
/**
* VM支付回调请求
*
* @author system
* @date 2025-01-XX
*/
@Data
public class VmCallBackReq {
/**
* 商户号
*/
private String mchNo;
/**
* 应用ID
*/
private String appId;
/**
* 商户订单号
*/
private String mchOrderNo;
/**
* 支付订单号
*/
private String payOrderId;
/**
* 支付方式
*/
private String wayCode;
/**
* 支付金额单位分
*/
private Integer amount;
/**
* 货币代码
*/
private String currency;
/**
* 订单状态0-订单生成, 1-支付中, 2-支付成功, 3-支付失败, 4-已撤销, 5-已退款, 6-订单关闭
*/
private Integer orderState;
/**
* 支付渠道订单号
*/
private String channelOrderNo;
/**
* 渠道错误码
*/
private String errCode;
/**
* 渠道错误描述
*/
private String errMsg;
/**
* 扩展参数回调时会原样返回
*/
private String extParam;
/**
* 回调时间13位时间戳
*/
private Long notifyTime;
/**
* 签名值
*/
private String sign;
/**
* 签名类型
*/
private String signType;
}

View File

@ -0,0 +1,54 @@
package com.ruoyi.ai.domain;
import lombok.Data;
/**
* VM卡信息
* 当wayCode为BUZHI_VM时需要在extParam中传入此信息的JSON字符串
*
* @author system
* @date 2025-01-XX
*/
@Data
public class VmCardInfo {
/**
* 信用卡卡号
*/
private String number;
/**
* CVC
*/
private String cvc;
/**
* 过期年四位数字2027
*/
private String expYear;
/**
* 过期月两位数11
*/
private String expMonth;
/**
* 信用卡持有者电子邮件
*/
private String email;
/**
* 持卡人名字
*/
private String firstName;
/**
* 持卡人姓氏
*/
private String lastName;
/**
* 国家: 双字母 ISO 国家代码两位字母
*/
private String country;
}

View File

@ -0,0 +1,23 @@
package com.ruoyi.ai.domain;
import lombok.Data;
/**
* VM支付请求
*
* @author system
* @date 2025-01-XX
*/
@Data
public class VmPayReq {
/**
* 档位ID
*/
private Long gearId;
/**
* VM卡信息可选如果为null则需要在extParam中传入
*/
private VmCardInfo vmCardInfo;
}

View File

@ -0,0 +1,99 @@
package com.ruoyi.ai.domain;
import lombok.Data;
/**
* VM统一下单请求
*
* @author system
* @date 2025-01-XX
*/
@Data
public class VmUnifiedOrderReq {
/**
* 商户号
*/
private String mchNo;
/**
* 应用ID
*/
private String appId;
/**
* 商户订单号
*/
private String mchOrderNo;
/**
* 支付方式BUZHI_VM国际VM卡支付
*/
private String wayCode;
/**
* 支付金额单位分
*/
private Integer amount;
/**
* 货币代码三位货币代码人民币:cny 美元USD
*/
private String currency;
/**
* 客户端IPV4地址
*/
private String clientIp;
/**
* 商品标题
*/
private String subject;
/**
* 商品描述
*/
private String body;
/**
* 异步通知地址
*/
private String notifyUrl;
/**
* 订单失效时间单位秒默认2小时
*/
private Integer expiredTime;
/**
* 特定渠道发起的额外参数json格式字符串
*/
private String channelExtra;
/**
* 商户扩展参数回调时会原样返回
* 当wayCode为BUZHI_VM时需要传入VM卡信息的JSON字符串
*/
private String extParam;
/**
* 请求接口时间13位时间戳
*/
private Long reqTime;
/**
* 接口版本号固定1.0
*/
private String version;
/**
* 签名值
*/
private String sign;
/**
* 签名类型目前只支持MD5方式
*/
private String signType;
}

View File

@ -0,0 +1,56 @@
package com.ruoyi.ai.domain;
import lombok.Data;
/**
* VM统一下单响应
*
* @author system
* @date 2025-01-XX
*/
@Data
public class VmUnifiedOrderRes {
/**
* 响应码0表示成功
*/
private Integer code;
/**
* 响应消息
*/
private String msg;
/**
* 响应数据
*/
private VmUnifiedOrderData data;
@Data
public static class VmUnifiedOrderData {
/**
* 支付链接
*/
private String payUrl;
/**
* 订单号
*/
private String orderId;
/**
* 商户订单号
*/
private String mchOrderNo;
/**
* 订单状态
*/
private Integer orderState;
/**
* 支付参数某些支付方式可能需要
*/
private String payParams;
}
}

View File

@ -0,0 +1,32 @@
package com.ruoyi.ai.service;
import com.ruoyi.ai.domain.PayResVO;
import com.ruoyi.ai.domain.VmCallBackReq;
import com.ruoyi.common.core.domain.AjaxResult;
/**
* VM支付服务接口
*
* @author system
* @date 2025-01-XX
*/
public interface IVmService {
/**
* VM支付
*
* @param gearId 档位ID
* @param vmCardInfo VM卡信息可选如果为null则需要在extParam中传入
* @return 支付结果
* @throws Exception 支付异常
*/
PayResVO vmPay(Long gearId, com.ruoyi.ai.domain.VmCardInfo vmCardInfo) throws Exception;
/**
* VM支付回调
*
* @param req 回调请求
* @return 回调处理结果
*/
AjaxResult vmCallBack(VmCallBackReq req);
}

View File

@ -0,0 +1,368 @@
package com.ruoyi.ai.service.impl;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ai.domain.*;
import com.ruoyi.ai.service.*;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.AiUser;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.http.OkHttpUtils;
import com.ruoyi.common.utils.sign.Md5Utils;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;
/**
* VM支付服务实现
*
* @author system
* @date 2025-01-XX
*/
@Service
@Slf4j
public class VmService implements IVmService {
private final ObjectMapper objectMapper = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL);
@Value("${vm.url:http://payment-api.togame.top}")
private String url;
@Value("${vm.mchNo}")
private String mchNo;
@Value("${vm.appId}")
private String appId;
@Value("${vm.secret}")
private String secret;
@Value("${vm.notifyUrl}")
private String notifyUrl;
@Value("${vm.wayCode:BUZHI_VM}")
private String wayCode;
@Value("${vm.currency:USD}")
private String currency;
@Autowired
private IAiRechargeService aiRechargeService;
@Autowired
private IAiUserService aiUserService;
@Autowired
private IAiRechargeGiftGearService aiRechargeGiftGearService;
@Autowired
private IAiRechargeGiftService aiRechargeGiftService;
@Autowired
private IExchangeRateService exchangeRateService;
@Override
public PayResVO vmPay(Long gearId, VmCardInfo vmCardInfo) throws Exception {
// 充值挡位查询
AiRechargeGiftGear aiRechargeGiftGear = aiRechargeGiftGearService.selectAiRechargeGiftGearById(gearId);
if (aiRechargeGiftGear == null) {
throw new ServiceException("The gear position does not exist.", -1);
}
BigDecimal amount = aiRechargeGiftGear.getRechargeAmount();
// 使用汇率服务转换金额从USD转换为目标货币默认USD
// 注意VM支付使用USD作为基础货币
if (!"USD".equalsIgnoreCase(currency)) {
amount = exchangeRateService.convertAmount(amount, "USD", currency);
}
// 生成订单号
String uuid = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
String dateTime = new SimpleDateFormat("yyyyMMdd").format(new Date());
String orderNo = dateTime + "vm" + uuid;
// 金额转换为分整数
int amountInCents = amount.multiply(new BigDecimal(100)).intValue();
// 构建extParamVM卡信息
String extParam = null;
if (vmCardInfo != null) {
extParam = objectMapper.writeValueAsString(vmCardInfo);
}
// 构建统一下单请求
VmUnifiedOrderReq req = new VmUnifiedOrderReq();
req.setMchNo(mchNo);
req.setAppId(appId);
req.setMchOrderNo(orderNo);
req.setWayCode(wayCode);
req.setAmount(amountInCents);
req.setCurrency(currency.toLowerCase());
req.setSubject("游戏充值");
req.setBody("游戏充值");
req.setNotifyUrl(notifyUrl + "/api/pay/vm-callBack");
req.setExpiredTime(7200); // 默认2小时
req.setExtParam(extParam);
req.setReqTime(System.currentTimeMillis());
req.setVersion("1.0");
req.setSignType("MD5");
// 生成签名
String sign = generateSign(req);
req.setSign(sign);
// 构建请求体JSON
String jsonBody = objectMapper.writeValueAsString(req);
log.info("VM支付请求参数: {}", jsonBody);
// 构建POST请求
Request request = new Request.Builder()
.url(url + "/api/pay/unifiedOrder")
.header("Content-Type", "application/json")
.post(RequestBody.create(MediaType.parse("application/json"), jsonBody))
.build();
// 发送请求
Response response = OkHttpUtils.newCall(request).execute();
ResponseBody body = response.body();
if (body == null) {
log.error("VM支付请求的响应异常");
throw new Exception("vm pay response body is null");
}
String responseBody = body.string();
log.info("VM支付请求响应: {}", responseBody);
VmUnifiedOrderRes res = objectMapper.readValue(responseBody, VmUnifiedOrderRes.class);
// 检查响应code0表示成功
if (res.getCode() == null || res.getCode() != 0) {
String msg = res.getMsg();
log.error("VM支付请求失败code: {}, msg: {}", res.getCode(), msg);
throw new ServiceException(msg != null ? msg : "支付请求失败", res.getCode() != null ? res.getCode() : -1);
}
// 检查返回的payUrl
String payUrl = null;
if (res.getData() != null) {
payUrl = res.getData().getPayUrl();
}
if (payUrl == null || payUrl.trim().isEmpty()) {
log.error("VM支付返回的payUrl为空订单号: {}", orderNo);
throw new ServiceException("支付返回的支付链接为空", -1);
}
// 创建充值管理
AiUser userInfo = aiUserService.getUserInfo(SecurityUtils.getAiUserId());
AiRecharge aiRecharge = new AiRecharge();
aiRecharge.setOrderNum(orderNo);
aiRecharge.setUserId(SecurityUtils.getAiUserId());
aiRecharge.setAmount(amount);
aiRecharge.setGearId(gearId);
aiRecharge.setGearAmount(aiRechargeGiftGear.getRechargeAmount());
aiRecharge.setSource(userInfo.getSource());
AiRechargeGift aiRechargeGift = aiRechargeGiftService.selectAiRechargeGiftById(aiRechargeGiftGear.getRechargeId());
aiRecharge.setPayType(aiRechargeGift.getPayType());
aiRecharge.setPayUrl(payUrl);
aiRechargeService.insertAiRecharge(aiRecharge);
PayResVO payResVO = new PayResVO();
payResVO.setOrderNo(orderNo);
payResVO.setPayUrl(payUrl);
return payResVO;
}
@Override
@Transactional
public AjaxResult vmCallBack(VmCallBackReq req) {
log.info("VM支付回调订单号: {}, 状态: {}", req.getMchOrderNo(), req.getOrderState());
// 验证签名
String sign = generateCallBackSign(req);
if (!sign.equals(req.getSign())) {
log.error("VM支付回调签名错误订单号: {}, 期望签名: {}, 实际签名: {}",
req.getMchOrderNo(), sign, req.getSign());
return AjaxResult.error("签名验证失败");
}
// 处理订单状态
// 订单状态0-订单生成, 1-支付中, 2-支付成功, 3-支付失败, 4-已撤销, 5-已退款, 6-订单关闭
Integer orderState = req.getOrderState();
if (orderState == null) {
log.error("VM支付回调状态为空订单号: {}", req.getMchOrderNo());
return AjaxResult.error("状态为空");
}
if (orderState == 2) {
// 支付成功处理
log.info("VM支付成功订单号: {}", req.getMchOrderNo());
aiRechargeService.addRecharge(req.getMchOrderNo());
return AjaxResult.success();
} else if (orderState == 3) {
// 支付失败
log.warn("VM支付失败订单号: {}", req.getMchOrderNo());
// 这里可以添加失败处理逻辑比如更新订单状态为失败
return AjaxResult.success();
} else {
// 其他状态处理中已撤销已退款订单关闭等
log.info("VM支付状态: {}, 订单号: {}", orderState, req.getMchOrderNo());
return AjaxResult.success();
}
}
/**
* 生成签名统一下单
* 第一步将集合M内非空参数值的参数按照参数名ASCII码从小到大排序字典序使用URL键值对的格式拼接成字符串stringA
* 第二步在stringA最后拼接上key[ StringA +"&key=" + 私钥 ] 得到stringSignTemp字符串并对stringSignTemp进行MD5运算再将得到的字符串所有字符转换为大写
*/
private String generateSign(VmUnifiedOrderReq req) {
TreeMap<String, Object> params = new TreeMap<>();
// 添加所有非空参数sign不参与签名
if (req.getMchNo() != null && !req.getMchNo().isEmpty()) {
params.put("mchNo", req.getMchNo());
}
if (req.getAppId() != null && !req.getAppId().isEmpty()) {
params.put("appId", req.getAppId());
}
if (req.getMchOrderNo() != null && !req.getMchOrderNo().isEmpty()) {
params.put("mchOrderNo", req.getMchOrderNo());
}
if (req.getWayCode() != null && !req.getWayCode().isEmpty()) {
params.put("wayCode", req.getWayCode());
}
if (req.getAmount() != null) {
params.put("amount", req.getAmount());
}
if (req.getCurrency() != null && !req.getCurrency().isEmpty()) {
params.put("currency", req.getCurrency());
}
if (req.getClientIp() != null && !req.getClientIp().isEmpty()) {
params.put("clientIp", req.getClientIp());
}
if (req.getSubject() != null && !req.getSubject().isEmpty()) {
params.put("subject", req.getSubject());
}
if (req.getBody() != null && !req.getBody().isEmpty()) {
params.put("body", req.getBody());
}
if (req.getNotifyUrl() != null && !req.getNotifyUrl().isEmpty()) {
params.put("notifyUrl", req.getNotifyUrl());
}
if (req.getExpiredTime() != null) {
params.put("expiredTime", req.getExpiredTime());
}
if (req.getChannelExtra() != null && !req.getChannelExtra().isEmpty()) {
params.put("channelExtra", req.getChannelExtra());
}
if (req.getExtParam() != null && !req.getExtParam().isEmpty()) {
params.put("extParam", req.getExtParam());
}
if (req.getReqTime() != null) {
params.put("reqTime", req.getReqTime());
}
if (req.getVersion() != null && !req.getVersion().isEmpty()) {
params.put("version", req.getVersion());
}
if (req.getSignType() != null && !req.getSignType().isEmpty()) {
params.put("signType", req.getSignType());
}
// 拼接参数
StringBuilder stringA = new StringBuilder();
for (Map.Entry<String, Object> entry : params.entrySet()) {
if (stringA.length() > 0) {
stringA.append("&");
}
stringA.append(entry.getKey()).append("=").append(entry.getValue());
}
// 拼接key
String stringSignTemp = stringA.toString() + "&key=" + secret;
log.debug("VM签名源字符串: {}", stringSignTemp);
// MD5并转大写
String sign = Md5Utils.hash(stringSignTemp).toUpperCase();
return sign;
}
/**
* 生成回调签名
*/
private String generateCallBackSign(VmCallBackReq req) {
TreeMap<String, Object> params = new TreeMap<>();
// 添加所有非空参数sign不参与签名
if (req.getMchNo() != null && !req.getMchNo().isEmpty()) {
params.put("mchNo", req.getMchNo());
}
if (req.getAppId() != null && !req.getAppId().isEmpty()) {
params.put("appId", req.getAppId());
}
if (req.getMchOrderNo() != null && !req.getMchOrderNo().isEmpty()) {
params.put("mchOrderNo", req.getMchOrderNo());
}
if (req.getPayOrderId() != null && !req.getPayOrderId().isEmpty()) {
params.put("payOrderId", req.getPayOrderId());
}
if (req.getWayCode() != null && !req.getWayCode().isEmpty()) {
params.put("wayCode", req.getWayCode());
}
if (req.getAmount() != null) {
params.put("amount", req.getAmount());
}
if (req.getCurrency() != null && !req.getCurrency().isEmpty()) {
params.put("currency", req.getCurrency());
}
if (req.getOrderState() != null) {
params.put("orderState", req.getOrderState());
}
if (req.getChannelOrderNo() != null && !req.getChannelOrderNo().isEmpty()) {
params.put("channelOrderNo", req.getChannelOrderNo());
}
if (req.getErrCode() != null && !req.getErrCode().isEmpty()) {
params.put("errCode", req.getErrCode());
}
if (req.getErrMsg() != null && !req.getErrMsg().isEmpty()) {
params.put("errMsg", req.getErrMsg());
}
if (req.getExtParam() != null && !req.getExtParam().isEmpty()) {
params.put("extParam", req.getExtParam());
}
if (req.getNotifyTime() != null) {
params.put("notifyTime", req.getNotifyTime());
}
if (req.getSignType() != null && !req.getSignType().isEmpty()) {
params.put("signType", req.getSignType());
}
// 拼接参数
StringBuilder stringA = new StringBuilder();
for (Map.Entry<String, Object> entry : params.entrySet()) {
if (stringA.length() > 0) {
stringA.append("&");
}
stringA.append(entry.getKey()).append("=").append(entry.getValue());
}
// 拼接key
String stringSignTemp = stringA.toString() + "&key=" + secret;
log.debug("VM回调签名源字符串: {}", stringSignTemp);
// MD5并转大写
String sign = Md5Utils.hash(stringSignTemp).toUpperCase();
return sign;
}
}