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