全局异常处理

订单开放接口
This commit is contained in:
lild
2026-03-03 23:15:56 +08:00
parent 474a993a7e
commit 4b05460fd0
9 changed files with 547 additions and 53 deletions

204
OPEN_API.md Normal file
View File

@@ -0,0 +1,204 @@
# 外部订单接口文档
## 接口概述
该接口用于外部系统向支付系统添加订单支持MD5签名验证、时间戳校验、事务处理等功能。
## 接口信息
- **接口路径**: `POST /api/openapi/orders`
- **请求方法**: POST
- **Content-Type**: application/json
## 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------------------------------------------|
| orderNo | String | 是 | 订单号,唯一标识 |
| amount | BigDecimal | 是 | 订单金额必须大于0且小于等于100000 |
| payType | String | 是 | 支付方式alipay/wechat/alipay_dmf/wechat_zs |
| nickName | String | 否 | 用户昵称 |
| description | String | 否 | 订单描述 |
| email | String | 否 | 用户邮箱 |
| notifyUrl | String | 否 | 异步通知地址 |
| userId | String | 否 | 用户ID |
| productId | Long | 否 | 产品ID |
| timestamp | Long | 是 | 请求时间戳毫秒有效期5分钟 |
| sign | String | 是 | MD5签名 |
## 签名算法
1. 将所有参数除sign外按字母顺序排序
2. 拼接成 `key1=value1&key2=value2&...&key=secretKey` 格式
3. 对拼接后的字符串进行MD5加密并转为大写
### 签名示例
假设配置的密钥为:`your_openapi_secret_key_here`
参数:
```json
{
"orderNo": "EXT20250303001",
"amount": 10.00,
"payType": "alipay",
"timestamp": 1733232000000
}
```
排序后的参数:
```
amount=10.00&orderNo=EXT20250303001&payType=alipay&timestamp=1733232000000&key=your_openapi_secret_key_here
```
MD5签名`XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX`
## 响应格式
### 成功响应
```json
{
"code": 200,
"msg": "success",
"data": {
"orderId": "uuid-generated-id",
"orderNo": "EXT20250303001",
"amount": 10.00,
"payType": "alipay",
"payNum": "随机支付标识",
"state": 0,
"message": "订单创建成功",
"timestamp": 1733232000000
}
}
```
### 失败响应
```json
{
"code": 400,
"msg": "错误信息描述",
"data": null
}
```
## 错误码说明
| 错误码 | 说明 |
|--------|------|
| 200 | 成功 |
| 400 | 参数错误 |
| 401 | 签名验证失败 |
| 402 | 时间戳无效或已过期 |
| 403 | 订单号已存在 |
| 404 | 金额错误 |
| 500 | 系统内部异常 |
## 请求示例
### cURL 示例
```bash
curl -X POST http://localhost:8889/api/openapi/orders \
-H "Content-Type: application/json" \
-d '{
"orderNo": "EXT20250303001",
"amount": 10.00,
"payType": "alipay",
"nickName": "测试用户",
"description": "测试订单",
"email": "test@example.com",
"userId": "USER001",
"productId": 1,
"notifyUrl": "http://example.com/notify",
"timestamp": 1733232000000,
"sign": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}'
```
### Java 示例
```java
import cn.hutool.http.HttpRequest;
import cn.hutool.json.JSONObject;
import cn.hutool.crypto.SecureUtil;
public class ExternalOrderClient {
private static final String API_URL = "http://localhost:8889/api/openapi/orders";
private static final String SECRET_KEY = "your_openapi_secret_key_here";
public static void main(String[] args) {
JSONObject params = new JSONObject();
params.put("orderNo", "EXT20250303001");
params.put("amount", 10.00);
params.put("payType", "alipay");
params.put("nickName", "测试用户");
params.put("description", "测试订单");
params.put("email", "test@example.com");
params.put("userId", "USER001");
params.put("productId", 1);
params.put("notifyUrl", "http://example.com/notify");
params.put("timestamp", System.currentTimeMillis());
String sign = generateSign(params);
params.put("sign", sign);
String response = HttpRequest.post(API_URL)
.header("Content-Type", "application/json")
.body(params.toString())
.execute()
.body();
System.out.println(response);
}
private static String generateSign(JSONObject params) {
List<String> keys = new ArrayList<>(params.keySet());
Collections.sort(keys);
StringBuilder sb = new StringBuilder();
for (String key : keys) {
Object value = params.get(key);
if (value != null && !"".equals(value) && !"sign".equals(key)) {
sb.append(key).append("=").append(value).append("&");
}
}
sb.append("key=").append(SECRET_KEY);
return SecureUtil.md5(sb.toString()).toUpperCase();
}
}
```
## 配置说明
`application.yml` 中配置外部接口密钥:
```yaml
paypro:
openapi:
secret: your_openapi_secret_key_here
```
## 安全特性
1. **MD5签名验证**:确保请求参数未被篡改
2. **时间戳校验**防止重放攻击时间戳有效期5分钟
3. **订单号唯一性**:防止重复订单
4. **金额限制**单笔订单金额不超过100000元
5. **事务处理**:确保订单创建的原子性
## 注意事项
1. 请妥善保管密钥,不要泄露给第三方
2. 时间戳必须使用毫秒级时间戳
3. 订单号必须唯一,重复的订单号会返回错误
4. 建议在生产环境中使用HTTPS协议
5. 建议实现IP白名单等额外的安全措施
## 测试页面
访问 `http://localhost:8889/open-api-test.html` 可以使用测试页面进行接口测试。

