diff --git a/ruoyi-admin/src/main/java/com/ruoyi/api/PayController.java b/ruoyi-admin/src/main/java/com/ruoyi/api/PayController.java index 871c77a..afeacf0 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/api/PayController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/PayController.java @@ -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; + } + } diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 7a72af4..d076789 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -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 diff --git a/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmCallBackReq.java b/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmCallBackReq.java new file mode 100644 index 0000000..dfd1215 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmCallBackReq.java @@ -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; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmCardInfo.java b/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmCardInfo.java new file mode 100644 index 0000000..82eb751 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmCardInfo.java @@ -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; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmPayReq.java b/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmPayReq.java new file mode 100644 index 0000000..98b482e --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmPayReq.java @@ -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; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmUnifiedOrderReq.java b/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmUnifiedOrderReq.java new file mode 100644 index 0000000..577df4c --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmUnifiedOrderReq.java @@ -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; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmUnifiedOrderRes.java b/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmUnifiedOrderRes.java new file mode 100644 index 0000000..0bdeafd --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/ai/domain/VmUnifiedOrderRes.java @@ -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; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/ai/service/IVmService.java b/ruoyi-system/src/main/java/com/ruoyi/ai/service/IVmService.java new file mode 100644 index 0000000..ad4f42c --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/ai/service/IVmService.java @@ -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); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/VmService.java b/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/VmService.java new file mode 100644 index 0000000..cf1f3e7 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/VmService.java @@ -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(); + + // 构建extParam(VM卡信息) + 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); + + // 检查响应code,0表示成功 + 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 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 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 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 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; + } +}