Spring Boot整合支付宝沙箱支付

1,682 阅读9分钟

gitee链接

Spring Boot版本:2.3.4.RELEASE

支付宝的支付能力有多种,常用的有当面付、App支付、手机网站支付和电脑网站支付等,关于支付能力的说明介绍可以看支付能力概览

本文以支付宝沙箱支付电脑网站支付为示例,就是在网页中点击支付,然后跳转到支付页面。

支付宝Maven依赖

新建Spring Boot项目,添加支付宝的Maven依赖:

<!--支付宝支付-->
<dependency>
    <groupId>com.alipay.sdk</groupId>
    <artifactId>alipay-sdk-java</artifactId>
    <version>4.19.10.ALL</version>
</dependency>

依赖一直在更新,可以在 支付宝Maven项目依赖 获取最新的版本。

应用信息

要使用支付宝的支付功能首先要创建一个应用,登录支付宝开发平台的控制台,找到开发服务中的研发服务,这里就是沙箱支付模块。

沙箱已经提供了APPID、支付宝网关等信息给我们,其中我们需要自行配置RSA2密钥

RSA2密钥

支付宝有提供在线生成密钥功能,十分方便,点击生成,保存私钥公钥到本地即可。

别忘了将生成的公钥配置上,将应用的公钥拷贝进去,会生成支付宝公钥,我们要的就是这个支付宝公钥,这点要注意。

钱包

支付宝提供了沙箱支付的钱包应用下载,仅提供Android版本。

打开这个钱包应用,输入右侧的沙箱账号登录,不是自己真正的支付宝账号哦!

配置文件

在项目中将我们目前有的信息放到配置文件中:

application.yml:

server:
  port: 8888

# 支付宝沙箱支付配置
alipay:
  # 应用Id
  appId: 
  # 支付宝私钥公钥
  privateKey:
  publicKey:
  # 支付完成后同步跳转的路径
#  returnUrl: http://IP:端口/success.html
  # 支付完成后异步调用的地址,返回结果中,确认收款成功的key值是 trade_status
#  notifyUrl:
  # 签名方式
  signType: RSA2
  # 编码格式
  charset: utf-8
  # 支付宝网关
  gatewayUrl: https://openapi.alipaydev.com/gateway.do

再次说明下,配置文件的publicKey,是在配置RSA2密钥的时候,通过在线生成密钥生成的公钥再次生成的支付宝公钥,这两个公钥别搞错了。

实战示例

我们需要创建以下的类:

  • alipay
    • AlipayOrder:alipay的订单实体类,字段是根据接口文档创建的
    • AlipayProperties:alipay配置信息类
    • AlipayService:alipay支付调用类
  • config
    • GlobalCorsConfig:跨域设置,因为等会要写一个html来测试
  • controller
    • PayController:支付接口
  • model
    • OrderVo:我们自己的订单业务实体类,这是为了更好的模拟实际场景

如果报错误:支付存在钓鱼风险!防钓鱼网站的方法,关掉支付宝开发中心相关的页面或者换浏览器就可以。

AlipayOrder

package com.cc.alipay;

/**
 * alipay的订单实体类,根据接口文档,字段要用下划线
 *
 * @author cc
 * @date 2021-12-01 15:31
 */
public class AlipayOrder {
    /**
     * 用户订单号,必填
     */
    private String out_trade_no;

    /**
     * 订单名称,必填
     */
    private String subject;

    /**
     * 订单总金额,必填
     */
    private String total_amount;

    /**
     * 销售产品码,必填,注:目前电脑支付场景下仅支持FAST_INSTANT_TRADE_PAY,所以可以写死
     */
    private final String product_code = "FAST_INSTANT_TRADE_PAY";

    ...
}

AlipayProperties

package com.cc.alipay;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * 支付宝配置信息类
 * @author cc
 * @date 2021-12-01 15:51
 */
@Component
@ConfigurationProperties(prefix = "alipay")
public class AlipayProperties {
    private String appId;

    private String privateKey;

    private String publicKey;

    private String returnUrl;

    private String notifyUrl;

    private String signType;

    private String charset;

    private String gatewayUrl;

    ...
}

AlipayService

package com.cc.alipay;

import com.alibaba.fastjson.JSON;
import com.alipay.api.AlipayApiException;
import com.alipay.api.AlipayClient;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.request.AlipayTradePagePayRequest;
import org.springframework.stereotype.Component;

/**
 * 支付宝调用服务
 * @author cc
 * @date 2021-12-01 15:53
 */
@Component
public class AlipayService {
    private final AlipayProperties alipayProperties;