View File

@@ -3,12 +3,15 @@ package com.wendao.controller;
import cn.hutool.log.StaticLog;
import com.wendao.config.PayProConfig;
import com.wendao.entity.Order;
import com.wendao.exception.ApiException;
import com.wendao.model.ResponseVO;
import com.wendao.common.utils.*;
import com.wendao.enums.OrderStatesEnum;
import com.wendao.model.req.GetOrderListReq;
import com.wendao.model.req.OpenApiOrderReq;
import com.wendao.model.req.OrderReq;
import com.wendao.model.resp.AddOrderResp;
import com.wendao.model.resp.OpenApiOrderResp;
import com.wendao.service.OrderService;
import com.baomidou.mybatisplus.core.metadata.IPage;
import io.swagger.annotations.Api;
@@ -235,4 +238,26 @@ public class OrderController {
public String getOrderId(String id){
return id;
}
@PostMapping("/api/openapi/orders")
@ApiOperation(value = "创建OpenApi订单", notes = "OpenApi通过此接口创建支付订单")
@ResponseBody
public ResponseVO<OpenApiOrderResp> createOpenApiOrder(@RequestBody @Validated OpenApiOrderReq req) {
try {
log.info("收到OpenApi订单创建请求 - 订单号: {}, 金额: {}, 支付方式: {}",
req.getOrderNo(), req.getAmount(), req.getPayType());
OpenApiOrderResp resp = orderService.createOpenApiOrder(req);
log.info("OpenApi订单创建成功 - 订单ID: {}, 订单号: {}", resp.getOrderId(), resp.getOrderNo());
return new ResponseVO<>(ApiException.ErrorCode.SUCCESS, "success", resp);
} catch (ApiException e) {
log.warn("OpenApi订单创建失败 - 错误码: {}, 错误信息: {}", e.getCode(), e.getMessage());
return new ResponseVO<>(e.getCode(), e.getMessage());
} catch (Exception e) {
log.error("OpenApi订单创建系统异常", e);
return new ResponseVO<>(ApiException.ErrorCode.SYSTEM_ERROR, "系统内部异常");
}
}
}

View File

@@ -0,0 +1,30 @@
package com.wendao.exception;
import lombok.Getter;
@Getter
public class ApiException extends RuntimeException {
private final int code;
private final String message;
public ApiException(int code, String message) {
super(message);
this.code = code;
this.message = message;
}
public ApiException(String message) {
this(500, message);
}
public static class ErrorCode {
public static final int SUCCESS = 200;
public static final int INVALID_PARAM = 400;
public static final int SIGN_ERROR = 401;
public static final int TIMESTAMP_ERROR = 402;
public static final int DUPLICATE_ORDER = 403;
public static final int AMOUNT_ERROR = 404;
public static final int SYSTEM_ERROR = 500;
}
}

View File

