在图标的设计上,微信和支付宝都选择超出背景部分。

前言

由于工作需求,需要添加统一支付功能,微信已经有人对接过,因此我主要负责对接支付宝,本文主要记录Java对接支付宝的步骤,以及服务改造。


支付宝

API文档

确保支付所需的证书和参数正确下,我第一步选择查看官方API文档:https://opendocs.alipay.com/apis

由于项目是APP项目,且只涉及到支付与退款,因此我选择查看 alipay.trade.app.pay(app支付接口2.0)alipay.trade.refund(统一收单交易退款接口)

APP支付的含义是:外部商户APP唤起快捷SDK创建订单并支付。因此,我们得知,是APP通过阿里SDK调起支付宝,对于后端而言,我们只需要生成 符合规则的订单串 给前端,由前端唤起即可。

在查看支付宝官方的请求示例后,我并没有直接动手按照请求示例的代码开始编写测试,而是选择先Google下 ,有没有更好的服务端SDK供我们使用,果不其然,我发现了支付宝官方升级版SDK, Alipay Easy SDK:https://github.com/alipay/alipay-easysdk

对比

Alipay Easy SDK Alipay SDK
极简代码风格,更贴近自然语言阅读习惯 传统代码风格,需要多行代码完成一个接口的调用
Factory单例全局任何地方都可直接引用 AlipayClient实例需自行创建并在上下文中传递
API中只保留高频场景下的必备参数,同时提供低频可选参数的装配能力 没有区分高低频参数,单API最多可达数十个入参,对普通开发者的干扰较大

引入所需的 Gradle (Maven请自行搜索)

1
implementation group: 'com.alipay.sdk', name: 'alipay-easysdk', version: '2.2.0'

证书参数注入

第一步,我先根据SDK和文档,定义 公共请求参数(可自行声明为 Java Class)

1
2
3
4
5
6
7
8
9
10
11
12
13
message AlipayParam {
string appId = 1;
string privateKey = 2;
string publicKey = 3;
string appCertPath = 4;
string aliPayCertPath = 5;
string aliPayRootCertPath = 6;
string notifyUrl = 7;
string encryptKey = 8;
string protocol = 9; //非必填 默认 https
string gatewayHost = 10; //非必填 默认 openapi.alipay.com
string signType = 11; //非必填 默认 RSA2
};

第二步,我需要注入支付宝所需要的参数和证书等文件,我整合成了 Util 方便日后使用(或者用启动注入的方式)。

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
import com.alipay.easysdk.factory.Factory;
import com.alipay.easysdk.kernel.Config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* @Author: Leopold
*/
public class ConfigUtil {

private static final Logger logger = LoggerFactory.getLogger(ConfigUtil.class);

private static final String INIT_PROTOCOL = "https";
private static final String INIT_GATEWAY_HOST = "openapi.alipay.com";
private static final String INIT_SIGN_TYPE = "RSA2";

/**
* 手动注入Alipay证书相关参数至easySDK的Factory
* @param AlipayParam proto定义的Alipay证书参数
*/
public static void run(AlipayParam AlipayParam) {
logger.info("注入证书 -> {}", AlipayParam);
String protocol = AlipayParam.getProtocol();
String gatewayHost = AlipayParam.getGatewayHost();
String signType = AlipayParam.getSignType();

Config config = new Config();
config.protocol = ObjectUtils.isEmpty(protocol) ? INIT_PROTOCOL : protocol;
config.gatewayHost = ObjectUtils.isEmpty(gatewayHost) ? INIT_GATEWAY_HOST : gatewayHost;
config.signType = ObjectUtils.isEmpty(signType) ? INIT_SIGN_TYPE : signType;
config.appId = AlipayParam.getAppId();

// 为避免私钥随源码泄露,推荐从文件中读取私钥字符串而不是写入源码中
config.merchantPrivateKey = AlipayParam.getPrivateKey();

//注:证书文件路径支持设置为文件系统中的路径或CLASS_PATH中的路径,优先从文件系统中加载,加载失败后会继续尝试从CLASS_PATH中加载
config.merchantCertPath = AlipayParam.getAppCertPath();
config.alipayCertPath = AlipayParam.getAliPayCertPath();
config.alipayRootCertPath = AlipayParam.getAliPayRootCertPath();

//可设置异步通知接收服务地址(可选)
config.notifyUrl = AlipayParam.getNotifyUrl();

//可设置AES密钥,调用AES加解密相关接口时需要(可选)
config.encryptKey = AlipayParam.getEncryptKey();
Factory.setOptions(config);
}

}

