feat(pay): 添加PingPong支付回调处理功能
- 新增CallbackController接收并处理PingPong支付回调通知 - 实现回调签名验证逻辑 - 添加支付结果页面展示功能,支持成功、失败、审核中等状态显示 - 创建CallbackService接口及实现类处理回调业务逻辑 - 新增账单地址DTO(BillingDTO)用于风险信息传输 - 添加航空信息DTO(AirlineDTO)和租车信息DTO(CarRentalDTO)作为扩展风险数据结构 - 完善Checkout请求DTO字段校验规则,增强数据安全性 - 实现订单状态映射与更新机制,确保支付状态同步准确 - 记录回调处理日志便于问题追踪与审计
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
package com.mtkj.mtpay.controller;
|
||||
|
||||
import com.mtkj.mtpay.service.CallbackService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 回调控制器
|
||||
* 接收PingPong支付回调通知
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/callback")
|
||||
@RequiredArgsConstructor
|
||||
public class CallbackController {
|
||||
|
||||
private final CallbackService callbackService;
|
||||
|
||||
/**
|
||||
* PingPong支付回调接口
|
||||
*/
|
||||
@PostMapping("/pingpong")
|
||||
public ResponseEntity<Map<String, String>> pingpongCallback(@RequestBody Map<String, Object> callbackData) {
|
||||
log.info("收到PingPong回调通知");
|
||||
|
||||
try {
|
||||
boolean success = callbackService.handleCallback(callbackData);
|
||||
|
||||
if (success) {
|
||||
return ResponseEntity.ok(Map.of("code", "0000", "message", "success"));
|
||||
} else {
|
||||
return ResponseEntity.badRequest().body(Map.of("code", "9999", "message", "处理失败"));
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("处理回调失败", e);
|
||||
return ResponseEntity.badRequest().body(Map.of("code", "9999", "message", "处理异常: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付结果重定向页面(用户支付完成后跳转)
|
||||
*/
|
||||
@GetMapping("/result")
|
||||
public ResponseEntity<String> paymentResult(
|
||||
@RequestParam(required = false) String merchantTransactionId,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String message
|
||||
) {
|
||||
log.info("支付结果页面,商户订单号: {}, 状态: {}", merchantTransactionId, status);
|
||||
|
||||
String html = """
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>支付结果</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.result-container {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
}
|
||||
.success { color: #52c41a; }
|
||||
.failed { color: #ff4d4f; }
|
||||
.pending { color: #faad14; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="result-container">
|
||||
<h1 class="%s">支付%s</h1>
|
||||
<p>订单号: %s</p>
|
||||
<p>状态: %s</p>
|
||||
%s
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
|
||||
String statusClass = "success";
|
||||
String statusText = "成功";
|
||||
String messageHtml = "";
|
||||
|
||||
if (status != null) {
|
||||
switch (status.toUpperCase()) {
|
||||
case "SUCCESS", "SUCCESSFUL" -> {
|
||||
statusClass = "success";
|
||||
statusText = "成功";
|
||||
}
|
||||
case "FAILED", "FAILURE" -> {
|
||||
statusClass = "failed";
|
||||
statusText = "失败";
|
||||
}
|
||||
case "REVIEW" -> {
|
||||
statusClass = "pending";
|
||||
statusText = "审核中";
|
||||
}
|
||||
default -> {
|
||||
statusClass = "pending";
|
||||
statusText = "处理中";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message != null && !message.isEmpty()) {
|
||||
messageHtml = "<p>说明: " + message + "</p>";
|
||||
}
|
||||
|
||||
String finalHtml = String.format(html, statusClass, statusText,
|
||||
merchantTransactionId != null ? merchantTransactionId : "未知",
|
||||
status != null ? status : "未知",
|
||||
messageHtml);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Type", "text/html;charset=UTF-8")
|
||||
.body(finalHtml);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.mtkj.mtpay.dto.request;
|
||||
|
||||
import com.mtkj.mtpay.dto.RiskInfoDTO;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Checkout接口请求DTO
|
||||
*/
|
||||
@Data
|
||||
public class CheckoutRequestDTO implements Serializable {
|
||||
|
||||
@NotBlank(message = "商户店铺编号不能为空")
|
||||
@Size(max = 64, message = "商户店铺编号长度不能超过64")
|
||||
private String accId;
|
||||
|
||||
@NotBlank(message = "交易金额不能为空")
|
||||
@Pattern(regexp = "^\\d+(\\.\\d{2})?$", message = "交易金额格式不正确,需保留两位小数")
|
||||
@Size(max = 12, message = "交易金额长度不能超过12")
|
||||
private String amount;
|
||||
|
||||
@NotBlank(message = "交易币种不能为空")
|
||||
@Size(min = 3, max = 3, message = "交易币种必须为3位ISO 4217币种代码")
|
||||
private String currency;
|
||||
|
||||
@NotBlank(message = "商户订单号不能为空")
|
||||
@Size(max = 64, message = "商户订单号长度不能超过64")
|
||||
private String merchantTransactionId;
|
||||
|
||||
@NotBlank(message = "交易类型不能为空")
|
||||
@Pattern(regexp = "^(SALE|AUTH)$", message = "交易类型必须为SALE或AUTH")
|
||||
private String paymentType;
|
||||
|
||||
@Size(max = 64, message = "支付方式长度不能超过64")
|
||||
private String paymentBrand;
|
||||
|
||||
@NotBlank(message = "结果重定向URL不能为空")
|
||||
@Size(max = 255, message = "结果重定向URL长度不能超过255")
|
||||
private String shopperResultUrl;
|
||||
|
||||
@NotBlank(message = "取消重定向URL不能为空")
|
||||
@Size(max = 255, message = "取消重定向URL长度不能超过255")
|
||||
private String shopperCancelUrl;
|
||||
|
||||
@NotBlank(message = "签名类型不能为空")
|
||||
@Pattern(regexp = "^(MD5|SHA256)$", message = "签名类型必须为MD5或SHA256")
|
||||
private String signType;
|
||||
|
||||
@NotBlank(message = "签名不能为空")
|
||||
@Size(max = 255, message = "签名长度不能超过255")
|
||||
private String sign;
|
||||
|
||||
@Size(max = 64, message = "商户用户ID长度不能超过64")
|
||||
private String merchantUserId;
|
||||
|
||||
@Size(min = 2, max = 4, message = "语言代码长度必须在2-4之间")
|
||||
private String language = "en";
|
||||
|
||||
@Pattern(regexp = "^(Y|N)$", message = "3DS参数必须为Y或N")
|
||||
private String threeDSecure = "N";
|
||||
|
||||
@Size(max = 64, message = "原始商户交易订单号长度不能超过64")
|
||||
private String primaryMerchantTransactionId;
|
||||
|
||||
private Integer periodsNum;
|
||||
|
||||
@NotNull(message = "风控信息不能为空")
|
||||
@Valid
|
||||
private RiskInfoDTO riskInfo;
|
||||
|
||||
@Size(max = 255, message = "异步通知地址长度不能超过255")
|
||||
private String notificationUrl;
|
||||
|
||||
@Size(max = 500, message = "备注长度不能超过500")
|
||||
private String remark;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.mtkj.mtpay.dto.response;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Checkout接口响应DTO
|
||||
*/
|
||||
@Data
|
||||
public class CheckoutResponseDTO implements Serializable {
|
||||
|
||||
private String clientId;
|
||||
|
||||
private String accId;
|
||||
|
||||
private String merchantTransactionId;
|
||||
|
||||
private String code;
|
||||
|
||||
private String description;
|
||||
|
||||
private String token;
|
||||
|
||||
private String paymentUrl;
|
||||
|
||||
private String innerJsUrl;
|
||||
|
||||
private String signType;
|
||||
|
||||
private String sign;
|
||||
|
||||
private String remark;
|
||||
|
||||
private String paymentHtml;
|
||||
}
|
||||
|
||||
15
mt-pay/src/main/java/com/mtkj/mtpay/dto/risk/AirlineDTO.java
Normal file
15
mt-pay/src/main/java/com/mtkj/mtpay/dto/risk/AirlineDTO.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.mtkj.mtpay.dto.risk;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 航空信息DTO
|
||||
*/
|
||||
@Data
|
||||
public class AirlineDTO implements Serializable {
|
||||
|
||||
// 根据实际需求添加字段
|
||||
}
|
||||
|
||||
31
mt-pay/src/main/java/com/mtkj/mtpay/dto/risk/BillingDTO.java
Normal file
31
mt-pay/src/main/java/com/mtkj/mtpay/dto/risk/BillingDTO.java
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.mtkj.mtpay.dto.risk;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 账单地址DTO
|
||||
*/
|
||||
@Data
|
||||
public class BillingDTO implements Serializable {
|
||||
|
||||
private String firstName;
|
||||
|
||||
private String lastName;
|
||||
|
||||
private String phone;
|
||||
|
||||
private String email;
|
||||
|
||||
private String street;
|
||||
|
||||
private String postcode;
|
||||
|
||||
private String city;
|
||||
|
||||
private String state;
|
||||
|
||||
private String country;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.mtkj.mtpay.dto.risk;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 租车信息DTO
|
||||
*/
|
||||
@Data
|
||||
public class CarRentalDTO implements Serializable {
|
||||
|
||||
// 根据实际需求添加字段
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.mtkj.mtpay.service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 回调处理服务接口
|
||||
* 处理PingPong支付回调通知
|
||||
*/
|
||||
public interface CallbackService {
|
||||
|
||||
/**
|
||||
* 处理支付回调
|
||||
*
|
||||
* @param callbackData 回调数据
|
||||
* @return 处理结果
|
||||
*/
|
||||
boolean handleCallback(Map<String, Object> callbackData);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.mtkj.mtpay.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mtkj.mtpay.entity.PaymentOrder;
|
||||
import com.mtkj.mtpay.entity.PaymentRecord;
|
||||
import com.mtkj.mtpay.mapper.PaymentOrderMapper;
|
||||
import com.mtkj.mtpay.mapper.PaymentRecordMapper;
|
||||
import com.mtkj.mtpay.service.CallbackService;
|
||||
import com.mtkj.mtpay.service.SignatureService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 回调处理服务实现类
|
||||
* 处理PingPong支付回调通知
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CallbackServiceImpl implements CallbackService {
|
||||
|
||||
private final PaymentOrderMapper paymentOrderMapper;
|
||||
private final PaymentRecordMapper paymentRecordMapper;
|
||||
private final SignatureService signatureService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public boolean handleCallback(Map<String, Object> callbackData) {
|
||||
log.info("收到PingPong回调通知: {}", callbackData);
|
||||
|
||||
// 验证签名
|
||||
if (!signatureService.verifySign(callbackData)) {
|
||||
log.error("回调签名验证失败");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取订单信息
|
||||
String merchantTransactionId = (String) callbackData.get("merchantTransactionId");
|
||||
String transactionId = (String) callbackData.get("transactionId");
|
||||
String code = (String) callbackData.get("code");
|
||||
String description = (String) callbackData.get("description");
|
||||
String status = (String) callbackData.get("status");
|
||||
|
||||
if (merchantTransactionId == null || merchantTransactionId.isEmpty()) {
|
||||
log.error("回调数据中缺少商户订单号");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 查询订单
|
||||
Optional<PaymentOrder> orderOpt = paymentOrderMapper.findByMerchantTransactionId(merchantTransactionId);
|
||||
if (orderOpt.isEmpty()) {
|
||||
log.error("订单不存在: {}", merchantTransactionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
PaymentOrder order = orderOpt.get();
|
||||
|
||||
// 更新订单状态
|
||||
if (status != null && !status.isEmpty()) {
|
||||
// 根据status更新订单状态
|
||||
String orderStatus = mapCallbackStatusToOrderStatus(status);
|
||||
order.setStatus(orderStatus);
|
||||
|
||||
if (transactionId != null && !transactionId.isEmpty()) {
|
||||
order.setTransactionId(transactionId);
|
||||
}
|
||||
|
||||
paymentOrderMapper.updateById(order);
|
||||
log.info("订单状态已更新,商户订单号: {}, 状态: {}", merchantTransactionId, orderStatus);
|
||||
}
|
||||
|
||||
// 保存回调记录
|
||||
PaymentRecord record = new PaymentRecord();
|
||||
record.setMerchantTransactionId(merchantTransactionId);
|
||||
record.setTransactionId(transactionId);
|
||||
record.setRecordType("CALLBACK");
|
||||
record.setCode(code);
|
||||
record.setDescription(description);
|
||||
record.setStatus(status);
|
||||
|
||||
try {
|
||||
record.setRequestData(objectMapper.writeValueAsString(callbackData));
|
||||
} catch (Exception e) {
|
||||
log.warn("序列化回调数据失败", e);
|
||||
}
|
||||
|
||||
paymentRecordMapper.insert(record);
|
||||
|
||||
// 处理业务逻辑(例如:通知业务系统、更新库存等)
|
||||
handleBusinessLogic(order, status);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将回调状态映射到订单状态
|
||||
*/
|
||||
private String mapCallbackStatusToOrderStatus(String callbackStatus) {
|
||||
return switch (callbackStatus.toUpperCase()) {
|
||||
case "SUCCESS", "SUCCESSFUL" -> "SUCCESS";
|
||||
case "FAILED", "FAILURE" -> "FAILED";
|
||||
case "REVIEW" -> "REVIEW";
|
||||
case "CANCELLED", "CANCEL" -> "CANCELLED";
|
||||
case "PENDING" -> "PENDING";
|
||||
default -> {
|
||||
log.warn("未知的回调状态: {}", callbackStatus);
|
||||
yield "PENDING";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理业务逻辑
|
||||
* 这里可以根据实际业务需求实现,例如:
|
||||
* - 通知业务系统支付结果
|
||||
* - 更新库存
|
||||
* - 发送通知等
|
||||
*/
|
||||
private void handleBusinessLogic(PaymentOrder order, String status) {
|
||||
log.info("处理业务逻辑,订单号: {}, 状态: {}", order.getMerchantTransactionId(), status);
|
||||
|
||||
// TODO: 实现具体的业务逻辑
|
||||
// 例如:
|
||||
// - 如果支付成功,通知业务系统
|
||||
// - 如果支付失败,记录失败原因
|
||||
// - 如果状态为REVIEW,触发审核流程
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user