@@ -0,0 +1,40 @@
package com.wendao.exception;
import com.wendao.model.ResponseVO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ApiException.class)
public ResponseVO<?> handleApiException(ApiException e) {
log.warn("API异常 - 错误码: {}, 错误信息: {}", e.getCode(), e.getMessage());
return ResponseVO.builder()
.code(e.getCode())
.msg(e.getMessage())
.build();
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseVO<?> handleIllegalArgumentException(IllegalArgumentException e) {
log.warn("参数异常: {}", e.getMessage());
return ResponseVO.builder()
.code(ApiException.ErrorCode.INVALID_PARAM)
.msg(e.getMessage())
.build();
}
@ExceptionHandler(Exception.class)
public ResponseVO<?> handleException(Exception e) {
log.error("系统异常", e);
return ResponseVO.builder()
.code(ApiException.ErrorCode.SYSTEM_ERROR)
.msg("系统内部异常")
.build();
}
}

View File

@@ -0,0 +1,38 @@
package com.wendao.model.req;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.io.Serializable;
import java.math.BigDecimal;
@Data
public class OpenApiOrderReq implements Serializable {
@NotBlank(message = "订单号不能为空")
private String orderNo;
@NotNull(message = "金额不能为空")
private BigDecimal amount;
@NotBlank(message = "支付方式不能为空")
private String payType;
private String nickName;
private String description;
private String email;
private String notifyUrl;
private String userId;
private Long productId;
@NotBlank(message = "签名不能为空")
private String sign;
private Long timestamp;
}

View File

@@ -0,0 +1,32 @@
package com.wendao.model.resp;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OpenApiOrderResp implements Serializable {
private String orderId;
private String orderNo;
private BigDecimal amount;
private String payType;
private String payNum;
private Integer state;
private String message;
private Long timestamp;
}

View File

@@ -2,6 +2,7 @@ package com.wendao.service;
import com.wendao.entity.Order;
import com.wendao.entity.PayChatMessage;
import com.wendao.model.req.OpenApiOrderReq;
import com.wendao.model.resp.CountResp;
import com.wendao.model.ResponseVO;
import com.wendao.dto.WeChatMsgDTO;
@@ -9,6 +10,8 @@ import com.wendao.model.req.GetOrderListReq;
import com.wendao.model.req.OrderReq;
import com.wendao.model.resp.AddOrderResp;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.wendao.model.resp.OpenApiOrderResp;
import javax.servlet.http.HttpServletRequest;
import java.util.Date;
import java.util.List;
@@ -71,7 +74,6 @@ public interface OrderService {
Order getByPayNum(String desc, Date time);
void autoPass(WeChatMsgDTO dto);
OpenApiOrderResp createOpenApiOrder(OpenApiOrderReq req);
void autoPass(PayChatMessage dto);
}

View File