    public AlipayService(AlipayProperties alipayProperties) {
        this.alipayProperties = alipayProperties;
    }

    /**
     * 支付接口
     * @author cc
     * @date 2021-12-01 15:54
     */
    public String pay(AlipayOrder order) throws AlipayApiException {
        // 支付宝网关
        String serverUrl = alipayProperties.getGatewayUrl();
        // APPID
        String appId = alipayProperties.getAppId();
        // 私钥
        String privateKey = alipayProperties.getPrivateKey();
        // 格式化为 json 格式
        String format = "json";
        // 字符编码格式
        String charset = alipayProperties.getCharset();
        // 公钥,对应APPID的那个
        String alipayPublicKey = alipayProperties.getPublicKey();
        // 签名方式
        String signType = alipayProperties.getSignType();
        // 页面跳转同步通知页面路径
        String returnUrl = alipayProperties.getReturnUrl();
        // 服务器异步通知页面路径
        String notifyUrl = alipayProperties.getNotifyUrl();

        // 1. 初始化client
        AlipayClient client = new DefaultAlipayClient(serverUrl, appId, privateKey, format, charset, alipayPublicKey, signType);

        // 2. 设置请求参数
        AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
        request.setReturnUrl(returnUrl);
        request.setNotifyUrl(notifyUrl);
        System.out.println("JSON.toJSONString(order):" + JSON.toJSONString(order));
        request.setBizContent(JSON.toJSONString(order));

        // 3. 调用支付并获取支付结果
        String result = client.pageExecute(request).getBody();
        return result;
    }
}

GlobalCorsConfig

package com.cc.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

/**
 * 全局跨域配置
 * @author cc
 * @date 2021-07-12 10:18
 */
@Configuration
public class GlobalCorsConfig {
    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration configuration = new CorsConfiguration();
        // 允许所有域名进行跨域调用
        configuration.addAllowedOrigin("*");
//        configuration.addAllowedOriginPattern("*"); // SpringBoot2.4.0以后用这个
        // 允许跨域发送cookie
        configuration.setAllowCredentials(true);
        // 放行全部原始头信息
        configuration.addAllowedHeader("*");
        // 允许所有请求方法跨域调用
        configuration.addAllowedMethod("*");
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return new CorsFilter(source);
    }
}

PayController

package com.cc.controller;

import com.alipay.api.AlipayApiException;
import com.cc.alipay.AlipayOrder;
import com.cc.alipay.AlipayService;
import com.cc.model.OrderVo;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

/**
 * 支付接口
 * @author cc
 * @date 2021-12-01 16:01
 */
@RestController
public class PayController {
    private final AlipayService alipayService;

    public PayController(AlipayService alipayService) {
        this.alipayService = alipayService;
    }

    @PostMapping("/order/alipay")
    public String alipay(@RequestBody OrderVo orderVo) throws AlipayApiException {
        AlipayOrder alipayOrder = new AlipayOrder();
        alipayOrder.setOut_trade_no(orderVo.getOrderNo());
        alipayOrder.setSubject(orderVo.getOrderName());
        alipayOrder.setTotal_amount(orderVo.getPrice());

        return alipayService.pay(alipayOrder);
    }
}

OrderVo

package com.cc.model;

/**
 * 自定义的订单请求类
 * @author cc
 * @date 2021-12-01 16:02
 */
public class OrderVo {
    // 订单编号
    private String orderNo;

    // 订单名
    private String orderName;

    // 订单金额
    private String price;
    
    ...
}

然后我们要写前端页面来测试:

index.html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
</head>

<body>
    <label>订单编号:</label><input id="orderNo" name="orderNo" type="text" value="123"><br>
    <label>订单名:</label><input id="orderName" name="orderName" type="text" value="订单1号"><br>
    <label>金额:</label><input id="price" name="price" type="text" value="1.00"><br>
    <button onclick="toPay()">去支付</button>

    <div id="div">
    </div>

    </form>

    <script>
        function toPay() {
            var outTradeNo = $("#orderNo").val();
            var subject = $("#orderName").val();
            var totalAmount = $("#price").val();

            var params = {
                "orderNo": outTradeNo,
                "orderName": subject,
                "price": totalAmount
            }

            console.log(JSON.stringify(params))

            $.ajax({
                url: 'http://localhost:8888/order/alipay',
                type: 'post',
                headers: { "Content-Type": "application/json" },
                data: JSON.stringify(params),
                success: function (result) {
                    console.log(result)

                    const divForm = document.getElementsByTagName("div");
                    if (divForm.length) {
                        document.body.removeChild(divForm[0]);
                    }
                    const div = document.createElement("div");
                    div.innerHTML = result; // data就是接口返回的form 表单字符串
                    document.body.appendChild(div);
                    document.forms[0].setAttribute("target", "_blank"); // 新开窗口跳转
                    document.forms[0].submit();
                },
                error: function (e) {
                    console.log("ajax请求出现错误: ");
                    console.log(e);
                },
            });

        }
    </script>
