feat(pay): 添加PingPong支付回调处理功能

- 新增CallbackController接收并处理PingPong支付回调通知
- 实现回调签名验证逻辑
- 添加支付结果页面展示功能,支持成功、失败、审核中等状态显示
- 创建CallbackService接口及实现类处理回调业务逻辑
- 新增账单地址DTO(BillingDTO)用于风险信息传输
- 添加航空信息DTO(AirlineDTO)和租车信息DTO(CarRentalDTO)作为扩展风险数据结构
- 完善Checkout请求DTO字段校验规则,增强数据安全性
- 实现订单状态映射与更新机制,确保支付状态同步准确
- 记录回调处理日志便于问题追踪与审计
This commit is contained in:
2025-12-18 17:47:35 +08:00
parent 723676ddb3
commit 1cf4914d8b
8 changed files with 467 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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 {
// 根据实际需求添加字段
}

View 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;
}

View File

@@ -0,0 +1,15 @@
package com.mtkj.mtpay.dto.risk;
import lombok.Data;
import java.io.Serializable;
/**
* 租车信息DTO
*/
@Data
public class CarRentalDTO implements Serializable {
// 根据实际需求添加字段
}

View File

@@ -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);
}

View File

@@ -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触发审核流程
}
}