@@ -4,6 +4,7 @@ import com.wendao.config.PayProConfig;
import com.wendao.entity.AutoPassPay;
import com.wendao.entity.Order;
import com.wendao.entity.PayChatMessage;
import com.wendao.exception.ApiException;
import com.wendao.mapper.AutoPassPayMapper;
import com.wendao.mapper.OrderMapper;
import com.wendao.mapper.PayChatMessageMapper;
@@ -12,8 +13,10 @@ import com.wendao.dto.MsgContentsDTO;
import com.wendao.dto.WeChatMsgDTO;
import com.wendao.enums.OrderStatesEnum;
import com.wendao.model.req.GetOrderListReq;
import com.wendao.model.req.OpenApiOrderReq;
import com.wendao.model.resp.AddOrderResp;
import com.wendao.model.resp.CountResp;
import com.wendao.model.resp.OpenApiOrderResp;
import com.wendao.service.OrderService;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.lang.Snowflake;
@@ -27,6 +30,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.wendao.utils.OpenApiSignUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
@@ -36,6 +40,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletRequest;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.TimeUnit;
@@ -74,6 +79,9 @@ public class OrderServiceImpl implements OrderService {
@Autowired
PayChatMessageMapper payChatMessageMapper;
@Autowired
OpenApiSignUtil openApiSignUtil;
@Override
public Order getOrderById(String id) {
Order byId = orderMapper.selectById(id);
@@ -291,64 +299,67 @@ public class OrderServiceImpl implements OrderService {
}
@Override
@Transactional
public void autoPass(WeChatMsgDTO dto) {
QueryWrapper<AutoPassPay> autoPassPayQueryWrapper = new QueryWrapper<>();
autoPassPayQueryWrapper.lambda().eq(AutoPassPay::getMessageId, dto.getId());
Long l = autoPassPayMapper.selectCount(autoPassPayQueryWrapper);
if (l > 0) {
return;
@Transactional(rollbackFor = Exception.class)
public OpenApiOrderResp createOpenApiOrder(OpenApiOrderReq req) {
if (!openApiSignUtil.verifyTimestamp(req.getTimestamp())) {
throw new ApiException(ApiException.ErrorCode.TIMESTAMP_ERROR, "请求时间戳无效或已过期");
}
String desc = dto.getContents().extractRemark();
Order byPayNum = null;
if (StringUtils.isNotBlank(desc)) {
/** 找到匹配的订单*/
byPayNum = thisService.getByPayNum(desc,dto.getTime());
thisService.pass(byPayNum.getId());
if (!openApiSignUtil.verifySign(req)) {
throw new ApiException(ApiException.ErrorCode.SIGN_ERROR, "签名验证失败");
}
AutoPassPay autoPassPay = new AutoPassPay();
autoPassPay.setId(snowflake.nextId());
if (byPayNum != null) {
autoPassPay.setOrderId(byPayNum.getId());
if (req.getAmount() == null || req.getAmount().compareTo(new BigDecimal("0")) <= 0) {
throw new ApiException(ApiException.ErrorCode.AMOUNT_ERROR, "金额必须大于0");
}
autoPassPay.setMessageCreateTime(dto.getTime());
autoPassPay.setMessageId(dto.getId());
autoPassPay.setMessageDesc(desc);
autoPassPayMapper.insert(autoPassPay);
if (req.getAmount().compareTo(new BigDecimal("100000")) > 0) {
throw new ApiException(ApiException.ErrorCode.AMOUNT_ERROR, "金额超出限制单笔订单不能超过100000元");
}
Order existingOrder = orderMapper.selectById(req.getOrderNo());
if (existingOrder != null) {
throw new ApiException(ApiException.ErrorCode.DUPLICATE_ORDER, "订单号已存在");
}
Order order = new Order();
order.setId(req.getOrderNo());
order.setMoney(req.getAmount());
order.setPayType(req.getPayType());
order.setNickName(req.getNickName());
order.setEmail(req.getEmail());
order.setNotifyUrl(req.getNotifyUrl());
order.setUserId(req.getUserId());
order.setProductId(req.getProductId());
order.setOrderSource("OPENAPI");
order.setState(OrderStatesEnum.WAIT_PAY.getState());
order.setCreateTime(new Date());
order.setPayNum(StringUtils.getRandomNum());
if (req.getProductId() != null) {
int i = new Random().nextInt(payProConfig.getQrCodeNum()) + 1;
order.setPayQrNum(i);
}
try {
orderMapper.insert(order);
} catch (Exception e) {
log.error("创建OpenApi订单失败: {}", e.getMessage(), e);
throw new ApiException(ApiException.ErrorCode.SYSTEM_ERROR, "创建订单失败");
}
return OpenApiOrderResp.builder()
.orderId(order.getId())
.orderNo(req.getOrderNo())
.amount(req.getAmount())
.payType(req.getPayType())
.payNum(order.getPayNum())
.state(order.getState())
.message("订单创建成功")
.timestamp(System.currentTimeMillis())
.build();
}
@Override
@Transactional
public void autoPass(PayChatMessage dto) {
String desc = null;
if (dto.getPlatformType().equals("weixin")) {
MsgContentsDTO bean = JSONUtil.toBean(dto.getContents(), MsgContentsDTO.class);
desc = bean.extractRemark();
if (StringUtils.isBlank(desc)) {
dto.setProcessStatus(2);
payChatMessageMapper.updateById(dto);
log.info("处理:{}.没有提取到备注" + JSONUtil.toJsonStr(dto));
return;
}
}
Order byPayNum = null;
if (StringUtils.isNotBlank(desc)) {
/** 找到匹配的订单*/
byPayNum = thisService.getByPayNum(desc,dto.getTime());
if (byPayNum != null){
thisService.pass(byPayNum.getId());
dto.setOrderId(byPayNum.getId());
dto.setProcessStatus(1);
payChatMessageMapper.updateById(dto);
}
}
}
/**
* 拼接管理员链接

View File

@@ -0,0 +1,112 @@
package com.wendao.utils;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.wendao.model.req.OpenApiOrderReq;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.*;
@Component
public class OpenApiSignUtil {
private static final Logger log = LoggerFactory.getLogger(OpenApiSignUtil.class);
@Value("${paypro.openapi.secret:default_secret_key}")
private String secretKey;
public String generateSign(Map<String, Object> params) {
List<String> keys = new ArrayList<>(params.keySet());
Collections.sort(keys);
log.info("开始生成签名,参数数量: {}", keys.size());
StringBuilder sb = new StringBuilder();
for (String key : keys) {
Object value = params.get(key);
if (value != null && !"".equals(value) && !"sign".equals(key)) {
// 对BigDecimal进行特殊处理保持与前端一致的格式
String valueStr;
if (value instanceof BigDecimal) {
valueStr = ((BigDecimal) value).setScale(2, BigDecimal.ROUND_HALF_UP).toPlainString();
} else {
valueStr = value.toString();
}
// 检查 valueStr 是否包含空格
log.info("处理参数: key={}, value类型={}, value值=[{}], 包含空格={}",
key, value.getClass().getSimpleName(), valueStr, valueStr.contains(" "));
String paramPart = key + "=" + valueStr + "&";
log.info("添加参数: key={}, value={}, paramPart={}", key, value, paramPart);
sb.append(paramPart);
}
}
sb.append("key=").append(secretKey);
String signStr = sb.toString();
log.info("服务端签名字符串: {}", signStr);
log.info("服务端使用的密钥: {}", secretKey);
return SecureUtil.md5(signStr).toUpperCase();
}
public boolean verifySign(OpenApiOrderReq req) {
Map<String, Object> params = new TreeMap<>();
params.put("orderNo", req.getOrderNo());
params.put("amount", req.getAmount());
params.put("payType", req.getPayType());
params.put("nickName", req.getNickName());
params.put("description", req.getDescription());
params.put("email", req.getEmail());
params.put("notifyUrl", req.getNotifyUrl());
params.put("userId", req.getUserId());
params.put("productId", req.getProductId());
params.put("timestamp", req.getTimestamp());
log.info("服务端接收到的参数: orderNo={}, amount={}, payType={}, nickName={}, description={}, email={}, notifyUrl={}, userId={}, productId={}, timestamp={}",
req.getOrderNo(), req.getAmount(), req.getPayType(), req.getNickName(),
req.getDescription(), req.getEmail(), req.getNotifyUrl(), req.getUserId(),
req.getProductId(), req.getTimestamp());
log.info("服务端接收到的签名: {}", req.getSign());
// 检查 notifyUrl 是否包含空格
if (req.getNotifyUrl() != null) {
String notifyUrl = req.getNotifyUrl();
log.info("notifyUrl 原始值: [{}]", notifyUrl);
log.info("notifyUrl 长度: {}, 包含空格: {}", notifyUrl.length(), notifyUrl.contains(" "));
log.info("notifyUrl 字节数组: {}", notifyUrl.getBytes());
}
String calculatedSign = generateSign(params);
boolean isValid = calculatedSign.equals(req.getSign());
if (!isValid) {
log.warn("签名验证失败 - 期望: {}, 实际: {}", calculatedSign, req.getSign());
}
return isValid;
}
public boolean verifyTimestamp(Long timestamp) {
if (timestamp == null) {
return false;
}
long currentTime = System.currentTimeMillis();
long diff = Math.abs(currentTime - timestamp);
long maxDiff = 5 * 60 * 1000;
if (diff > maxDiff) {
log.warn("时间戳验证失败 - 当前: {}, 请求: {}, 差值: {}ms", currentTime, timestamp, diff);
return false;
}
return true;
}
public static void main(String[] args) {
System.out.println(DigestUtil.md5Hex("amount=10.00&description=1&email=test@example.com&nickName=1"));
}
}