feat(payment): 初始化支付模块基础功能

- 添加订单号生成工具类 OrderIdGenerator
- 定义订单状态枚举 OrderStatus
- 实现OSS文件上传服务接口及阿里云OSS实现
- 添加支付常量类 PaymentConstants
- 创建支付控制器 PaymentController 支持下单、查单和收银台页面
- 新增支付记录实体类 PaymentRecord 用于存储回调和查询记录
This commit is contained in:
2025-12-19 18:13:20 +08:00
parent c338571dc1
commit a544eb6d0e
8 changed files with 597 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
package com.mtkj.mtpay.common.constants;
/**
* 支付相关常量
*/
public class PaymentConstants {
/**
* PingPong成功响应码
*/
public static final String PINGPONG_SUCCESS_CODE = "001000";
/**
* 默认订单状态
*/
public static final String DEFAULT_ORDER_STATUS = "PENDING";
/**
* 签名类型 - MD5
*/
public static final String SIGN_TYPE_MD5 = "MD5";
/**
* 签名类型 - SHA256
*/
public static final String SIGN_TYPE_SHA256 = "SHA256";
/**
* 默认签名类型
*/
public static final String DEFAULT_SIGN_TYPE = SIGN_TYPE_MD5;
/**
* 3DS验证 - 否
*/
public static final String THREE_D_SECURE_NO = "N";
/**
* 3DS验证 - 是
*/
public static final String THREE_D_SECURE_YES = "Y";
/**
* 虚拟商品 - 否
*/
public static final String VIRTUAL_PRODUCT_NO = "N";
/**
* 虚拟商品 - 是
*/
public static final String VIRTUAL_PRODUCT_YES = "Y";
/**
* 订单号前缀
*/
public static final String ORDER_ID_PREFIX = "MTN";
/**
* 私有构造函数,防止实例化
*/
private PaymentConstants() {
throw new UnsupportedOperationException("常量类不能被实例化");
}
}

View File

@@ -0,0 +1,60 @@
package com.mtkj.mtpay.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 订单状态枚举
*/
@Getter
@AllArgsConstructor
public enum OrderStatus {
/**
* 待支付
*/
PENDING("PENDING", "待支付"),
/**
* 支付成功
*/
SUCCESS("SUCCESS", "支付成功"),
/**
* 支付失败
*/
FAILED("FAILED", "支付失败"),
/**
* 审核中
*/
REVIEW("REVIEW", "审核中"),
/**
* 已取消
*/
CANCELLED("CANCELLED", "已取消");
/**
* 状态码
*/
private final String code;
/**
* 状态描述
*/
private final String description;
/**
* 根据状态码获取枚举
*/
public static OrderStatus getByCode(String code) {
for (OrderStatus status : values()) {
if (status.getCode().equalsIgnoreCase(code)) {
return status;
}
}
return null;
}
}

View File