统一下单

紧接着,我着手于这个SDK中的APP支付接口 pay(subject: string, outTradeNo: string, totalAmount: string) ,但很快,我就遇到了瓶颈。为什么呢?

因为这个参数的入参只有 订单标题金额商户订单号 ,而我需要 订单超时时间 和本系统流转的 公用回传参数 ,也就是说,我需要设定最晚支付时间和本系统的订单号(我传给支付宝,它再回传给我),而SDK中的方法显然不能满足我的需求。查看源码发现,batchOptional(java.util.Map<String, Object> optionalArgs) 可批量设置API入参中没有的其他可选业务请求参数(biz_content下的字段),伪码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 注入证书
ConfigUtil.run(alipayParam);

// 统一下单
String timeOutExpressStr = String.format("%sm", timeoutExpress);
AlipayTradeAppPayResponse alipayTradeAppPayResponse = Factory.Payment.App()
.batchOptional(BeanUtil.beanToMap(new UnifiedOrderDto(subject,
outTradeNo,
totalAmount,
timeOutExpressStr,
URLEncoder.encode(passBackParams, StandardCharsets.UTF_8)),
true,
true))
.pay(subject, outTradeNo, totalAmount);

由于 batchOptional 方法的入参是 Map ,因此我整合下单所需要的 biz_content ,并声明为 UnifiedOrderDto ,通过 BeanUtil.beanToMap() 方法,驼峰命名的属性自动替换为下划线,并转换成Map,代码如下:

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
import com.google.common.base.Objects;

import java.io.UnsupportedEncodingException;

/**
* 统一支付宝下单 Dto
*
* @Author: Leopold
*/
public class UnifiedOrderDto {

/**
* [必填] 交易主题<br/>
* e.g. 订单:582144534855553024
*/
private String subject;

/**
* [必填] 本系统的订单号,非流水号<br/>
* e.g. 582144534855553024
*/
private String outTradeNo;

/**
* [必填] 金额,单位是元<br/>
* e.g. 0.01
*/
private String totalAmount;

/**
* [必填] 该笔订单允许的最晚付款时间,逾期将关闭交易。取值范围:5m~15d。<br/>
* e.g. 15m
*/
private String timeoutExpress;

/**
* [选填] <b>本系统其他业务参数,比如日志id</b>
* <ul>
* <li>公用回传参数,如果请求时传递了该参数,则返回给商户时会回传该参数。</li>
* <li>支付宝只会在同步返回(包括跳转回商户网站)和异步通知时将该参数原样返回。</li>
* <li>本参数必须进行 UrlEncode 之后才可以发送给支付宝。</li>
* </ul>
* <i>P.S. 请保持 <b>passbackParams</b> 的驼峰顺序,否则后续驼峰自动转下划线时,支付宝无法识别!</i>
*/
private String passbackParams;

public UnifiedOrderDto(String subject, String outTradeNo, String totalAmount, String timeoutExpress,
String passbackParams) throws UnsupportedEncodingException {
this.subject = subject;
this.outTradeNo = outTradeNo;
this.totalAmount = totalAmount;
this.timeoutExpress = timeoutExpress;
this.passbackParams = passbackParams;
}

public String getPassbackParams() {
return passbackParams;
}

public void setPassbackParams(String passbackParams) {
this.passbackParams = passbackParams;
}

public String getSubject() {
return subject;
}

public void setSubject(String subject) {
this.subject = subject;
}

public String getOutTradeNo() {
return outTradeNo;
}

public void setOutTradeNo(String outTradeNo) {
this.outTradeNo = outTradeNo;
}

public String getTotalAmount() {
return totalAmount;
}

public void setTotalAmount(String totalAmount) {
this.totalAmount = totalAmount;
}

public String getTimeoutExpress() {
return timeoutExpress;
}

public void setTimeoutExpress(String timeoutExpress) {
this.timeoutExpress = timeoutExpress;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof UnifiedOrderDto)) return false;
UnifiedOrderDto that = (UnifiedOrderDto) o;
return Objects.equal(subject, that.subject) && Objects.equal(outTradeNo, that.outTradeNo) && Objects.equal(totalAmount, that.totalAmount) && Objects.equal(timeoutExpress, that.timeoutExpress) && Objects.equal(passbackParams, that.passbackParams);
}

