Apong's Blog

当你快坚持不住的时候,困难也快坚持不住了

0%

SpringBoot集成沙箱支付——不墨迹版

SpringBoot集成沙箱支付——不墨迹版

一、获取沙箱配置信息

先进入支付宝的个人沙箱应用页面 https://openhome.alipay.com/develop/sandbox/app

image.png

image-20240419143841824

图中 黑色框框 圈出来的我们需要的四个配置信息。

以下步骤省略创建 Spring Boot 项目过程。

教程采用版本:

  • spring boot 2.6.13
  • java 8

二、在 pom.xml 中引入支付宝SDK依赖

1
2
3
4
5
6
<!-- alipay -->
<dependency>
<groupId>com.alipay.sdk</groupId>
<artifactId>alipay-sdk-java</artifactId>
<version>4.34.0.ALL</version>
</dependency>

经监测,开源的Java开发组件Fastjson存在远程代码执行漏洞,攻击者可利用上述漏洞远程执行任意代码。Java SDK(alipay-sdk-java)在4.34.0版本之前使用了存在漏洞的Fastjson版本(详情可查看 关于Fastjson漏洞预警的公告)。

建议将上述SDK升级至 4.34.0 及以上版本

——摘自官方文档

三、往 application.yml 中写入第一步获取到的配置信息

1
2
3
4
5
6
# 支付宝沙箱
myalipay:
gateway: # 支付宝网关地址
appId: # 填入APPID
appPrivateKey: # 填入Java应用私钥
alipayPublicKey: # 填入应用公钥

四、编写 AlipayConfig 沙箱支付配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package com.example.alipaysandboxdemo.config;

import com.alipay.api.DefaultAlipayClient;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* 沙箱支付配置
*/
@Data
@ConfigurationProperties("myalipay") // 第三步中yml配置的前缀
@Configuration
public class AlipayConfig {
/**
* 沙箱支付网关
*/
private String gateway;
/**
* 应用Id
*/
private String appId;
/**
* 应用私钥
*/
private String appPrivateKey;
/**
* 支付宝公钥
*/
private String alipayPublicKey;

/**
* 参数返回格式
*/
public static final String FORMAT = "JSON";
/**
* 编码方式
*/
public static final String CHARSET = "UTF-8";
/**
* 签名方式
*/
public static final String SIGN_TYPE = "RSA2";

@Bean
public DefaultAlipayClient defaultAlipayClient() {
return new DefaultAlipayClient(
gateway,
appId,
appPrivateKey,
FORMAT,
CHARSET,
alipayPublicKey,
SIGN_TYPE
);
}

}

从上述代码可见,向 AlipayConfig 类注入了第三步中写入的配置信息,并创建了一个 DefaultAlipayClient 的 Bean,后续沙箱支付的操作基本都使用 DefaultAlipayClient 完成。

五、编写沙箱支付相关接口

在编写接口请求之前,需要知道一件事:

在服务器通过沙箱支付成功后,支付宝会发出一个携带本次支付相关信息参数的请求,通知该服务器

服务器:这里指本地的 Springboot 后端程序

既然是请求,就需要给它提供一个接口访问,让它能够把本次支付信息传过来。

问题是:支付宝发出的请求只能访问外网的地址,而在本地的 tomcat 服务器属于内网。

因此需要通过使用 内网穿透 工具获取一个临时的公网域名,让支付宝能够正常访问到。

内网穿透

这里使用 netapp 搭建

image-20240419154343084

netapp 官方教程:https://natapp.cn/article/natapp_newbie

==注意:隧道对应的本地端口应改为自己的 Spring Boot 项目启动端口==

image-20240419154814353

如果忘记改了,也可以自己在 “我的隧道” 那里配置刚才创建的隧道

运行成功后,得到如下界面:

image-20240419160102998

==注意:圈出来的是临时域名,每次重新运行都会更改,应该保证代码里写的是最新的域名==

编写 AlipayController 类

为了省事,业务逻辑全部写在 Controller 中了,读者可自行分层封装,降低代码耦合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
package com.example.alipaysandboxdemo.controller;