@@ -0,0 +1,176 @@
package com.mtkj.mtpay.controller;
import com.mtkj.mtpay.common.Result;
import com.mtkj.mtpay.common.ResultCode;
import com.mtkj.mtpay.dto.request.CheckoutRequestDTO;
import com.mtkj.mtpay.entity.PaymentOrder;
import com.mtkj.mtpay.service.PaymentOrderService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 支付控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/payment")
@RequiredArgsConstructor
public class PaymentController {
private final PaymentOrderService paymentOrderService;
/**
* 创建支付订单
*/
@PostMapping("/checkout")
public ResponseEntity<Result<Map<String, Object>>> checkout(@Valid @RequestBody CheckoutRequestDTO request) {
log.info("收到创建支付订单请求,商户订单号: {}", request.getMerchantTransactionId());
PaymentOrder order = paymentOrderService.createPaymentOrder(request);
Map<String, Object> data = new HashMap<>();
data.put("merchantTransactionId", order.getMerchantTransactionId());
data.put("token", order.getToken());
data.put("paymentUrl", order.getPaymentUrl());
data.put("status", order.getStatus());
return ResponseEntity.ok(Result.success("订单创建成功", data));
}
/**
* 查询订单状态
*/
@GetMapping("/order/{merchantTransactionId}")
public ResponseEntity<Result<Map<String, Object>>> getOrder(@PathVariable String merchantTransactionId) {
log.info("查询订单状态,商户订单号: {}", merchantTransactionId);
Optional<PaymentOrder> orderOpt = paymentOrderService.findByMerchantTransactionId(merchantTransactionId);
if (orderOpt.isPresent()) {
PaymentOrder order = orderOpt.get();
Map<String, Object> data = new HashMap<>();
data.put("merchantTransactionId", order.getMerchantTransactionId());
data.put("transactionId", order.getTransactionId() != null ? order.getTransactionId() : "");
data.put("status", order.getStatus());
data.put("amount", order.getAmount());
data.put("currency", order.getCurrency());
data.put("createTime", order.getCreateTime());
data.put("token", order.getToken());
data.put("paymentUrl", order.getPaymentUrl());
return ResponseEntity.ok(Result.success(data));
} else {
return ResponseEntity.ok(Result.fail(ResultCode.ORDER_NOT_FOUND));
}
}
/**
* 获取收银台页面HTML
* 返回包含SDK初始化的HTML页面
*/
@GetMapping("/checkout/page")
public ResponseEntity<String> getCheckoutPage(@RequestParam String token) {
log.info("获取收银台页面token: {}", token);
String html = generateCheckoutPage(token);
return ResponseEntity.ok()
.header("Content-Type", "text/html;charset=UTF-8")
.body(html);
}
/**
* 生成收银台页面HTML
*/
private String generateCheckoutPage(String token) {
return """
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>PingPong支付收银台</title>
<style>
#ufo-container {
width: 100%;
min-height: 100vh;
margin: 0 auto;
padding: 20px;
box-sizing: border-box;
}
</style>
</head>
<body>
<div id="ufo-container"></div>
<script src="https://pay-cdn.pingpongx.com/production/static/sdk/1.2.0/ppPay.min.js"></script>
<script>
window.onload = function() {
let client = new ppPay({
lang: 'zh',
root: '#ufo-container',
manul: false,
located: true,
showPrice: true,
bill: true,
mode: 'sandbox',
menu: false,
base: {
width: '100%',
height: '100%',
fontSize: '14px',
backgroundColor: '#fff',
showHeader: true,
showHeaderLabel: true,
headerLabelFont: "支付",
headerColor: '#333333',
headerSize: '16px',
headerBackgroundColor: '#fff',
headerPadding: '20px',
btnSize: '100%',
btnColor: '#fff',
btnFontSize: '14px',
btnPaddingX: '20px',
btnPaddingY: '10px',
btnBackgroundColor: '#1fa0e8',
btnBorderRadius: '4px',
btnMarginTop: '20px'
}
});
let sdkConfig = {
token: '%s'
};
client.createPayment(sdkConfig);
function setPPPayPropWin() {
let winWidth = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
let winHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
let ufoContainer = document.getElementById('ufo-container');
if (winWidth >= 500) {
let clientW = Math.floor(winWidth / 3);
clientW = clientW >= 500 ? clientW : 500;
ufoContainer.style.width = clientW + 'px';
} else {
ufoContainer.style.width = winWidth + 'px';
}
}
setPPPayPropWin();
window.addEventListener('resize', setPPPayPropWin);
}
</script>
</body>
</html>
""".formatted(token);
}
}

View File

