🌟“场快订” 场馆预定平台是我个人匠心打造的全栈免费开源项目,使用 Spring Cloud + Uniapp 开发,包含高并发设计(缓存击穿、缓存穿透处理)、大数据量查询优化、分库分表、IP 流量管控、分布式事务、分布式 ID、幂等处理、WebSocket 双向通信、消息队列异步执行、延时队列等内容,此外还包括域名购买与解析、项目打包上线、HTTP升级HTTPS等手把手教程,项目代码简洁,部分代码使用设计模式重构,非常适用于学习后端技术、毕业设计、相关计算机竞赛,感兴趣的朋友可以从以下链接进行学习:
- 📦 源码仓库:gitee.com/HelloDam/ve…
- 📚 技术专栏:blog.csdn.net/laodanqiu/c…
- 📱 在线体验:hellodam.website(建议手机访问)
🎯导读:本文详细介绍了支付宝沙箱支付的使用方法,包括支付、退款和交易查询的完整流程。通过沙箱环境,开发者可以模拟真实支付场景,无需使用真实资金。文章提供了Java代码示例,展示了如何发起支付、处理回调、进行退款以及查询交易状态。此外,还探讨了支付过程中可能遇到的问题及解决方案,如订单超时处理和内网穿透技术的应用,帮助开发者更好地理解和集成支付宝支付功能。
沙箱支付
支付宝沙箱功能网址:open.alipay.com/develop/san…
基本介绍
沙箱支付通常指的是在受控环境中模拟真实支付流程的一种测试工具。它并非一个实际的支付服务,而是为开发者、商家和支付服务提供商提供的一种安全的测试环境。通过沙箱支付,用户可以模拟完整的支付过程,包括发起支付请求、处理支付网关交互、以及接收支付结果,而无需使用真实的资金。
沙箱基本信息
在调用支付宝接口发起支付、退款、查看交易信息的时候,需要配置应用公钥、应用私钥、支付宝公钥信息,这些信息的查看方式如下
在付款的时候,需要登录支付宝账户,当然不是登录自己的支付宝,需要使用沙箱提供的账号,账户余额想设置多少就多少,可以体验一把当富豪的感觉
如何发起一笔支付
在沙箱支付中,支持如下支付,我们想使用什么类型的支付,就前往调试,可以查看相应的教程
手机网站支付快速集成教程链接:opendocs.alipay.com/open/203/10…
在跑上面的代码之前,记得先引入支付宝的相关依赖:
<!-- 支付宝SDK -->
<dependency>
<groupId>com.alipay.sdk</groupId>
<artifactId>alipay-sdk-java</artifactId>
<version>4.40.26.ALL</version>
</dependency>
发起支付的代码如下:
将上述代码复制到JAVA中,配置好公钥、私钥等信息,启动成功之后,生成了如下信息
创建一个 html 文件,用来展示支付宝创建订单之后返回的表单数据
<!DOCTYPE html>
<html>
<head>
<title>立即支付</title>
<script type="text/javascript">
// 页面加载完成后自动提交表单
window.onload = function() {
document.forms['punchout_form'].submit();
}
</script>
</head>
<body>
<form name="punchout_form" method="post" action="https://openapi-sandbox.dl.alipaydev.com/gateway.do?charset=UTF-8&method=alipay.trade.wap.pay&sign=DCpod52Ea%2FLgEc30q7NO48prZg9G7gImMFwBQq%2F63NvgOsyL81xm3JnAEhYLzaj0bTxIqdZbUSe0lluyf5C7hPKusWiRC8g8vZWGn4ALv9tCTBQb%2FwA78PNUcAyd9FnUJk7K%2B6XoREqDnqO6B5LKIIjUcFOtNhobDTmp4XlMCo59inxku4iHepRaQ73sRUshQhEfjEVqkmVI3CWhvMXrTAz%2BiLIJJFZmUM1QiEsE6lIxy4RHGXuCNffcIjV%2FqnmidZLw6%2BeUBHpcP6e9lfq7LpRTlE6vMwTE6bu%2B5vYSvyV7GlfrD5ckStttEjP7FyNDjDPnnQmywB62MW2lwH42%2FQ%3D%3D&version=1.0&app_id=2021000143601715&sign_type=RSA2×tamp=2024-12-31+09%3A47%3A32&alipay_sdk=alipay-sdk-java-4.40.26.ALL&format=json">
<input type="hidden" name="biz_content" value="{"auth_token":"appopenBb64d181d0146481ab6a762c00714cC27","business_params":"{\"mc_create_trade_ip\":\"127.0.0.1\"}","ext_user_info":{"cert_no":"362334768769238881","cert_type":"IDENTITY_CARD","fix_buyer":"F","identity_hash":"27bfcd1dee4f22c8fe8a2374af9b660419d1361b1c207e9b41a754a113f38fcc","min_age":"18","mobile":"16587658765","name":"李明","need_check_info":"F"},"extend_params":{"card_type":"S0JP0000","hb_fq_num":"3","hb_fq_seller_percent":"100","industry_reflux_info":"{\"scene_code\":\"metro_tradeorder\",\"channel\":\"xxxx\",\"scene_data\":{\"asset_name\":\"ALIPAY\"}}","royalty_freeze":"true","specified_seller_name":"XXX的跨境小铺","sys_service_provider_id":"2088511833207846"},"goods_detail":[{"alipay_goods_id":"20010001","body":"特价手机","categories_tree":"124868003|126232002|126252004","goods_category":"34543238","goods_id":"apple-01","goods_name":"ipad","price":"2000","quantity":1,"show_url":"http:\/\/www.alipay.com\/xxx.jpg"}],"merchant_order_no":"20161008001","out_trade_no":"c4a897c8-66bc-4d0f-a391-b00a7ef97b26","passback_params":"merchantBizType%3d3C%26merchantBizNo%3d2016010101111","product_code":"QUICK_WAP_WAY","quit_url":"http:\/\/www.taobao.com\/product\/113714.html","subject":"大乐透","time_expire":"2025-12-31 10:05:00","total_amount":"9.00"}">
<input type="submit" value="立即支付" style="display:none" >
</body>
</html>
成功显示了如下页面
但当进行支付时,返回了如下错误
由于我不知道错误的原因,通过查询,找到了如下调试工具
- 排查工具:opensupport.alipay.com/support/dia…
- 根据订单号,查看交易失败原因:opensupport.alipay.com/support/dia…
但最终还是没找到异常原因,我只能另选他法,后面几经查询,发现了如下文档(opensupport.alipay.com/support/FAQ…
代码如下
package com.vrs;
import com.alibaba.fastjson.JSONObject;
import com.alipay.api.AlipayApiException;
import com.alipay.api.AlipayClient;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.request.AlipayTradeWapPayRequest;
import com.alipay.api.response.AlipayTradeWapPayResponse;
/**
* @Author dam
* @create 2024/12/31 9:33
*/
public class AlipayTradeWapPay {
public static void main(String[] args) throws AlipayApiException {
/** 引用初始化方法,Config配置链接:https://opensupport.alipay.com/support/FAQ/65b9bd525b64740546048a01prod **/
AlipayClient alipayClient = new DefaultAlipayClient(Config.gatewayUrl, Config.app_id, Config.merchant_private_key, Config.format, Config.charset, Config.alipay_public_key, Config.sign_type);
/** 实例化具体API对应的request类,类名称和接口名称对应,当前调用接口名称 alipay.trade.wap.pay **/
AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest();
JSONObject Content = new JSONObject() ;
/******必传参数******/
// 商户订单号,商户自定义,需保证在商户端不重复,如:20200612000001
Content.put("out_trade_no", "20200612000001");
// 订单标题
Content.put("subject", "subject");
// 订单金额,精确到小数点后两位
Content.put("total_amount", "0.1");
/******可选参数******/
// 销售产品码,固定值:ALIPAY_WAP_PAY
Content.put("product_code", "ALIPAY_WAP_PAY");
// 扩展信息
/* JSONObject extendParams = new JSONObject();
// 花呗分期参数:hb_fq_num 代表花呗分期数,仅支持传入 3、6、12
extendParams.put("hb_fq_num", "3");
// 花呗分期参数:hb_fq_seller_percent 代表卖家承担收费比例,商家承担手续费传入 100,用户承担手续费传入 0
extendParams.put("hb_fq_seller_percent", "100");
Content.put("extend_params", extendParams);*/
//封装请求参数到biz_content
request.setBizContent(Content.toString());
/**注:支付结果以异步通知为准,不能以同步返回为准,因为如果实际支付成功,但因为外力因素,如断网、断电等导致页面没有跳转,则无法接收到同步通知;**/
/** 支付完成的跳转地址,用于用户视觉感知支付是否完成,传值外网可以访问的地址 **/
request.setReturnUrl(Config.return_url);
/** 异步通知地址,以http或者https开头的,商户外网可以post访问的异步地址,用于接收支付宝返回的支付结果 **/
request.setNotifyUrl(Config.notify_url);
/**第三方调用(服务商模式),传值app_auth_token后,会收款至授权token对应商家账号 **/
request.putOtherTextParam("app_auth_token", Config.app_auth_token);
AlipayTradeWapPayResponse response = alipayClient.pageExecute(request);//生成form表单
// AlipayTradeWapPayResponse response = alipayClient.pageExecute(request,"GET");//生成url链接
/** 获取接口调用结果 **/
System.out.println(response.getBody());
}
}
Config类如下,注意需要配置好 应用ID、应用私钥、支付宝公钥
/**
* 初始化方法参数&公共参数配置
*/
public class Config {
/** 初始化代码配置信息 **/
//(必填)支付宝网关
//正式环境网关:https://openapi.alipay.com/gateway.do
//沙箱环境网关:https://openapi-sandbox.dl.alipaydev.com/gateway.do
public static final String gatewayUrl = "https://openapi.alipay.com/gateway.do";
//(必填)应用ID
//请填写您的APPID:https://opendocs.alipay.com/common/02nebp
public static final String app_id = "";
//(必填)应用私钥:https://opendocs.alipay.com/common/02kipk?pathHash=0d20b438
//请填写您的应用私钥,例如:MIIEvQIBADANB ...
public static final String merchant_private_key = "";
/********** RSA2公钥模式签名必用,公钥证书签名不传 ************/
/**注:如果采用非证书模式,则无需赋值三个证书路径,改为赋值如下的支付宝公钥字符串即可**/
//设置RSA2公钥方式:hhttps://opendocs.alipay.com/common/02kdnc?pathHash=fb0c752a
//支付宝公钥
//请填写您的支付宝公钥,例如:MIIBIjANBg...
public static final String alipay_public_key = "";
/********** 公钥证书模式签名必用,RSA2公钥签名不传 ************/
/** 注:证书文件路径支持设置为文件系统中的路径或CLASS_PATH中的路径,同时配置公钥证书和RSA2公钥优先取公钥证书**/
//设置证书方式:https://opendocs.alipay.com/common/056zub?pathHash=91c49771
// 应用公钥证书路径
//请填写您的应用公钥证书文件路径,例如:/foo/appCertPublicKey_2019051064521003.crt
public static String app_cert_path ="";
//支付宝公钥证书路径
//请填写您的支付宝公钥证书文件路径,例如:/foo/alipayCertPublicKey_RSA2.crt
public static String alipay_cert_path ="";
//支付宝根证书路径
//请填写您的支付宝根证书文件路径,例如:/foo/alipayRootCert.crt
public static String alipay_root_cert_path ="";
//(必填)签名类型
public static String sign_type = "RSA2";
//(必填)编码格式
public static String charset = "UTF-8";
//请求格式,固定值json
public static String format = "JSON";
//调用的接口版本,固定为:1.0
public static String version = "1.0";
//AES密钥,配合encrypt_type=AES加解密相关接口
public static String encryptKey = "";
//请求格式,固定值AES,(设置EncryptKey时必选)
public static String encryptType = "AES";
/** 代码中其他配置信息,根据各产品API公共参数选择性引用 **/
//第三方调用(服务商模式),传值app_auth_token后相当于以授权商户角色调用接口,app_auth_token获取流程:https://opendocs.alipay.com/isv/10467/xldcyq?pathHash=abce531a
public static String app_auth_token = "";
//通过公共参数notify_url配置上传异步地址
//异步通知地址需http://或者https://格式的完整路径,不能加?id=123这类自定义参数,商户外网可以post访问的异步地址(不支持本地测试),用于接收支付宝返回的支付结果
public static String notify_url ="";
//通过接口公共参数return_url配置上传同步地址
//同步通知地址需http://或者https://格式的完整路径,不能加?id=123这类自定义参数,get访问,用于支付完成后前端页面同步跳转
public static String return_url ="";
//日志记录目录
public static String log_path = "D:/log.txt";
}
运行之后,复制如下代码到html文件中
<!DOCTYPE html>
<html>
<head>
<title>立即支付</title>
<script type="text/javascript">
// 页面加载完成后自动提交表单
window.onload = function() {
document.forms['punchout_form'].submit();
}
</script>
</head>
<body>
<form name="punchout_form" method="post" action="https://openapi-sandbox.dl.alipaydev.com/gateway.do?charset=UTF-8&method=alipay.trade.wap.pay&sign=K5uCXN78U4vI9a0jv%2BhPPN9CQyfGE9bI%2BgTlq5uj%2BBz3fIJSSe%2FbiKTwOtCseg9JVDDeJ4fFIWi1eW3eP%2F4sH8%2Bsir5QXYWuvlBc4g7gNP0jNU26C9E21qbVnn%2BPAy6aeY9xUILzWdubRep2lOOjKNI0erSuJxxdzTKz%2Fm3r%2FS4E5xSIzhD%2FPdAB0yrZJgTZrOeN7L9vTUi1YyM308J9w5rsbs0WldJSx0zSW4ZbjDC8dcbJcqyBD9bG8EDFOpxq%2BwIJnY2IEajj%2BKLmNnPvCMKs%2FmFgz2xvaGVAAk6Ktd970gOwwuFlNWxFw3Z5%2BL1Phexq3doscKjof%2FlsQxaX7Q%3D%3D&version=1.0&app_id=2021000143601715&sign_type=RSA2×tamp=2024-12-31+10%3A08%3A38&alipay_sdk=alipay-sdk-java-4.40.26.ALL&format=JSON">
<input type="hidden" name="biz_content" value="{"out_trade_no":"510ae33d-897a-46ee-ac71-1b18a4ae3956","total_amount":"0.1","subject":"subject","product_code":"ALIPAY_WAP_PAY"}">
<input type="submit" value="立即支付" style="display:none" >
</form>
<script>document.forms[0].submit();</script>
</body>
</html>
经过测试,发现成功了,果然还是最新的文档靠谱
除了生成 form 表单之外,还可以直接返回一个支付链接的url,前端直接跳转到这个url进行支付就可以了,这种方式更加简单
支付之后如何退款
opendocs.alipay.com/open/4b7cc5…
【退款流程】
退款接口参数阅读如下文档即可
【注意事项】
- 支付成功之后,会返回一个交易号,可以存储到数据库中,退款的时候可以使用,不然直接使用订单号也可以
- 退款时所设置得退款金额必须小于等于支付金额
import com.alipay.api.AlipayApiException;
import com.alipay.api.AlipayClient;
import com.alipay.api.AlipayConfig;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.diagnosis.DiagnosisUtils;
import com.alipay.api.domain.AlipayTradeRefundModel;
import com.alipay.api.request.AlipayTradeRefundRequest;
import com.alipay.api.response.AlipayTradeRefundResponse;
public class AlipayTradeRefund {
public static void main(String[] args) throws AlipayApiException {
// 初始化SDK
AlipayClient alipayClient = new DefaultAlipayClient(getAlipayConfig());
// 构造请求参数以调用接口
AlipayTradeRefundRequest request = new AlipayTradeRefundRequest();
AlipayTradeRefundModel model = new AlipayTradeRefundModel();
// 设置支付宝交易号
model.setTradeNo("2025010222001498510504477448");
// 设置退款金额
model.setRefundAmount("50.00");
// 设置退款原因说明
model.setRefundReason("正常退款");
request.setBizModel(model);
AlipayTradeRefundResponse response = alipayClient.execute(request);
System.out.println(response.getBody());
if (response.isSuccess()) {
System.out.println("调用成功");
} else {
System.out.println("调用失败");
// sdk版本是"4.38.0.ALL"及以上,可以参考下面的示例获取诊断链接
String diagnosisUrl = DiagnosisUtils.getDiagnosisUrl(response);
System.out.println(diagnosisUrl);
}
}
private static AlipayConfig getAlipayConfig() {
String privateKey = "";
String alipayPublicKey = "";
AlipayConfig alipayConfig = new AlipayConfig();
alipayConfig.setServerUrl("https://openapi-sandbox.dl.alipaydev.com/gateway.do");
alipayConfig.setAppId("");
alipayConfig.setPrivateKey(privateKey);
alipayConfig.setFormat("json");
alipayConfig.setAlipayPublicKey(alipayPublicKey);
alipayConfig.setCharset("UTF-8");
alipayConfig.setSignType("RSA2");
return alipayConfig;
}
}
测试成功
也可以直接调试退款接口
查询交易情况
opendocs.alipay.com/open/4e2d51…
package com.vrs;
import com.alipay.api.AlipayApiException;
import com.alipay.api.AlipayClient;
import com.alipay.api.AlipayConfig;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.diagnosis.DiagnosisUtils;
import com.alipay.api.domain.AlipayTradeQueryModel;
import com.alipay.api.request.AlipayTradeQueryRequest;
import com.alipay.api.response.AlipayTradeQueryResponse;
import java.util.ArrayList;
import java.util.List;
/**
* @Author dam
* @create 2025/1/3 9:33
*/
public class AlipayTradeQuery {
public static void main(String[] args) throws AlipayApiException {
// 初始化SDK
AlipayClient alipayClient = new DefaultAlipayClient(getAlipayConfig());
// 构造请求参数以调用接口
AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
AlipayTradeQueryModel model = new AlipayTradeQueryModel();
// 设置订单支付时传入的商户订单号
model.setOutTradeNo("1874780217354649600850432");
// 设置支付宝交易号
// model.setTradeNo("2025010222001498510504477448");
// 设置查询选项
List<String> queryOptions = new ArrayList<String>();
queryOptions.add("trade_settle_info");
model.setQueryOptions(queryOptions);
request.setBizModel(model);
AlipayTradeQueryResponse response = alipayClient.execute(request);
System.out.println(response.getBody());
if (response.isSuccess()) {
System.out.println("调用成功");
} else {
System.out.println("调用失败");
// sdk版本是"4.38.0.ALL"及以上,可以参考下面的示例获取诊断链接
String diagnosisUrl = DiagnosisUtils.getDiagnosisUrl(response);
System.out.println(diagnosisUrl);
}
}
private static AlipayConfig getAlipayConfig() {
String privateKey = "";
String alipayPublicKey = "";
AlipayConfig alipayConfig = new AlipayConfig();
alipayConfig.setServerUrl("https://openapi-sandbox.dl.alipaydev.com/gateway.do");
alipayConfig.setAppId("");
alipayConfig.setPrivateKey(privateKey);
alipayConfig.setFormat("json");
alipayConfig.setAlipayPublicKey(alipayPublicKey);
alipayConfig.setCharset("UTF-8");
alipayConfig.setSignType("RSA2");
return alipayConfig;
}
}
支付功能开发
依赖
<!-- 支付宝SDK -->
<dependency>
<groupId>com.alipay.sdk</groupId>
<artifactId>alipay-sdk-java</artifactId>
<version>4.40.26.ALL</version>
</dependency>
配置文件
pay:
alipay:
app-id: appId
alipay-public-key: 支付宝公钥
private-key: 应用私钥
gateway-url: https://openapi-sandbox.dl.alipaydev.com/gateway.do
notify-url: 回调接口地址
配置类
package com.vrs.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* 支付宝配置文件
*
* @Author dam
* @create 2024/12/31 9:35
*/
@Data
@Configuration
@ConfigurationProperties(prefix = AlipayConfig.PREFIX)
public class AlipayConfig {
public static final String PREFIX = "pay.alipay";
/**
* 初始化代码配置信息
**/
//(必填)支付宝网关
// 正式环境网关:https://openapi.alipay.com/gateway.do
// 沙箱环境网关:https://openapi-sandbox.dl.alipaydev.com/gateway.do
private String gatewayUrl;
// (必填)应用ID
// 请填写您的APPID:https://opendocs.alipay.com/common/02nebp
private String appId;
// (必填)应用私钥:https://opendocs.alipay.com/common/02kipk?pathHash=0d20b438
// 请填写您的应用私钥,例如:MIIEvQIBADANB ...
private String privateKey;
/********** RSA2公钥模式签名必用,公钥证书签名不传 ************/
/**
* 注:如果采用非证书模式,则无需赋值三个证书路径,改为赋值如下的支付宝公钥字符串即可
**/
// 设置RSA2公钥方式:hhttps://opendocs.alipay.com/common/02kdnc?pathHash=fb0c752a
// 支付宝公钥
private String alipayPublicKey;
/********** 公钥证书模式签名必用,RSA2公钥签名不传 ************/
/**
* 注:证书文件路径支持设置为文件系统中的路径或CLASS_PATH中的路径,同时配置公钥证书和RSA2公钥优先取公钥证书
**/
// 设置证书方式:https://opendocs.alipay.com/common/056zub?pathHash=91c49771
// 应用公钥证书路径
// 请填写您的应用公钥证书文件路径,例如:/foo/appCertPublicKey_2019051064521003.crt
private String app_cert_path = "";
// 支付宝公钥证书路径
// 请填写您的支付宝公钥证书文件路径,例如:/foo/alipayCertPublicKey_RSA2.crt
private String alipay_cert_path = "";
// 支付宝根证书路径
// 请填写您的支付宝根证书文件路径,例如:/foo/alipayRootCert.crt
private String alipay_root_cert_path = "";
//(必填)签名类型
private String signType = "RSA2";
//(必填)编码格式
private String charset = "UTF-8";
// 请求格式,固定值json
private String format = "JSON";
// 调用的接口版本,固定为:1.0
private String version = "1.0";
// AES密钥,配合encrypt_type=AES加解密相关接口
private String encryptKey = "";
// 请求格式,固定值AES,(设置EncryptKey时必选)
private String encryptType = "AES";
/**
* 代码中其他配置信息,根据各产品API公共参数选择性引用
**/
// 第三方调用(服务商模式),传值app_auth_token后相当于以授权商户角色调用接口,app_auth_token获取流程:https://opendocs.alipay.com/isv/10467/xldcyq?pathHash=abce531a
private String appAuthToken = "";
// 通过公共参数notify_url配置上传异步地址
// 异步通知地址需http://或者https://格式的完整路径,不能加?id=123这类自定义参数,商户外网可以post访问的异步地址(不支持本地测试),用于接收支付宝返回的支付结果
private String notifyUrl;
// 通过接口公共参数return_url配置上传同步地址
// 同步通知地址需http://或者https://格式的完整路径,不能加?id=123这类自定义参数,get访问,用于支付完成后前端页面同步跳转
private String returnUrl;
// 日志记录目录
private String log_path = "D:/log.txt";
}
支付存在问题
若用户在订单过期前几十秒开始付款,付款成功之后,回调还没有执行完成,此时延时任务执行,订单到时间没有完成支付,订单被取消了,导致库存被还原,但实际用户已经付款了,这种情况怎么处理?
方案一
支付完成,回调时验证一下订单的状态,如果发现已经超时取消了,自动退款给用户
优点:
- 简单直接:在支付回调中处理订单状态,逻辑清晰,易于实现。
- 用户友好:如果订单已取消,自动退款给用户,避免用户因订单取消而损失资金。
缺点:
- 时间窗口问题:如果支付回调在订单取消后执行,可能会导致订单已取消但支付成功的状态不一致问题。
- 退款延迟:自动退款可能需要一定时间,用户可能会感到困惑,尤其是在支付成功后立即退款。
方案二
用户调用支付的时候,先锁定订单。如果订单到达过期时间,判断订单处于锁定状态,就不执行订单取消逻辑,发送一个延时消息,如果过几分钟检查简单还不是已支付状态,说明用户支付未成功,再取消订单
优点:
- 防止订单误取消:通过锁定订单,避免在支付过程中订单被取消,减少订单状态不一致的风险。
- 灵活性:通过延时消息,可以在支付完成后再次检查订单状态,确保订单不会被误取消。
缺点:
- 复杂性增加:需要实现订单锁定机制和延时消息处理,增加了系统的复杂性。
- 资源占用:锁定订单可能会占用系统资源,尤其是在高并发场景下,可能会影响系统性能。
- 延时消息的可靠性:延时消息的可靠性依赖于消息队列的稳定性,如果消息队列出现问题,可能会导致订单状态处理不及时。
解决方案
经过权衡,最终使用方案二来解决,代码实现参考发起支付和支付回调小节
发起支付
发起支付主要是将订单信息封装起来调用支付宝的接口,让其生成一个支付链接给我们
支付服务
controller
package com.vrs.controller;
import com.vrs.convention.result.Result;
import com.vrs.convention.result.Results;
import com.vrs.domain.dto.req.AlipayPayReqDTO;
import com.vrs.service.AlipayService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @Author dam
* @create 2024/12/31 14:06
*/
@RestController
@RequiredArgsConstructor
@Tag(name = "支付相关")
@RequestMapping("/pay/")
public class PayController {
private final AlipayService alipayService;
/**
* 调用支付宝进行支付
* @param alipayPayReqDTO
* @return 支付地址
*/
@PostMapping("/v1/alipay/commonPay")
@Operation(summary = "普通支付")
public Result<String> commonPay(@RequestBody AlipayPayReqDTO alipayPayReqDTO) {
String alipayUrl = alipayService.commonPay(alipayPayReqDTO);
return Results.success(alipayUrl);
}
}
service
该方法先向支付宝发起支付,然后存储一条支付信息到数据库中
package com.vrs.service.impl;
import cn.hutool.core.lang.Singleton;
import com.alibaba.fastjson.JSONObject;
import com.alipay.api.AlipayApiException;
import com.alipay.api.AlipayClient;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.diagnosis.DiagnosisUtils;
import com.alipay.api.domain.AlipayTradeRefundModel;
import com.alipay.api.request.AlipayTradeRefundRequest;
import com.alipay.api.request.AlipayTradeWapPayRequest;
import com.alipay.api.response.AlipayTradeRefundResponse;
import com.alipay.api.response.AlipayTradeWapPayResponse;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.vrs.common.AlipayConfig;
import com.vrs.common.constant.PayTypeConstant;
import com.vrs.common.constant.RefundTypeConstant;
import com.vrs.common.dto.PayCallbackDTO;
import com.vrs.convention.errorcode.BaseErrorCode;
import com.vrs.convention.exception.ClientException;
import com.vrs.domain.dto.mq.OrderPayMqDTO;
import com.vrs.domain.dto.req.AlipayPayReqDTO;
import com.vrs.domain.dto.req.AlipayRefundReqDTO;
import com.vrs.domain.dto.resp.AlipayRefundRespDTO;
import com.vrs.domain.entity.PayDO;
import com.vrs.rocketMq.producer.OrderPayProducer;
import com.vrs.service.AlipayService;
import com.vrs.service.PayService;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
/**
* @Author dam
* @create 2024/12/31 14:11
*/
@Service
@RequiredArgsConstructor
public class AlipayServiceImpl implements AlipayService {
private final AlipayConfig alipayConfig;
private final PayService payService;
private final OrderPayProducer orderPayProducer;
@SneakyThrows
@Override
public String commonPay(AlipayPayReqDTO alipayPayReqDTO) {
String alipayUrl = alipay(alipayPayReqDTO);
// 存储支付信息到数据库中
PayDO payDO = PayDO.builder()
.orderSn(alipayPayReqDTO.getOrderSn())
.paymentMethod(PayTypeConstant.ALIPAY)
.subject(alipayPayReqDTO.getSubject())
.build();
try {
// 捕捉异常,避免重复发起支付,抛出唯一索引异常,导致程序中断
payService.save(payDO);
} catch (Exception e) {
e.printStackTrace();
}
return alipayUrl;
}
/**
* 支付宝支付
*
* @param alipayPayReqDTO
* @return
* @throws AlipayApiException
*/
private String alipay(AlipayPayReqDTO alipayPayReqDTO) throws AlipayApiException {
// 使用 Hutool 单例模式来管理
AlipayClient alipayClient = Singleton.get("alipayClient", () -> {
return new DefaultAlipayClient(
alipayConfig.getGatewayUrl(),
alipayConfig.getAppId(),
alipayConfig.getPrivateKey(),
alipayConfig.getFormat(),
alipayConfig.getCharset(),
alipayConfig.getAlipayPublicKey(),
alipayConfig.getSignType());
});
// 实例化具体API对应的request类,类名称和接口名称对应,当前调用接口名称 alipay.trade.wap.pay
AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest();
JSONObject Content = new JSONObject();
/// 必传参数
// 商户订单号,商户自定义,需保证在商户端不重复,如:20200612000001
Content.put("out_trade_no", alipayPayReqDTO.getOrderSn());
// 订单标题
Content.put("subject", alipayPayReqDTO.getSubject());
// 订单金额,精确到小数点后两位
Content.put("total_amount", alipayPayReqDTO.getPayAmount());
/// 可选参数
// 销售产品码,固定值:ALIPAY_WAP_PAY
Content.put("product_code", "ALIPAY_WAP_PAY");
// 封装请求参数到biz_content
request.setBizContent(Content.toString());
// 注:支付结果以异步通知为准,不能以同步返回为准,因为如果实际支付成功,但因为外力因素,如断网、断电等导致页面没有跳转,则无法接收到同步通知;
// 支付完成的跳转地址,用于用户视觉感知支付是否完成,传值外网可以访问的地址
request.setReturnUrl(alipayConfig.getReturnUrl());
// 异步通知地址,以http或者https开头的,商户外网可以post访问的异步地址,用于接收支付宝返回的支付结果
request.setNotifyUrl(alipayConfig.getNotifyUrl());
// 第三方调用(服务商模式),传值app_auth_token后,会收款至授权token对应商家账号
request.putOtherTextParam("app_auth_token", alipayConfig.getAppAuthToken());
// 生成form表单
// AlipayTradeWapPayResponse response = alipayClient.pageExecute(request);
// 生成url链接
AlipayTradeWapPayResponse response = alipayClient.pageExecute(request, "GET");
// 获取接口调用结果
return response.getBody();
}
}
因为每次支付都需要支付宝配置信息,这里使用Hutool的单例模式进行优化,避免对象的频繁创建
// 使用 Hutool 单例模式来管理
AlipayClient alipayClient = Singleton.get("alipayClient", () -> {
return new DefaultAlipayClient(
alipayConfig.getGatewayUrl(),
alipayConfig.getAppId(),
alipayConfig.getPrivateKey(),
alipayConfig.getFormat(),
alipayConfig.getCharset(),
alipayConfig.getAlipayPublicKey(),
alipayConfig.getSignType());
});
订单服务
controller
/**
* 订单支付
*
* @param orderSn
* @return
*/
@GetMapping("/pay")
@Operation(summary = "订单支付")
public Result pay(@RequestParam("orderSn") String orderSn) {
return Results.success(orderService.pay(orderSn));
}
service实现一
【支付方法】
该方法先校验参数是否正常,如果通过校验,发起一笔支付宝调用。注意调用支付服务发起支付成功之后,使用Redis缓存对当前订单添加一个锁标识,表示当前订单处于支付中,避免在支付过程中,到达过期时间,订单被关闭了
@Override
public String pay(String orderSn) {
OrderRespDTO orderRespDTO = this.getOrderRespDTOByOrderSn(orderSn);
if (orderRespDTO == null) {
// --if-- 订单不存在
throw new ClientException(BaseErrorCode.ORDER_NULL_ERROR);
}
if (orderRespDTO.getOrderStatus() == OrderStatusConstant.PAID) {
// --if-- 当前订单已经被支付
throw new ClientException(BaseErrorCode.ORDER_HAS_PAID_ERROR);
}
if (orderRespDTO.getOrderStatus() == OrderStatusConstant.CANCEL) {
// --if-- 当前订单已经取消
throw new ClientException(BaseErrorCode.ORDER_HAS_CANCELED_ERROR);
}
if (orderRespDTO.getOrderStatus() == OrderStatusConstant.REFUND) {
// --if-- 当前订单已退款
throw new ClientException(BaseErrorCode.ORDER_HAS_REFUND_ERROR);
}
// 使用 StringBuilder 进行字符串连接
StringBuilder subjectBuilder = new StringBuilder(orderRespDTO.getVenueName())
.append("_")
.append(orderRespDTO.getPartitionName())
.append(":")
.append(orderRespDTO.getPeriodDate())
.append(" ")
.append(orderRespDTO.getBeginTime())
.append("至")
.append(orderRespDTO.getEndTime());
// 构建 AlipayReqDTO 对象
AlipayPayReqDTO alipayPayReqDTO = AlipayPayReqDTO.builder()
.orderSn(orderSn)
.payAmount(orderRespDTO.getPayAmount())
.subject(subjectBuilder.toString())
.build();
Result<String> result;
try {
result = alipayFeignService.commonPay(alipayPayReqDTO);
} catch (Exception e) {
// --if-- 支付远程接口调用失败
throw new ServiceException(BaseErrorCode.REMOTE_ERROR);
}
if (result == null || !result.isSuccess()) {
throw new ServiceException("调用远程支付宝支付失败", BaseErrorCode.SERVICE_ERROR);
}
// 锁定订单,防止订单过期未支付被取消
stringRedisTemplate.opsForValue().set(String.format(RedisCacheConstant.ORDER_PAY_LOCK_KEY, orderSn), "true", 5, TimeUnit.MINUTES);
return result.getData();
}
【订单超时关闭】
注意,订单超时关闭时,首先判断订单是否处于支付锁定状态。
- 如果不处于支付锁定状态,直接取消订单
- 如果处于支付锁定状态,再发送一个十分钟的延时关闭订单消息。十分钟后支付锁定缓存已经过期了,如果此时订单还没有被支付,就会正常取消订单
@Override
@Transactional(rollbackFor = Throwable.class)
public void closeOrder(String orderSn) {
OrderDO orderDO = baseMapper.selectByOrderSn(orderSn);
String orderPayLock = stringRedisTemplate.opsForValue().get(String.format(RedisCacheConstant.ORDER_PAY_LOCK_KEY, orderSn));
if ("true".equals(orderPayLock)) {
// --if-- 订单已经被锁定,说明订单正处于支付状态
if (orderDO.getOrderStatus().equals(OrderStatusConstant.UN_PAID)) {
// --if-- 当前订单还没有支付成功,发一个延时消息,如果等等订单还没有被支付,就关闭订单
orderDelayCloseProducer.sendMessage(OrderDelayCloseMqDTO.builder()
.orderSn(orderDO.getOrderSn())
.build());
}
} else if (orderDO.getOrderStatus().equals(OrderStatusConstant.UN_PAID)) {
// --if-- 到时间了,订单还没有支付,取消该订单
orderDO.setOrderStatus(OrderStatusConstant.CANCEL);
// 分片键不能更新
orderDO.setVenueId(null);
baseMapper.updateByOrderSn(orderDO);
if (!isUseBinlog) {
// --if-- 如果不启用binlog的话,需要自己手动调用方法来释放库存
// 极端情况,如果说远程已经还原了库存,但是因为网络问题,返回了错误,导致订单没有关闭,于是出现了不一致的现象。库存都还原完了,你订单还可以支付
Result<OrderDO> result;
try {
result = timePeriodFeignService.release(TimePeriodStockRestoreReqDTO.builder()
.timePeriodId(orderDO.getTimePeriodId())
.partitionId(orderDO.getPartitionId())
.courtIndex(orderDO.getCourtIndex())
.userId(orderDO.getUserId())
.build());
} catch (Exception e) {
// --if-- 库存恢复远程接口调用失败
throw new ServiceException(BaseErrorCode.REMOTE_ERROR);
}
if (result == null || !result.isSuccess()) {
// 因为使用了Transactional,如果这里出现了异常,订单的关闭修改会回退
throw new ServiceException("调用远程服务释放时间段数据库库存失败", BaseErrorCode.SERVICE_ERROR);
}
} else {
// --if-- 如果启用binlog的话,会自动监听数据库的订单关闭,然后恢复缓存中的库存
}
}
}
上面的实现其实存在一个问题,那就是如果每次用户一支付,订单就处于支付锁定状态,延时任务无法关闭订单。只要用户每隔一段时间就发起一次支付,但不成功付款,那么该订单就永远不会支付成功,也不会超时取消,一直霸占着资源
MQ监听
【超时未支付关闭订单】
package com.vrs.rocketMq.listener;
import com.vrs.annotation.Idempotent;
import com.vrs.constant.RocketMqConstant;
import com.vrs.domain.dto.mq.OrderDelayCloseMqDTO;
import com.vrs.enums.IdempotentSceneEnum;
import com.vrs.service.OrderService;
import com.vrs.templateMethod.MessageWrapper;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.MessageModel;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.annotation.SelectorType;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
/**
* @Author dam
* @create 2024/9/20 21:30
*/
@Slf4j(topic = RocketMqConstant.ORDER_TOPIC)
@Component
@RocketMQMessageListener(topic = RocketMqConstant.ORDER_TOPIC,
consumerGroup = RocketMqConstant.ORDER_CONSUMER_GROUP + "-" + RocketMqConstant.ORDER_DELAY_CLOSE_TAG,
messageModel = MessageModel.CLUSTERING,
// 监听tag
selectorType = SelectorType.TAG,
selectorExpression = RocketMqConstant.ORDER_DELAY_CLOSE_TAG
)
@RequiredArgsConstructor
public class OrderDelayCloseListener implements RocketMQListener<MessageWrapper<OrderDelayCloseMqDTO>> {
private final OrderService orderService;
/**
* 消费消息的方法
* 方法报错就会拒收消息
*
* @param messageWrapper 消息内容,类型和上面的泛型一致。如果泛型指定了固定的类型,消息体就是我们的参数
*/
@Idempotent(
uniqueKeyPrefix = "order_delay_close:",
key = "#messageWrapper.getMessage().getOrderSn()",
scene = IdempotentSceneEnum.MQ,
keyTimeout = 3600L
)
@SneakyThrows
@Override
public void onMessage(MessageWrapper<OrderDelayCloseMqDTO> messageWrapper) {
// 开头打印日志,平常可 Debug 看任务参数,线上可报平安(比如消息是否消费,重新投递时获取参数等)
log.info("[消费者] 关闭订单:{}", messageWrapper.getMessage().getOrderSn());
String orderSn = messageWrapper.getMessage().getOrderSn();
orderService.closeOrder(orderSn);
}
}
service实现二
为了避免上述情况,经过梳理,我们需要保证如下逻辑。
- 一旦成功发起了支付,第一次延时任务到达时,不能关闭订单
- 一旦延时任务到达之后,就不能再发起支付
- 第二次延时任务进来的时候,可以顺利关闭订单
【解决方案】
如果调用订单支付时,先判断订单支付状态:
- 订单支付状态是0或不存在,就调用支付宝发起支付,发起支付之后,如果支付状态不存在,就设置为0
- 订单支付状态是1,说明订单超时了,不让发起支付宝支付
第一次延时任务到来,判断订单支付状态:
- 订单状态为0,不取消订单,将订单支付状态设置为1,并发生延时消息来触发第二次订单超时
第二次延时任务到来,如果订单还没有成功支付,直接关闭订单
是否存在一种情况:用户第二次支付,支付状态是0,成功发起了支付,但是此时第二次延时任务到来,将订单关闭了,造成用户支付成功,但是订单关闭的状态?
答:不会,假设第二次发起了支付,此时第一次延时任务执行,之后将订单支付状态设置为1,那等第二次延时任务到来的时候,第二次支付调用早就结束了,因为一次交易时间最长被设置为3分钟,如果3分钟还没有成功交易,就被关闭了,而第二次延时是5分钟之后才执行,所以此时,支付肯定结束了。
@Override
public String pay(String orderSn) {
OrderRespDTO orderRespDTO = this.getOrderRespDTOByOrderSn(orderSn);
if (orderRespDTO == null) {
// --if-- 订单不存在
throw new ClientException(BaseErrorCode.ORDER_NULL_ERROR);
}
if (orderRespDTO.getOrderStatus() == OrderStatusConstant.PAID) {
// --if-- 当前订单已经被支付
throw new ClientException(BaseErrorCode.ORDER_HAS_PAID_ERROR);
}
if (orderRespDTO.getOrderStatus() == OrderStatusConstant.CANCEL) {
// --if-- 当前订单已经取消
throw new ClientException(BaseErrorCode.ORDER_HAS_CANCELED_ERROR);
}
if (orderRespDTO.getOrderStatus() == OrderStatusConstant.REFUND) {
// --if-- 当前订单已退款
throw new ClientException(BaseErrorCode.ORDER_HAS_REFUND_ERROR);
}
String orderPayLock = stringRedisTemplate.opsForValue().get(String.format(RedisCacheConstant.ORDER_PAY_LOCK_KEY, orderSn));
if ("1".equals(orderPayLock)){
// --if-- 订单已经过期,不允许再发起支付
throw new ClientException(BaseErrorCode.ORDER_EXPIRE_ERROR);
}
// 使用 StringBuilder 进行字符串连接
StringBuilder subjectBuilder = new StringBuilder(orderRespDTO.getVenueName())
.append("_")
.append(orderRespDTO.getPartitionName())
.append(":")
.append(orderRespDTO.getPeriodDate())
.append(" ")
.append(orderRespDTO.getBeginTime())
.append("至")
.append(orderRespDTO.getEndTime());
// 构建 AlipayReqDTO 对象
AlipayPayReqDTO alipayPayReqDTO = AlipayPayReqDTO.builder()
.orderSn(orderSn)
.payAmount(orderRespDTO.getPayAmount())
.subject(subjectBuilder.toString())
.build();
Result<String> result;
try {
result = alipayFeignService.commonPay(alipayPayReqDTO);
} catch (Exception e) {
// --if-- 支付远程接口调用失败
throw new ServiceException(BaseErrorCode.REMOTE_ERROR);
}
if (result == null || !result.isSuccess()) {
throw new ServiceException("调用远程支付宝支付失败", BaseErrorCode.SERVICE_ERROR);
}
// 锁定订单,防止订单过期未支付被取消
stringRedisTemplate.opsForValue().setIfAbsent(String.format(RedisCacheConstant.ORDER_PAY_LOCK_KEY, orderSn), "0", 5, TimeUnit.MINUTES);
return result.getData();
}
第一次延时任务关闭订单
@Override
@Transactional(rollbackFor = Throwable.class)
public void closeOrder(String orderSn) {
String orderPayLock = stringRedisTemplate.opsForValue().get(String.format(RedisCacheConstant.ORDER_PAY_LOCK_KEY, orderSn));
if ("0".equals(orderPayLock)) {
OrderDO orderDO = baseMapper.selectByOrderSn(orderSn);
// --if-- 订单已经被锁定,说明订单正处于支付状态,先不要关闭订单,等等再看看是否支付成功了
if (orderDO.getOrderStatus().equals(OrderStatusConstant.UN_PAID)) {
// --if-- 当前订单还没有支付成功,发一个延时消息,如果等等订单还没有被支付,就关闭订单
orderSecondDelayCloseProducer.sendMessage(OrderDelayCloseMqDTO.builder()
.orderSn(orderDO.getOrderSn())
.build());
// 将订单支付状态设置为1,拒绝后面的支付调用
stringRedisTemplate.opsForValue().set(String.format(RedisCacheConstant.ORDER_PAY_LOCK_KEY, orderSn), "1", 5, TimeUnit.MINUTES);
}
} else {
// --if-- 订单不在支付中,直接关闭订单
secondCloseOrder(orderSn);
}
}
第二次延时任务关闭订单
@Override
public void secondCloseOrder(String orderSn) {
OrderDO orderDO = baseMapper.selectByOrderSn(orderSn);
if (orderDO.getOrderStatus().equals(OrderStatusConstant.UN_PAID)) {
// --if-- 到时间了,订单还没有支付,取消该订单
orderDO.setOrderStatus(OrderStatusConstant.CANCEL);
// 分片键不能更新
orderDO.setVenueId(null);
baseMapper.updateByOrderSn(orderDO);
if (!isUseBinlog) {
// --if-- 如果不启用binlog的话,需要自己手动调用方法来释放库存
// 极端情况,如果说远程已经还原了库存,但是因为网络问题,返回了错误,导致订单没有关闭,于是出现了不一致的现象。库存都还原完了,你订单还可以支付
Result<OrderDO> result;
try {
result = timePeriodFeignService.release(TimePeriodStockRestoreReqDTO.builder()
.timePeriodId(orderDO.getTimePeriodId())
.partitionId(orderDO.getPartitionId())
.courtIndex(orderDO.getCourtIndex())
.userId(orderDO.getUserId())
.build());
} catch (Exception e) {
// --if-- 库存恢复远程接口调用失败
throw new ServiceException(BaseErrorCode.REMOTE_ERROR);
}
if (result == null || !result.isSuccess()) {
// 因为使用了Transactional,如果这里出现了异常,订单的关闭修改会回退
throw new ServiceException("调用远程服务释放时间段数据库库存失败", BaseErrorCode.SERVICE_ERROR);
}
} else {
// --if-- 如果启用binlog的话,会自动监听数据库的订单关闭,然后恢复缓存中的库存
}
}
}
MQ监听
package com.vrs.rocketMq.listener;
import com.vrs.annotation.Idempotent;
import com.vrs.constant.RocketMqConstant;
import com.vrs.domain.dto.mq.OrderDelayCloseMqDTO;
import com.vrs.enums.IdempotentSceneEnum;
import com.vrs.service.OrderService;
import com.vrs.templateMethod.MessageWrapper;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.MessageModel;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.annotation.SelectorType;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
/**
* @Author dam
* @create 2024/9/20 21:30
*/
@Slf4j(topic = RocketMqConstant.ORDER_TOPIC)
@Component
@RocketMQMessageListener(topic = RocketMqConstant.ORDER_TOPIC,
consumerGroup = RocketMqConstant.ORDER_CONSUMER_GROUP + "-" + RocketMqConstant.ORDER_SECOND_DELAY_CLOSE_TAG,
messageModel = MessageModel.CLUSTERING,
// 监听tag
selectorType = SelectorType.TAG,
selectorExpression = RocketMqConstant.ORDER_SECOND_DELAY_CLOSE_TAG
)
@RequiredArgsConstructor
public class OrderSecondDelayCloseListener implements RocketMQListener<MessageWrapper<OrderDelayCloseMqDTO>> {
private final OrderService orderService;
/**
* 消费消息的方法
* 方法报错就会拒收消息
*
* @param messageWrapper 消息内容,类型和上面的泛型一致。如果泛型指定了固定的类型,消息体就是我们的参数
*/
@Idempotent(
uniqueKeyPrefix = "order_second_delay_close:",
key = "#messageWrapper.getMessage().getOrderSn()",
scene = IdempotentSceneEnum.MQ,
keyTimeout = 3600L
)
@SneakyThrows
@Override
public void onMessage(MessageWrapper<OrderDelayCloseMqDTO> messageWrapper) {
// 开头打印日志,平常可 Debug 看任务参数,线上可报平安(比如消息是否消费,重新投递时获取参数等)
log.info("[消费者] 关闭订单:{}", messageWrapper.getMessage().getOrderSn());
String orderSn = messageWrapper.getMessage().getOrderSn();
orderService.secondCloseOrder(orderSn);
}
}
支付回调
支付成功之后,支付宝需要异步通知我们支付结果,我们需要准备好一个接口来接收支付宝的回调信息。相较于同步通知,异步回调显著提升了支付系统的用户体验和可靠性。它允许用户迅速返回商家网站,避免长时间等待;同时,通过多次尝试发送通知,确保了即使在网络不稳定的情况下也能准确确认支付状态。异步机制还防止了重复支付,提高了系统的并发处理能力和灵活性,并增强了安全性与错误处理能力,使得支付流程更加流畅、高效和安全。
内网穿透
简介
内网穿透是指通过特定的技术手段,使得位于私有网络(如家庭或企业内部的局域网)中的设备能够被互联网上的其他设备访问。因为我们使用的开发机没有公网ip,支付宝无法访问我们的回调接口,因此我们需要使用内网穿透技术让回调接口可以被外网访问。
natapp实现内网穿透
natapp官网:natapp.cn/article/nat…
首先需要注册,登录,实名验证
然后购买隧道
购买一个免费的隧道即可
购买隧道之后,安装客户端,根据自己的系统来下载对应的版本
下载config.ini文件(下载地址:natapp.cn/article/con…
然后修改config.ini里面的authtoken,设置为隧道对应的authtoken(寻找方式如下图所示)
之后双击exe文件启动即可,注意,后面就是使用下图的内网穿透地址来让支付宝回调我们的接口。每次重新启动这个软件,穿透地址会不一样,注意及时替换地址
实体类
支付宝回调接口时携带数据如下:
{
"gmt_create": "2024-12-31 16:09:22",
"charset": "UTF-8",
"seller_email": "omldhw2845@sandbox.com",
"subject": "光明体育馆_篮球A区:2025-01-01 09:00到10:00",
"sign": "raBvcInPKPrL8Dl335wBxMpt8m2Kz8h8jjfTHR0x27p6zC5lrTTZBFi3yZSKOTHImI1ZLODvyaWgtP9B04zwJEuKtIoMMp130gdDHG+g/RZgeFYbyHXSMh8mMHCcONwT2w5a/UBatTcQuW19YB5h8aEbHLqYsiq5DDz6XJKWl5VDDhNmzqU+aNV/xmgN3OH3mkVY/QCe10PJHcb1RYlUUCxTU/iuKkIQxdkCEskuwFOC90dKGzAejvN5rOD9sZ9TofH8UWwBfx8N7o2e998QbUoTnTzOyzrlLP6gCPIochSWAWewZ2wJ451UuHWkAFL6XZJ7fQrewgK1fu4yy3aT4w==",
"buyer_id": "2088722053898514",
"invoice_amount": "50.00",
"notify_id": "2024123101222160924098510504508245",
"fund_bill_list": "[{\"amount\":\"50.00\",\"fundChannel\":\"ALIPAYACCOUNT\"}]",
"notify_type": "trade_status_sync",
"trade_status": "TRADE_SUCCESS",
"receipt_amount": "50.00",
"buyer_pay_amount": "50.00",
"app_id": "2021000143601715",
"sign_type": "RSA2",
"seller_id": "2088721053898502",
"gmt_payment": "2024-12-31 16:09:23",
"notify_time": "2024-12-31 16:09:24",
"version": "1.0",
"out_trade_no": "1874004712053325824850432",
"total_amount": "50.00",
"trade_no": "2024123122001498510504462937",
"auth_app_id": "2021000143601715",
"buyer_logon_id": "eseujj2902@sandbox.com",
"point_amount": "0.00"
}
使用一个实体类来接收关键数据
package com.vrs.common.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* @Author dam
* @create 2024/12/31 16:13
*/
@Data
public class PayCallbackDTO {
/**
* 支付状态
*/
@JsonAlias("trade_status")
private String tradeStatus;
/**
* 支付凭证号
*/
@JsonAlias("trade_no")
private String tradeNo;
/**
* 买家付款时间
*/
@JsonAlias("gmt_payment")
private Date gmtPayment;
/**
* 买家付款金额
*/
@JsonAlias("buyer_pay_amount")
private BigDecimal buyerPayAmount;
/**
* 商户订单号
* 由商家自定义,64个字符以内,仅支持字母、数字、下划线且需保证在商户端不重复
*/
@JsonAlias("out_trade_no")
private String outTradeNo;
/**
* 订单总金额
* 单位为元,精确到小数点后两位,取值范围:[0.01,100000000]
*/
private BigDecimal totalAmount;
/**
* 订单标题
* 注意:不可使用特殊字符,如 /,=,& 等
*/
private String subject;
}
支付服务
controller
提供一个回调接口给支付宝调用
package com.vrs.controller;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.bean.copier.CopyOptions;
import com.vrs.common.dto.PayCallbackDTO;
import com.vrs.service.AlipayService;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 支付结果回调
*
* @Author dam
* @create 2024/12/31 15:41
*/
@RestController
@RequiredArgsConstructor
@Tag(name = "支付相关")
public class PayCallbackController {
private final AlipayService alipayService;
/**
* 支付宝回调
* 调用支付宝支付后,支付宝会调用此接口发送支付结果
*/
// todo 如何校验回调接口的调用是否为支付宝调用
@PostMapping("/api/pay-service/callback/alipay")
public void callbackAlipay(@RequestParam Map<String, Object> requestParam) {
PayCallbackDTO payCallbackDTO = BeanUtil.mapToBean(requestParam, PayCallbackDTO.class, true, CopyOptions.create());
alipayService.callback(payCallbackDTO);
}
}
注意,网关登录验证不要拦截回调接口,否则支付成功之后回调不会成功。当然更合理的做法是,校验是否为支付宝进行回调,否则可能会被恶意利用。试想一下,没有身份验证,别人也能直接调用这个接口呀,那他直接调用回调接口就行了,不用付钱也能修改订单状态。
这里留个坑,后面优化一下
service
如果支付成功,修改支付状态和订单状态。
/**
* 支付之后的回调方法
*
* @param payCallbackDTO
*/
@Override
public void callback(PayCallbackDTO payCallbackDTO) {
if (payCallbackDTO.getTradeStatus().equals("TRADE_SUCCESS")) {
// --if-- 支付成功
QueryWrapper<PayDO> payDOQueryWrapper = new QueryWrapper<>();
payDOQueryWrapper.eq("order_sn", payCallbackDTO.getOutTradeNo());
payService.update(PayDO.builder()
.payAmount(payCallbackDTO.getBuyerPayAmount())
.payTime(payCallbackDTO.getGmtPayment())
.transactionId(payCallbackDTO.getTradeNo())
.build(), payDOQueryWrapper);
// 发送消息,通知订单服务,修改订单状态为已支付状态
orderPayProducer.sendMessage(OrderPayMqDTO.builder()
.orderSn(payCallbackDTO.getOutTradeNo())
.build());
}
}
订单服务
MQ监听
【修改订单状态为已支付】
package com.vrs.rocketMq.listener;
import com.vrs.annotation.Idempotent;
import com.vrs.constant.RocketMqConstant;
import com.vrs.domain.dto.mq.OrderPayMqDTO;
import com.vrs.enums.IdempotentSceneEnum;
import com.vrs.service.OrderService;
import com.vrs.templateMethod.MessageWrapper;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.MessageModel;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.annotation.SelectorType;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
/**
* @Author dam
* @create 2024/9/20 21:30
*/
@Slf4j(topic = RocketMqConstant.ORDER_TOPIC)
@Component
@RocketMQMessageListener(topic = RocketMqConstant.ORDER_TOPIC,
consumerGroup = RocketMqConstant.ORDER_CONSUMER_GROUP + "-" + RocketMqConstant.ORDER_PAY_TAG,
messageModel = MessageModel.CLUSTERING,
// 监听tag
selectorType = SelectorType.TAG,
selectorExpression = RocketMqConstant.ORDER_PAY_TAG
)
@RequiredArgsConstructor
public class OrderPayListener implements RocketMQListener<MessageWrapper<OrderPayMqDTO>> {
private final OrderService orderService;
/**
* 消费消息的方法
* 方法报错就会拒收消息
*
* @param messageWrapper 消息内容,类型和上面的泛型一致。如果泛型指定了固定的类型,消息体就是我们的参数
*/
@Idempotent(
uniqueKeyPrefix = "order_pay:",
key = "#messageWrapper.getMessage().getOrderSn()",
scene = IdempotentSceneEnum.MQ,
keyTimeout = 3600L
)
@SneakyThrows
@Override
public void onMessage(MessageWrapper<OrderPayMqDTO> messageWrapper) {
// 开头打印日志,平常可 Debug 看任务参数,线上可报平安(比如消息是否消费,重新投递时获取参数等)
log.info("[消费者] 修改订单为已支付状态:{}", messageWrapper.getMessage().getOrderSn());
String orderSn = messageWrapper.getMessage().getOrderSn();
orderService.payOrder(orderSn);
}
}
service
【订单成功支付】
订单支付成功之后,修改订单状态,并删除支付锁标识
@Override
public void payOrder(String orderSn) {
// 修改订单状态为已支付状态
baseMapper.updateStatusByOrderSn(orderSn, OrderStatusConstant.PAID);
// 删除订单支付锁定标识
stringRedisTemplate.delete(String.format(RedisCacheConstant.ORDER_PAY_LOCK_KEY, orderSn));
}
退款
支付服务
controller
package com.vrs.controller;
import com.vrs.convention.result.Result;
import com.vrs.convention.result.Results;
import com.vrs.domain.dto.req.AlipayRefundReqDTO;
import com.vrs.domain.dto.resp.AlipayRefundRespDTO;
import com.vrs.service.AlipayService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @Author dam
* @create 2024/12/31 14:06
*/
@RestController
@RequiredArgsConstructor
@Tag(name = "支付相关")
@RequestMapping("/refund/")
public class RefundController {
private final AlipayService alipayService;
/**
* 调用支付宝进行退款
*
* @param alipayRefundReqDTO
*/
@PostMapping("/v1/alipay/commonRefund")
@Operation(summary = "普通退款")
public Result<AlipayRefundRespDTO> commonRefund(@RequestBody AlipayRefundReqDTO alipayRefundReqDTO) {
AlipayRefundRespDTO refundRespDTO = alipayService.commonRefund(alipayRefundReqDTO);
return Results.success(refundRespDTO);
}
}
service
@SneakyThrows
@Override
public AlipayRefundRespDTO commonRefund(AlipayRefundReqDTO alipayRefundReqDTO) {
// 初始化SDK
AlipayClient alipayClient = Singleton.get("alipayClient", () -> {
return new DefaultAlipayClient(
alipayConfig.getGatewayUrl(),
alipayConfig.getAppId(),
alipayConfig.getPrivateKey(),
alipayConfig.getFormat(),
alipayConfig.getCharset(),
alipayConfig.getAlipayPublicKey(),
alipayConfig.getSignType());
});
QueryWrapper<PayDO> payDOQueryWrapper = new QueryWrapper<>();
payDOQueryWrapper.eq("order_sn", alipayRefundReqDTO.getOrderSn());
PayDO payDO = payService.getOne(payDOQueryWrapper);
if (payDO == null) {
// --if-- 订单未支付
throw new ClientException(BaseErrorCode.ORDER_NOT_PAID_ERROR);
}
// 构造请求参数以调用接口
AlipayTradeRefundRequest request = new AlipayTradeRefundRequest();
AlipayTradeRefundModel model = new AlipayTradeRefundModel();
// 设置支付宝交易号
// model.setOutTradeNo(alipayRefundReqDTO.getOrderSn());
model.setTradeNo(payDO.getTransactionId());
// 设置退款金额
model.setRefundAmount(alipayRefundReqDTO.getRefundAmount().toString());
// 设置退款原因说明
model.setRefundReason("正常退款");
// 退款请求号(如果分多笔退款,必须设置改参数,且同一订单的一致)
model.setOutRequestNo(alipayRefundReqDTO.getOrderSn());
request.setBizModel(model);
AlipayTradeRefundResponse response = alipayClient.execute(request);
System.out.println(response.getBody());
if (response.isSuccess()) {
System.out.println("退款成功");
payService.update(PayDO.builder()
.refundAmount(alipayRefundReqDTO.getRefundAmount())
.payTime(response.getGmtRefundPay())
.refundStatus(RefundTypeConstant.FULL_REFUND)
.build(), payDOQueryWrapper);
// 发送消息,通知订单服务,修改订单状态为已退款状态
orderRefundProducer.sendMessage(OrderRefundMqDTO.builder()
.orderSn(alipayRefundReqDTO.getOrderSn())
.build());
} else {
System.out.println("退款失败");
// sdk版本是"4.38.0.ALL"及以上,可以参考下面的示例获取诊断链接
String diagnosisUrl = DiagnosisUtils.getDiagnosisUrl(response);
System.out.println(diagnosisUrl);
}
AlipayRefundRespDTO refundRespDTO = new AlipayRefundRespDTO();
BeanUtils.copyProperties(response, refundRespDTO);
refundRespDTO.setSuccess(response.getCode().equals("10000") && response.getFundChange().equals("Y"));
refundRespDTO.setRefundFee(new BigDecimal(response.getRefundFee()));
return refundRespDTO;
}
订单服务
controller
/**
* 订单退款
*
* @param orderSn
* @return
*/
@GetMapping("/refund")
@Operation(summary = "订单退款")
public Result refund(@RequestParam("orderSn") String orderSn) {
AlipayRefundRespDTO refundRespDTO = orderService.refund(orderSn);
if (refundRespDTO.isSuccess()) {
return Results.success();
} else {
return Results.failure(refundRespDTO.getCode(), "退款失败:" + refundRespDTO.getMsg() + "_" + refundRespDTO.getSubMsg());
}
}
service
@Override
public AlipayRefundRespDTO refund(String orderSn) {
//// 退款条件校验
// todo 临近开场,不能退款
OrderRespDTO orderRespDTO = this.getOrderRespDTOByOrderSn(orderSn);
if (orderRespDTO == null) {
// --if-- 订单不存在
throw new ClientException(BaseErrorCode.ORDER_NULL_ERROR);
}
if (orderRespDTO.getOrderStatus() != OrderStatusConstant.PAID) {
// --if-- 当前订单还没有被支付
throw new ClientException(BaseErrorCode.ORDER_NOT_PAID_ERROR);
}
// 构建 AlipayReqDTO 对象
AlipayRefundReqDTO alipayRefundReqDTO = AlipayRefundReqDTO.builder()
.orderSn(orderSn)
.refundAmount(orderRespDTO.getPayAmount())
.build();
Result<AlipayRefundRespDTO> result;
try {
result = alipayFeignService.commonRefund(alipayRefundReqDTO);
} catch (Exception e) {
// --if-- 支付远程接口调用失败
throw new ServiceException(BaseErrorCode.REMOTE_ERROR);
}
if (result == null || !result.isSuccess()) {
throw new ServiceException("调用远程支付宝退款失败", BaseErrorCode.SERVICE_ERROR);
}
return result.getData();
}
将订单设置为已退款状态
@Override
public void refundOrder(String orderSn) {
// 修改订单状态为已退款状态
baseMapper.updateStatusByOrderSn(orderSn, OrderStatusConstant.REFUND);
}
MQ监听
package com.vrs.rocketMq.listener;
import com.vrs.annotation.Idempotent;
import com.vrs.constant.RocketMqConstant;
import com.vrs.domain.dto.mq.OrderRefundMqDTO;
import com.vrs.enums.IdempotentSceneEnum;
import com.vrs.service.OrderService;
import com.vrs.templateMethod.MessageWrapper;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.MessageModel;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.annotation.SelectorType;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
/**
* @Author dam
* @create 2024/9/20 21:30
*/
@Slf4j(topic = RocketMqConstant.ORDER_TOPIC)
@Component
@RocketMQMessageListener(topic = RocketMqConstant.ORDER_TOPIC,
consumerGroup = RocketMqConstant.ORDER_CONSUMER_GROUP + "-" + RocketMqConstant.ORDER_REFUND_TAG,
messageModel = MessageModel.CLUSTERING,
// 监听tag
selectorType = SelectorType.TAG,
selectorExpression = RocketMqConstant.ORDER_REFUND_TAG
)
@RequiredArgsConstructor
public class OrderRefundListener implements RocketMQListener<MessageWrapper<OrderRefundMqDTO>> {
private final OrderService orderService;
/**
* 消费消息的方法
* 方法报错就会拒收消息
*
* @param messageWrapper 消息内容,类型和上面的泛型一致。如果泛型指定了固定的类型,消息体就是我们的参数
*/
@Idempotent(
uniqueKeyPrefix = "order_refund:",
key = "#messageWrapper.getMessage().getOrderSn()",
scene = IdempotentSceneEnum.MQ,
keyTimeout = 3600L
)
@SneakyThrows
@Override
public void onMessage(MessageWrapper<OrderRefundMqDTO> messageWrapper) {
// 开头打印日志,平常可 Debug 看任务参数,线上可报平安(比如消息是否消费,重新投递时获取参数等)
log.info("[消费者] 修改订单为已退款状态:{}", messageWrapper.getMessage().getOrderSn());
String orderSn = messageWrapper.getMessage().getOrderSn();
orderService.refundOrder(orderSn);
}
}
交易查询
实体类
package com.vrs.domain.dto.resp;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.Date;
/**
* @Author dam
* @create 2024/12/31 14:38
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class AlipayInfoRespDTO {
/**
* 卖家账号
*/
private String buyerLogonId;
/**
* 交易状态
*/
private String tradeStatus;
/**
* 状态码
*/
private String code;
/**
* 消息
*/
private String msg;
/**
* 订单号
*/
private String orderSn;
/**
* 支付方式,0:信用卡、1:支付宝、2:微信
*/
private Integer paymentMethod;
/**
* 订单标题
*/
private String subject;
/**
* 交易编号
*/
private String transactionId;
/**
* 支付时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date payTime;
/**
* 支付金额
*/
private BigDecimal payAmount;
/**
* 退款状态 0: 未退款 1: 部分退款 2: 全额退款
*/
private Integer refundStatus;
/**
* 退款金额
*/
private BigDecimal refundAmount;
/**
* 退款时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date refundTime;
}
支付服务
controller
/**
* 查询支付宝交易详情
* @param alipayInfoReqDTO
* @return 支付地址
*/
@PostMapping("/v1/alipay/info")
@Operation(summary = "交易查询")
public Result<AlipayInfoRespDTO> info(@RequestBody AlipayInfoReqDTO alipayInfoReqDTO) {
return Results.success(alipayService.info(alipayInfoReqDTO));
}
service
@SneakyThrows
@Override
public AlipayInfoRespDTO info(AlipayInfoReqDTO alipayInfoReqDTO) {
PayDO payDO = payService.getOne(Wrappers.lambdaQuery(PayDO.class).eq(PayDO::getOrderSn, alipayInfoReqDTO.getOrderSn()));
if (payDO == null) {
// --if-- 订单还未交易
throw new ClientException(BaseErrorCode.ORDER_NOT_TRANSACTION_ERROR);
}
// 初始化SDK
AlipayClient alipayClient = Singleton.get("alipayClient", () -> {
return new DefaultAlipayClient(
alipayConfig.getGatewayUrl(),
alipayConfig.getAppId(),
alipayConfig.getPrivateKey(),
alipayConfig.getFormat(),
alipayConfig.getCharset(),
alipayConfig.getAlipayPublicKey(),
alipayConfig.getSignType());
});
// 构造请求参数以调用接口
AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
AlipayTradeQueryModel model = new AlipayTradeQueryModel();
// 设置订单支付时传入的商户订单号
model.setOutTradeNo(alipayInfoReqDTO.getOrderSn());
// 设置支付宝交易号
// model.setTradeNo("2025010222001498510504477448");
// 设置查询选项
List<String> queryOptions = new ArrayList<String>();
queryOptions.add("trade_settle_info");
model.setQueryOptions(queryOptions);
request.setBizModel(model);
AlipayTradeQueryResponse response = alipayClient.execute(request);
// System.out.println(response.getBody());
if (response.isSuccess()) {
System.out.println("调用成功");
} else {
System.out.println("调用失败");
// sdk版本是"4.38.0.ALL"及以上,可以参考下面的示例获取诊断链接
String diagnosisUrl = DiagnosisUtils.getDiagnosisUrl(response);
System.out.println(diagnosisUrl);
}
AlipayInfoRespDTO alipayInfoRespDTO = new AlipayInfoRespDTO();
BeanUtils.copyProperties(response, alipayInfoRespDTO);
BeanUtils.copyProperties(payDO, alipayInfoRespDTO);
return alipayInfoRespDTO;
}
订单服务
controller
/**
* 交易详情
*
* @param orderSn
* @return
*/
@GetMapping("/info")
@Operation(summary = "查看交易详情")
public Result info(@RequestParam("orderSn") String orderSn) {
return Results.success(orderService.info(orderSn));
}
service
@Override
public AlipayInfoRespDTO info(String orderSn) {
OrderRespDTO orderRespDTO = this.getOrderRespDTOByOrderSn(orderSn);
if (orderRespDTO == null) {
// --if-- 订单不存在
throw new ClientException(BaseErrorCode.ORDER_NULL_ERROR);
}
// 构建 AlipayReqDTO 对象
AlipayInfoReqDTO alipayInfoReqDTO = AlipayInfoReqDTO.builder()
.orderSn(orderSn)
.build();
Result<AlipayInfoRespDTO> result;
try {
result = alipayFeignService.info(alipayInfoReqDTO);
} catch (Exception e) {
// --if-- 支付远程接口调用失败
throw new ServiceException(BaseErrorCode.REMOTE_ERROR);
}
if (result == null || !result.isSuccess()) {
throw new ServiceException("调用远程支付宝交易查询失败", BaseErrorCode.SERVICE_ERROR);
}
AlipayInfoRespDTO alipayInfoRespDTO = result.getData();
return alipayInfoRespDTO;
}
调用失败说明
最近沙箱支付不太稳定,存在限流,有时候支付会失败,需要多刷新几次。退款最近也非常不稳定,可能出现退款失败的情况