import com.alibaba.fastjson.JSONObject;
import com.alipay.api.AlipayApiException;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.internal.util.AlipaySignature;
import com.alipay.api.request.AlipayTradePagePayRequest;
import com.alipay.api.request.AlipayTradeRefundRequest;
import com.alipay.api.response.AlipayTradeRefundResponse;
import com.example.alipaysandboxdemo.config.AlipayConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@RequestMapping("/alipay")
@RestController
public class AlipayController {
/**
* 沙箱支付配置
*/
@Resource
private AlipayConfig alipayConfig;

/**
* 沙箱支付代理
*/
@Resource
private DefaultAlipayClient defaultAlipayClient;

/**
* 支付成功通知地址
* todo: 确保更改为最新域名
*/
private static final String NOTIFY_PATH = "http://eiabc3.natappfree.cc" + "/alipay/notify";

/**
* 支付
*
* @param tradeNo 交易单号
* @param amount 商品名称
* @return
*/
@GetMapping("/pay")
public String alipay(String tradeNo, Double amount) {
// 封装支付请求体
AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
// json请求体
JSONObject bizContent = new JSONObject();
// 交易单号
bizContent.put("out_trade_no", tradeNo);
// 商品名称
bizContent.put("subject", "遥遥领先 华为meta60");
// 交易金额
bizContent.put("total_amount", amount);
// 沙箱支付环境唯一配置
bizContent.put("product_code", "FAST_INSTANT_TRADE_PAY");
// 设置支付宝通知地址
request.setNotifyUrl(NOTIFY_PATH);
request.setBizContent(bizContent.toString());
// 支付
String formPage;
try {
formPage = defaultAlipayClient.pageExecute(request).getBody();
} catch (AlipayApiException e) {
throw new RuntimeException("订单支付异常:" + tradeNo, e);
}
// 渲染页面
return formPage;
}

/**
* 支付成功通知接口
*
* @param request
*/
@PostMapping("/notify")
public void notify(HttpServletRequest request) throws AlipayApiException {
// 除了以下三个参数外,还有其他参数,可自行debug查看
String tradeStatus = request.getParameter("trade_status");
String tradeNo = request.getParameter("out_trade_no");
Double amount = Double.valueOf(request.getParameter("total_amount"));
if (!"TRADE_SUCCESS".equals(tradeStatus)) {
System.out.println("订单支付失败:" + tradeNo);
}
// 验签
Map<String, String> params = new HashMap<>();
for (String name : request.getParameterMap().keySet()) {
params.put(name, request.getParameter(name));
}
String content = AlipaySignature.getSignCheckContentV1(params);
String alipayPublicKey = alipayConfig.getAlipayPublicKey();
String sign = request.getParameter("sign");
boolean check = AlipaySignature.rsa256CheckContent(content, sign, alipayPublicKey, AlipayConfig.CHARSET);
if (!check) {
System.out.println("订单验签异常:" + tradeNo);
}
// 验签成功后,保存订单...
System.out.println("订单支付成功:" + tradeNo);
}

/**
* 退款
*
* @param tradeNo 交易单号
* @param amount 商品名称
* @return
*/
@GetMapping("/refund")
public void refund(String tradeNo, Double amount) throws AlipayApiException {
// 封装退款请求体
AlipayTradeRefundRequest request = new AlipayTradeRefundRequest();
JSONObject bizContent = new JSONObject();
bizContent.put("out_trade_no", tradeNo);
bizContent.put("refund_amount", amount);
request.setBizContent(bizContent.toString());
// 退款
AlipayTradeRefundResponse response = defaultAlipayClient.execute(request);;
if (!response.isSuccess()) {
System.out.println("订单退款失败:" + tradeNo);
}
// 保存退款信息...
System.out.println("订单退款成功:" + tradeNo);
}
}

前后端分离

/alipay/pay 支付接口返回的 formPage 实际上是一个 HTML片段,支付宝将我们发出的支付请求参数封装成了一个 form表单 并通过 script脚本 立即执行,用于跳转支付宝支付页面(需要联网)。

这里由于标注了 @RestController 注解,所以直接访问该接口直接渲染成一个页面。

如果你的项目是前后端分离,可以采用如下操作:

  1. 将返回的 formPage 字符串数据插入到页面中,并切割掉 script脚本 手动执行。

    <script>form[0].submit()</script>

    因为 script脚本 默认执行页面中的第一个表单。

  2. 搭建 iframe 容器,插入 formPage。

六、测试

测试支付接口

这里的账号和密码是沙箱环境中的 沙箱账号

http://localhost:8101/alipay/pay?tradeNo=1772919741251236385&amount=888

image-20240419163200727

image-20240419164512715

支付完成后,支付宝发出通知到指定地址:

image-20240419164607599

测试退款接口

将刚刚支付的订单退款:

http://localhost:8101/alipay/refund?tradeNo=1772919741251236385&amount=888

image-20240419164750328