@@ -0,0 +1,75 @@
package com.mtkj.mtpay.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 支付记录实体(用于记录回调、查询等操作)
*/
@TableName(value = "payment_record")
@Data
public class PaymentRecord {
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* PingPong交易流水号
*/
@TableField(value = "transaction_id", jdbcType = org.apache.ibatis.type.JdbcType.VARCHAR)
private String transactionId;
/**
* 商户订单号
*/
@TableField(value = "merchant_transaction_id", jdbcType = org.apache.ibatis.type.JdbcType.VARCHAR)
private String merchantTransactionId;
/**
* 记录类型CALLBACK-回调QUERY-查询REFUND-退款CAPTURE-预授权完成VOID-预授权撤销
*/
@TableField(value = "record_type", jdbcType = org.apache.ibatis.type.JdbcType.VARCHAR)
private String recordType;
/**
* 交易状态
*/
@TableField(value = "status", jdbcType = org.apache.ibatis.type.JdbcType.VARCHAR)
private String status;
/**
* 响应码
*/
@TableField(value = "code", jdbcType = org.apache.ibatis.type.JdbcType.VARCHAR)
private String code;
/**
* 响应描述
*/
@TableField(value = "description", jdbcType = org.apache.ibatis.type.JdbcType.VARCHAR)
private String description;
/**
* 原始请求数据JSON格式
*/
@TableField(value = "request_data", jdbcType = org.apache.ibatis.type.JdbcType.LONGVARCHAR)
private String requestData;
/**
* 原始响应数据JSON格式
*/
@TableField(value = "response_data", jdbcType = org.apache.ibatis.type.JdbcType.LONGVARCHAR)
private String responseData;
/**
* 创建时间
*/
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,42 @@
package com.mtkj.mtpay.service;
import java.io.InputStream;
/**
* OSS服务接口
*/
public interface OssService {
/**
* 上传文件
* @param content 文件内容(字节数组)
* @param originalFilename 原始文件名
* @return 文件访问URL
* @throws Exception 上传异常
*/
String upload(byte[] content, String originalFilename) throws Exception;
/**
* 上传文件(指定对象名)
* @param content 文件内容(字节数组)
* @param objectName OSS对象名
* @return 文件访问URL
* @throws Exception 上传异常
*/
String uploadToObject(byte[] content, String objectName) throws Exception;
/**
* 删除文件
* @param objectName OSS对象名
* @throws Exception 删除异常
*/
void delete(String objectName) throws Exception;
/**
* 构建OSS访问URL
* @param objectName OSS对象名
* @return 文件访问URL
*/
String buildUrl(String objectName);
}

View File

@@ -0,0 +1,136 @@
package com.mtkj.mtpay.service.impl;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.common.auth.CredentialsProvider;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.exception.OSSException;
import com.mtkj.mtpay.config.AliyunOSSProperties;
import com.mtkj.mtpay.service.OssService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
* OSS服务实现类
*/
@Slf4j
@Service
public class OssServiceImpl implements OssService {
@Autowired
private AliyunOSSProperties ossProperties;
@Override
public String upload(byte[] content, String originalFilename) throws Exception {
String endpoint = ossProperties.getEndpoint();
String bucketName = ossProperties.getBucketName();
// 创建凭证提供者
CredentialsProvider credentialsProvider = CredentialsProviderFactory
.newDefaultCredentialProvider(ossProperties.getAccessId(), ossProperties.getAccessKey());
// 生成文件路径yyyy/MM/dd/uuid.扩展名参考mt-oss实现
String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String extension = "";
if (originalFilename != null && originalFilename.contains(".")) {
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
}
String newFileName = UUID.randomUUID().toString() + extension;
String objectName = dir + "/" + newFileName;
// 创建OSS客户端
OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
try {
// 上传文件
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content));
log.info("文件上传成功objectName: {}", objectName);
// 构建URL参考mt-oss实现方式
return buildUrl(objectName);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
@Override
public String uploadToObject(byte[] content, String objectName) throws Exception {
String endpoint = ossProperties.getEndpoint();
String bucketName = ossProperties.getBucketName();
CredentialsProvider credentialsProvider = CredentialsProviderFactory
.newDefaultCredentialProvider(ossProperties.getAccessId(), ossProperties.getAccessKey());
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
ByteArrayInputStream inputStream = new ByteArrayInputStream(content);
ossClient.putObject(bucketName, objectName, inputStream);
log.info("文件上传成功objectName: {}", objectName);
return buildUrl(objectName);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
@Override
public void delete(String objectName) throws Exception {
String endpoint = ossProperties.getEndpoint();
String bucketName = ossProperties.getBucketName();
CredentialsProvider credentialsProvider = CredentialsProviderFactory
.newDefaultCredentialProvider(ossProperties.getAccessId(), ossProperties.getAccessKey());
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
ossClient.deleteObject(bucketName, objectName);
log.info("文件删除成功objectName: {}", objectName);
} catch (OSSException e) {
log.error("删除OSS文件失败objectName: {}", objectName, e);
throw e;
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
@Override
public String buildUrl(String objectName) {
String endpoint = ossProperties.getEndpoint();
String bucketName = ossProperties.getBucketName();
// 参考mt-oss实现使用字符串拼接方式构建URL
// 格式https://bucketName.endpoint/objectName
String[] endpointParts = endpoint.split("//");
if (endpointParts.length >= 2) {
String protocol = endpointParts[0]; // http: 或 https:
String host = endpointParts[1]; // oss-cn-hangzhou.aliyuncs.com
return protocol + "//" + bucketName + "." + host + "/" + objectName;
} else {
// 如果endpoint格式不符合预期使用URI方式构建
String host = endpoint.replaceFirst("^https?://", "");
try {
URI uri = new URI("https", bucketName + "." + host, "/" + objectName, null);
return uri.toASCIIString();
} catch (URISyntaxException e) {
log.error("构建OSS访问地址失败objectName: {}", objectName, e);
throw new RuntimeException("构建OSS访问地址失败", e);
}
}
}
}

View File

@@ -0,0 +1,37 @@
package com.mtkj.mtpay.util;
import com.mtkj.mtpay.common.constants.PaymentConstants;
import lombok.extern.slf4j.Slf4j;
import java.util.UUID;
/**
* 订单号生成器
*/
@Slf4j
public class OrderIdGenerator {
/**
* 生成商户订单号
* 格式MTN + 时间戳 + 随机数
*/
public static String generateMerchantTransactionId() {
long timestamp = System.currentTimeMillis();
int random = (int) (Math.random() * 10000);
String orderId = String.format("%s%d%04d", PaymentConstants.ORDER_ID_PREFIX, timestamp, random);
log.debug("生成商户订单号: {}", orderId);
return orderId;
}
/**
* 生成商户订单号带UUID后缀
*/
public static String generateMerchantTransactionIdWithUuid() {
long timestamp = System.currentTimeMillis();
String uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
String orderId = String.format("%s%d%s", PaymentConstants.ORDER_ID_PREFIX, timestamp, uuid);
log.debug("生成商户订单号UUID: {}", orderId);
return orderId;
}
}

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "MTKJPAY",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}