@Override
public int hashCode() {
return Objects.hashCode(subject, outTradeNo, totalAmount, timeoutExpress, passbackParams);
}

@Override
public String toString() {
return "UnifiedOrderDto{" + "subject='" + subject + '\'' + ", outTradeNo='" + outTradeNo + '\'' + ", " +
"totalAmount='" + totalAmount + '\'' + ", timeoutExpress='" + timeoutExpress + '\'' + ", " +
"passbackParams='" + passbackParams + '\'' + '}';
}
}

接下来我们需要验证生成的 alipayTradeAppPayResponse 是否正确,伪码如下:

1
2
3
if (ResponseChecker.success(alipayTradeAppPayResponse)) {
response = alipayTradeAppPayResponse.getBody();
}

此时,又出现了个疑问,支付宝APP支付接口2.0文档中,公共响应参数 是5个,而我怎么尝试上方代码,都无法返回 code 状态码,后来发现,这是APP移动端支付,仅提供证书下生成对应的串,生成串这个步骤其实并没有访问支付宝的网关,完全是本地自己生成的,因此这个方法也不存在 code 的概念。

前端通过SDK附带此串,唤起支付宝,无报错,能支付,即可。

P.S. 有一次报错是调起支付宝后,说我生成的串不对,仔细发现,有两个证书传反了……


统一退款

统一退款的原理和下单是一个套路,伪码如下:

1
2
3
4
5
6
7
8
9
// 注入证书
ConfigUtil.run(alipayParam);

// 统一退款
Map<String, Object> stringObjectMap = BeanUtil.beanToMap(
new RefundParamDto(outTradeNo, outRequestNo, refundAmount, refundRoyaltyDto),
true,
true);
AlipayTradeRefundResponse alipayTradeRefundResponse = Factory.Payment.Common().batchOptional(stringObjectMap)

验签

1
2
3
4
5
6
7
8
// 异步通知中收到的待验签的所有参数
Map<String, String> parametersMap = request.getParametersMap();

// 注入证书
ConfigUtil.run(alipayParam);

// 验签
Boolean flag = Factory.Payment.Common().verifyNotify(parametersMap);

回调/异步通知

支付宝异步通知文档:https://opensupport.alipay.com/support/helpcenter/193/201602472200?ant_source=opendoc

正常的支付流程:

用户下单 -> 后端调用支付宝统一下单接口 ->后端生成订单串,返回前端->前端根据订单串唤起支付宝->支付宝根据订单串支付->支付成功->支付宝发送下单的异步通知给后端->后端解析异步通知的参数->后端调用支付宝验签接口->验签成功,执行本系统其他业务逻辑(已支付),通知支付宝接收异步回调成功->支付宝收到成功message,不再发送下单的异步通知

三个月后:

支付宝发送订单关闭(禁止退款)的异步通知给后端->后端调用支付宝验签接口->验签成功,执行本系统其他业务逻辑(禁止申请退款),通知支付宝接收异步回调成功->支付宝收到成功message,不再发送订单关闭的异步通知


支付宝常量类:

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
/**
* 支付宝常量类
*
* @Author: Leopold
*/
public class AliPayConstant {


//------------------------------------------------------------ 返回值
/**
* <p>异步回调成功返回值,必须为success。如果不是success这个字符串,
* <p>则以4m、10m、10m、1h、2h、6h、15h的频率次数重新发送,直到超过25h后停止发送。
* <p>当商户收到服务器异步通知并打印出 success 时,服务器异步通知参数 notify_id 才会失效。
*/
public static final String CALL_BACK_SUCCESS = "success";
/**
* 异步回调失败返回值,支付宝会重新发送消息到异步地址。
*/
public static final String CALL_BACK_FAILED = "fail";


//------------------------------------------------------------ 回调状态
/**
* 商户签约的产品支持退款功能的前提下,买家付款成功。
* <p>或部分退款。
*/
public static final String TRADE_SUCCESS = "TRADE_SUCCESS";
/**
* 在指定时间段内未支付时关闭的交易 或 在交易完成全额退款成功时关闭的交易。
*/
public static final String TRADE_CLOSED = "TRADE_CLOSED";
/**
* <p>交易结束,不可退款
* <p>商户签约的产品不支持退款功能的前提下,买家付款成功;
* <p>或者,商户签约的产品支持退款功 能的前提下,交易已经成功并且已经超过可退款期限
*/
public static final String TRADE_FINISHED = "TRADE_FINISHED";


//------------------------------------------------------------ 回调路径
/**
* <p>下单or退款回调路径
*/
@Deprecated
public static final String CALLBACK_PATH = "你的notifyUrl";


//------------------------------------------------------------ code
/**
* 成功状态码
*/
public static final String CODE_SUCCESS = "10000";

}