</body>
</html>

前端代码很简单,简单说明下,用ajax发送post请求调用我们上面写的alipay接口,接口会返回一个html的form信息,前端接受form,将它添加到自己的模块中并调用,就会弹出一个新窗口,这个窗口就是支付宝的支付窗口。

在支付窗口扫码支付,或者输入买家的账密支付即可,这个买家信息在我们开头提到的沙箱模块中,请不要用自己的真实支付宝扫码。

内网穿透(FRP)

到这里我们有一个地方没有处理好,就是当我们扫码支付成功之后,页面无法识别不会跳转,因为我们是在本地开发环境调试的,所以在applicaiton配置文件中的returnUrlnotifyUrl都没有生效,这两个字段的生效需要公网环境,为了在本地调试,我们用内网穿透技术来实现。

内网穿透需要有一台公网服务器,这是必要条件,然后是两个闲置端口,后面会用到。

关于内网穿透,本文不具体展开,只介绍它在我们支付demo中的用法。

首先,公网服务器(Linux)和本地(Windows)下载FRP,不同的系统需要下载不同版本的,请在FRP各种版本的下载链接中下载好。

公网服务器启动server服务

找到frps.ini文件,修改内容为:

[common]
bind_port = 19000

bind_port是frp服务与frp客户端连接的端口,后面客户端要通过这个端口与服务端连接。

启动server服务:

./frps -c ./frps.ini

以下是启动成功的输出示例:

[root@izbp1id8uez7g43wcgry88z frp_0.34.3_linux_386]# ./frps -c ./frps.ini 
2021/01/18 12:59:11 [I] [service.go:190] frps tcp listen on 0.0.0.0:19000
2021/01/18 12:59:11 [I] [root.go:215] start frps success
本地开发环境启动client服务

找到frpc.ini文件,注意不要和服务端文件搞错了,修改内容为:

[common]
server_addr = 服务器的ip
server_port = 19000

[ssh]
type = tcp
local_ip = 127.0.0.1
local_port = 8888
remote_port = 19001
  • server_addr:公网服务器ip
  • server_port 是服务器监听的端口,即frps.ini配置文件的bind_port,两者要一致
  • local_ip和local_port:映射本地127.0.0.1:8888
  • remote_port:通过访问服务器的这个端口来进入内网,这个可能不好理解,先照着走就明白了

在我的示例中,端口以19000和19001为示例,这个大家自行修改即可。

启动客户端服务:

./frpc -c frpc.ini

完成,此时访问 服务器ip:remote_port 就可以看到内网服务了

但是这时候我们还没有准备好内网服务,所以访问也是没用的,抓紧做下一步吧。

支付完成跳转和支付结果通知

为了方便,我们用thymeleaf来显示html页面,因为这样只需要demo的8888端口,如果我们用nginx等服务器来启动html服务,那么内网穿透就要做多一个端口,这就太麻烦啦。

导入thymeleaf框架:

<!--支持html页面-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

新建两个页面,一个是提交支付的index页面,一个是支付成功后跳转的页面success

index.html:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
</head>

<body>
    <label>订单编号:</label><input id="orderNo" name="orderNo" type="text" value="123"><br>
    <label>订单名:</label><input id="orderName" name="orderName" type="text" value="订单1号"><br>
    <label>金额:</label><input id="price" name="price" type="text" value="1.00"><br>
    <button onclick="toPay()">去支付</button>

    <div id="div">
    </div>

    </form>

    <script>
        function toPay() {
            var outTradeNo = $("#orderNo").val();
            var subject = $("#orderName").val();
            var totalAmount = $("#price").val();

            var params = {
                "orderNo": outTradeNo,
                "orderName": subject,
                "price": totalAmount
            }

            console.log(JSON.stringify(params))

            $.ajax({
                url: 'http://localhost:8888/order/alipay',
                type: 'post',
                headers: { "Content-Type": "application/json" },
                data: JSON.stringify(params),
                success: function (result) {
                    console.log(result)

                    const divForm = document.getElementsByTagName("div");
                    if (divForm.length) {
                        document.body.removeChild(divForm[0]);
                    }
                    const div = document.createElement("div");
                    div.innerHTML = result; // data就是接口返回的form 表单字符串
                    document.body.appendChild(div);
                    document.forms[0].setAttribute("target", "_blank"); // 新开窗口跳转
                    document.forms[0].submit();
                },
                error: function (e) {
                    console.log("ajax请求出现错误: ");
                    console.log(e);
                },
            });

        }
    </script>