回调伪码如下:

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
/**
* <p>统一支付宝回调
* <p>支付宝的退款回调和下单回调必须共用一个回调地址,
* <p>不同于微信,微信的退款和下单中,回调是不同的两个地址。
*/
@PostMapping(value = "你的notifyUrl", consumes = "application/x-www-form-urlencoded")
public String verifyNotify(HttpServletRequest request) {
// 接收回调参数
Map<String, String[]> parameterMap = request.getParameterMap();

// 转换为Map<String, String>
Map<String, Object> map = new HashMap<>(16);
parameterMap.forEach((s1, s2) -> map.put(s1,s2.length > 1 ? Arrays.asList(s2): s2[0]));
Map<String, String> paramMap = mapObject2String(map); // 此处可优化

// 执行验签(异步通知中收到的待验签的所有参数)
Boolean flag = Factory.Payment.Common().verifyNotify(paramMap);

// 验签失败的就不用往下执行了
if (!flag) {
return "success";
}

// 判断回调类型 (自行 try cache)
String tradeStatus = paramMap.get("trade_status");
switch (tradeStatus){
case "TRADE_SUCCESS" -> {
String invoiceAmount = paramMap.get("invoice_amount");
if (!ObjectUtils.isEmpty(invoiceAmount)) {
logger.info("回调类型:TRADE_SUCCESS 交易支付成功");
// do something

} else {
String refundFee = paramMap.get("refund_fee");
logger.info("回调类型:TRADE_SUCCESS 部分退款成功");
// do something
}
}
case "TRADE_CLOSED" -> {
// 如果是全额退款,那么 out_biz_no refund_fee gmt_refund 这三个参数必定不能是空
// 否则就是交易超时关闭
if (!ObjectUtils.isEmpty(paramMap.get("out_biz_no")) && !ObjectUtils.isEmpty(paramMap.get("refund_fee")) &&
!ObjectUtils.isEmpty(paramMap.get("gmt_refund"))){
logger.info("回调类型:TRADE_CLOSED 支付完成后全额退款");
// do something

} else {
logger.info("回调类型:TRADE_CLOSED 未付款交易超时关闭");
// do something

}
}
default -> {
logger.info("回调类型:TRADE_FINISHED 交易结束不可退款");
// do something
}
}

// 通知支付宝接收异步回调成功
return "success";
}


//------------------------------------------------------------ private method


private Map<String,String> mapObject2String(Map<String,Object> map) {
Map<String,String> returnMap = new HashMap<>(32);
if (!ObjectUtils.isEmpty(map)){
map.forEach((k, v) -> returnMap.put(k,String.valueOf(v)));
}
return returnMap;
}

反思

反思一下,这样设计的下单、退款、验签和回调有怎样的局限性?

  • 证书参数注入是有状态的,如果多套证书参数怎么办?一直在配置文件中写吗?
  • 下单和退款是有状态的,如果我想剥离支付宝的下单、退款和验签为一个微服务,那多套证书又该如何注入呢?一直在配置文件中写吗?一直维护多个服务的配置文件吗?

思路:

  • 支付宝单独一个微服务(无状态),支付功能一个微服务(有状态)。
  • 证书参数由支付功能的微服务(有状态)流式发送到支付宝微服务(无状态),其余参数存到数据库中。
  • 支付回调写在支付功能的微服务中(有状态)
  • 在支付宝下单时记录日志,记录读取的参数是哪套证书相关的,并将日志id发送给支付宝,让它在回调时在发送给我们,我们在回调中收到日志id,根据此id查询下单时的证书参数,进行验签。

P.S. :微信回调由于参数解析时就需要验签,因此我们可以在回调地址上做手脚,比如@PostMapping("/xxx/xxx/{logId}/{systemId}")