</body>
</html>

success.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <h3>这里是支付成功后跳转的页面</h3>
</body>
</html>

编写页面获取接口WebController:

package com.cc.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

/**
 * web页面提供
 * 用thymeleaf的时候,不要用RestController注解
 * @author cc
 * @date 2021-12-02 15:03
 */
@Controller
public class WebController {
    @GetMapping("/index")
    public String index() {
        return "index";
    }

    @GetMapping("/success")
    public String success() {
        return "success";
    }
}

在支付接口中添加一个接收支付结果通知的notify接口:

PayController:

@RestController
public class PayController {
    ...

    @PostMapping("/notify")
    public String finishNotify(HttpServletRequest request) throws AlipayApiException, UnsupportedEncodingException {
        System.out.println("cynsjj8596@sandbox.com");
        return alipayService.finishNotify(request);
    }
}

ApiService(代码源于官方demo):

/**
     * 接收支付完成通知
     * @author cc
     * @date 2021-12-02 10:34
     */
public String finishNotify(HttpServletRequest request) throws AlipayApiException, UnsupportedEncodingException {
    Map<String,String> params = new HashMap<String,String>();
    Map<String,String[]> requestParams = request.getParameterMap();

    for (Iterator<String> iter = requestParams.keySet().iterator(); iter.hasNext();) {
        String name = (String) iter.next();
        String[] values = (String[]) requestParams.get(name);
        String valueStr = "";
        for (int i = 0; i < values.length; i++) {
            valueStr = (i == values.length - 1) ? valueStr + values[i]
                : valueStr + values[i] + ",";
        }
        params.put(name, valueStr);
    }

    //调用SDK验证签名
    System.out.println(params.toString());
    System.out.println(alipayProperties.toString());
    boolean signVerified = AlipaySignature.rsaCheckV1(params, alipayProperties.getPublicKey(), alipayProperties.getCharset(), alipayProperties.getSignType());
    System.out.println("SDK验证签名结果1:" + signVerified);

    if(signVerified) { //验证成功
        // 商户订单号
        String out_trade_no = new String(request.getParameter("out_trade_no").getBytes("ISO-8859-1"),"UTF-8");

        // 支付宝交易号
        String trade_no = new String(request.getParameter("trade_no").getBytes("ISO-8859-1"),"UTF-8");

        // 交易状态
        String trade_status = new String(request.getParameter("trade_status").getBytes("ISO-8859-1"),"UTF-8");

        System.out.println("==========");
        System.out.println("out_trade_no: " + out_trade_no);
        System.out.println("trade_no: " + trade_no);
        System.out.println("trade_status: " + trade_status);

        if(trade_status.equals("TRADE_FINISHED")){
            // 判断该笔订单是否在商户网站中已经做过处理
            // 如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
            // 如果有做过处理,不执行商户的业务程序

            // 注意:
            // 退款日期超过可退款期限后(如三个月可退款),支付宝系统发送该交易状态通知
        }else if (trade_status.equals("TRADE_SUCCESS")){
            // 判断该笔订单是否在商户网站中已经做过处理
            // 如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
            // 如果有做过处理,不执行商户的业务程序

            // 注意:
            // 付款完成后,支付宝系统发送该交易状态通知
        }
        return "success";

    }else { //验证失败
        // 调试用,写文本函数记录程序运行情况是否正常
        // String sWord = AlipaySignature.getSignCheckContentV1(params);
        // AlipayConfig.logResult(sWord);

        return "fail";
    }
}

/notify接口返回success告诉支付宝成功,返回fail失败。

最后完善一下我们的application配置文件中的returnUrl和notifyUrl:

server:
  port: 8888

# 支付宝沙箱支付配置
alipay:
  # 应用Id
  appId: 
  # 支付宝私钥公钥
  privateKey: 
  publicKey: 
  # 支付完成后同步跳转的路径
  returnUrl: http://服务器IP:19001/success
  # 支付完成后异步调用的地址,返回结果中,确认收款成功的key值是 trade_status
  notifyUrl: http://服务器IP:19001/notify
  # 签名方式
  signType: RSA2
  # 编码格式
  charset: utf-8
  # 支付宝网关
  gatewayUrl: https://openapi.alipaydev.com/gateway.do

效果

再次测试支付功能,页面在支付完成后会跳转到我们的success.html,并且会发送一个通知到/notify接口。