跟我一起玩转微信APIv3支付

写在前面

现在到处有微信支付的身影,作为一个后端开发者,跟我一起来看看微信支付到底怎么应用于自己的项目中吧

如果你还不了微信第三方服务生态的,请先阅读一下微信与阿里云第三方服务的一些概念流程梳理,相信读过后你会对微信第三方服务生态有了一定的了解,下面可以按照要求准备开通微信支付的必备条件,并开通APIv3证书,获取一些必备的参数。

如果你对密码学的常识不够了解,最好先阅读一下开发过程中那些不得不知道的密码学基础。基于这些常识,理解这篇文章将会事半功倍。

熟悉官方文档

如果你在之前已经有尝试过浏览微信支付的官方文档/SDK,你或许会和我一样一头雾水。因此,我会带着大家熟悉一下文档。

以Native支付为例,先是开发指引。在这里,微信支付高屋建瓴的总结了实现微信支付的流程。

image-20220311134633330

首先说明了微信支付接口基于APIv3(贴张官方图说明一下)

image-20220311135145870

一言以蔽之,APIv3采用JSON作为数据交互格式,接口遵循REST风格,使用高效安全的SHA256-RSA签名算法保证数据完整性和请求者身份。不需要HTTPS客户端证书,仅凭借证书序列号换取公钥和签名内容,使用AES-256-GCM对称加密保证数据私密性。

作为开发者,两个东西需要保管好,极为重要,不可泄漏

  • 商户API证书

商户证书中封装了公钥和签名内容,用于发送请求和签名验证

  • 商户API私钥

商户申请商户API证书时,会生成商户私钥,并保存在本地证书文件夹的文件apiclient_key.pem 中。私钥用于获取AES加密口令,并解密获取加密内容

其次,在开发准备中提供了JAVA,PHP,GO三个语言版本的SDK,封装了签名生成、签名验证、敏感信息加解密、媒体文件上传等功能,方便我们直接使用,而不用自己手写这一系列的操作。若您对官方SDK不放心,可以自己实现。实现思路官方也给出了:

image-20220311141242738

也有相应的快速测试方法:

image-20220311141328879

然后,在快速接入中提供了业务流程图和相应功能(下单、查单、关单、回调支付)的实现逻辑

忽略官方示意图的一些细节,我画了一个更易于理解流程的时序图:

image-20220311143031084

下面,我们要做的是就一目了然了:了解怎么使用SDK封装一系列的安全保障过程,即构建自动化的HttpClient,然后具体实现每个API的请求就可以了!

看懂SDK文档

做好准备

这里以Java为例,剖析官方SDK的README文档

文档地址 在这里建议大家下载源码到本地,更方便的阅读源码,对实现细节做了解

我写这篇文章的时间是

1
2
3
4
5
6
7
mysql> select now();
+---------------------+
| now() |
+---------------------+
| 2022-03-11 14:39:46 |
+---------------------+
1 row in set (0.03 sec)

采用最新版本SDK wechatpay-apache-httpclient 0.4.2

基于JDK1.8+ Maven依赖为

1
2
3
4
5
<dependency>
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-apache-httpclient</artifactId>
<version>0.4.2</version>
</dependency>

开始

image-20220311144210416

这里告诉我们凭借商户号、证书序列号、私钥、商户证书等可构建专有的WechatPayHttpClient,帮助我们实现加解密、签名、签名验证等繁琐的过程

image-20220311144428678

接下来则是使用该HttpClient如果封装请求头和请求体的简单实例

填坑

通过上面的方式,需要手动下载更新证书。在README的后面给予了解决方法

image-20220311144859088

不同于上面,这里利用CertificatesManager证书管理器实现验签器、证书更新的集中管理

回调方案

image-20220311145056863

这里则提供了如何使用SDK进行回调签名验证,返回数据的解密。稍微分析一下,可见SDK提供了NotificationRequest和NotificationHandler两个工具实现此功能。

现在感到懵逼不要紧张,下面结合具体实例来说明

开始干活

再准备一次

相信看过上面对于微信文档和SDK文档的大致分析后,对大概怎么个流程已经心里有数了。下面就开始干活。

开发指引提供了通用的解决思路(如何加载商户私钥、加载平台证书、初始化httpClient),只可惜其中AutoUpdateCertificatesVerifier在最新的SDK中已经弃用

image-20220311145744644

image-20220311145943104

虽然但是,开发者提供了更好的解决方法:

image-20220311150055608

此工具集成了获取证书,下载证书,定期更新证书,获取验签器功能为一体

看看该类的结构:

image-20220311150240045

显然,这是单例模式的设计。getInstance方法获得唯一实例,可以放入证书,停止下载更新,获取验签器。

注册全局Bean 供业务使用

经过上面的分析,不难得出。拿到CertificatesManager实例,放入证书,开启自动下载更新,取出验签器即可。并在SpringBoot服务中注册全局Bean,静候差遣!

当然,在这之前,最好在yml中配置好需要用到的参数,并读取到WxPayConfig中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
wxpay:
# 商户号
mch-id: 1******42
# API证书序列号
mch-serial-no: 你的API证书序列号
# 商户私钥文件
private-key-path: C:\Users\cheung0\Desktop\apiclient_key.pem
# APIv3 密钥
api-v3-key: w*************x
# APPID
appid: wx3*********46
# 微信服务器地址
domain: https://api.mch.weixin.qq.com
# 接受结果通知地址
notify-domain: http://maiqu.sh1.k9s.run:2271

其中notify-domain是回调通知时为微信服务器请求的地址。若要做本地测试,请用内网穿透工具开通隧道。这里推荐我使用的SuiDao

随后配置到

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
@Data
@Slf4j
@Component
@ConfigurationProperties(prefix = "wxpay")
public class WxPayConfig {

// 商户号
private String mchId;

// API证书序列号
private String mchSerialNo;

// 私钥地址
private String privateKeyPath;

// APIv3 密钥
private String apiV3Key;

// APPID
private String appid;

// 微信服务器地址
private String domain;

// 接收结果通知地址
private String notifyDomain;

}

这里推荐添加POM依赖,对配置和实体之间更好的依赖:

1
2
3
4
5
6
<!--配置映射-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>

下面,可以自己写一个测试看看有没有配置上

接下来开始注册Bean

由于多处需要加载私钥,便注册一个返回私钥内容的Bean

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 加载商户私钥 <br>
* @return PrivateKey
*/
@Bean
public PrivateKey getPrivateKey() throws IOException {

// 加载商户私钥(privateKey:私钥字符串)
log.info("开始加载私钥,读取内容...");
String content = new String(Files.readAllBytes(Paths.get(privateKeyPath)),StandardCharsets.UTF_8 );
return PemUtil.loadPrivateKey(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)));

}

PemUtil是SDK提供的工具类,它可以帮助我们读取私钥:

1
2
3
4
5
6
7
8
9
10
11
12
public static PrivateKey loadPrivateKey(String privateKey) {
privateKey = privateKey.replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", "").replaceAll("\\s+", "");

try {
KeyFactory kf = KeyFactory.getInstance("RSA");
return kf.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)));
} catch (NoSuchAlgorithmException var2) {
throw new RuntimeException("当前Java环境不支持RSA", var2);
} catch (InvalidKeySpecException var3) {
throw new RuntimeException("无效的密钥格式");
}
}

获取验签器

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
/**
* 获取平台证书管理器,定时更新证书(默认值为UPDATE_INTERVAL_MINUTE)
* <br>
* 返回验签器实例,注册为bean,在实际业务中使用
*
* @return
*/
@Bean
public Verifier getVerifier(PrivateKey merchantPrivateKey) throws IOException, NotFoundException {

log.info("加载证书管理器实例");

// 获取证书管理器单例实例
CertificatesManager certificatesManager = CertificatesManager.getInstance();

// 向证书管理器增加需要自动更新平台证书的商户信息
log.info("向证书管理器增加商户信息,并开启自动更新");
try {
// 该方法底层已实现同步线程更新证书
// 详见beginScheduleUpdate()方法
certificatesManager.putMerchant(mchId, new WechatPay2Credentials(mchId,
new PrivateKeySigner(mchSerialNo, merchantPrivateKey)), apiV3Key.getBytes(StandardCharsets.UTF_8));
} catch (GeneralSecurityException | HttpCodeException e) {
e.printStackTrace();
}

log.info("从证书管理器中获取验签器");
return certificatesManager.getVerifier(mchId);
}

至此,获取验签器的同时也开启了定时下载更新证书,在certificatesManager.putMerchant方法中可见:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public synchronized void putMerchant(String merchantId, Credentials credentials, byte[] apiV3Key) throws IOException, GeneralSecurityException, HttpCodeException {
if (merchantId != null && !merchantId.isEmpty()) {
if (credentials == null) {
throw new IllegalArgumentException("credentials为空");
} else if (apiV3Key.length == 0) {
throw new IllegalArgumentException("apiV3Key为空");
} else {
if (this.certificates.get(merchantId) == null) {
this.certificates.put(merchantId, new ConcurrentHashMap());
}

this.initCertificates(merchantId, credentials, apiV3Key);
this.credentialsMap.put(merchantId, credentials);
this.apiV3Keys.put(merchantId, apiV3Key);
if (this.executor == null) {
this.beginScheduleUpdate();
}

}
} else {
throw new IllegalArgumentException("merchantId为空");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void beginScheduleUpdate() {
this.executor = new SafeSingleScheduleExecutor();
Runnable runnable = () -> {
try {
Thread.currentThread().setName("scheduled_update_cert_thread");
log.info("Begin update Certificates.Date:{}", Instant.now());
this.updateCertificates();
log.info("Finish update Certificates.Date:{}", Instant.now());
} catch (Throwable var2) {
log.error("Update Certificates failed", var2);
}

};
this.executor.scheduleAtFixedRate(runnable, 0L, 1440L, TimeUnit.MINUTES);
}

不难看出,当SpringBoot服务启动后,线程池中会创建一个名为”scheduled_update_cert_thread”的线程来定时下载更新证书

获取HttpClient

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 通过WechatPayHttpClientBuilder构造HttpClient
*
* @param verifier
* @return
*/
@Bean(name = "wxPayClient")
public CloseableHttpClient getWxPayClient(Verifier verifier,PrivateKey merchantPrivateKey) throws IOException {

log.info("构造httpClient");

WechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create()
.withMerchant(mchId, mchSerialNo, merchantPrivateKey)
.withValidator(new WechatPay2Validator(verifier));

// 通过WechatPayHttpClientBuilder构造的HttpClient,会自动的处理签名和验签,并进行证书自动更新
CloseableHttpClient httpClient = builder.build();

log.info("构造httpClient成功");
return httpClient;
}

获取支付回调请求处理器

1
2
3
4
5
6
7
8
9
10
/**
* 构建微信支付回调请求处理器
*
* @param verifier
* @return NotificationHandler
*/
@Bean
public NotificationHandler notificationHandler(Verifier verifier) {
return new NotificationHandler(verifier,apiV3Key.getBytes(StandardCharsets.UTF_8));
}

启动服务做测试

1
2
3
4
5
6
7
8
2022-03-11 15:54:27.683  INFO 4944 --- [  restartedMain] t.m.metrichall.wxpay.config.WxPayConfig  : 开始加载私钥,读取内容...
2022-03-11 15:54:27.697 INFO 4944 --- [ restartedMain] t.m.metrichall.wxpay.config.WxPayConfig : 加载证书管理器实例
2022-03-11 15:54:27.698 INFO 4944 --- [ restartedMain] t.m.metrichall.wxpay.config.WxPayConfig : 向证书管理器增加商户信息,并开启自动更新
2022-03-11 15:54:28.363 INFO 4944 --- [ restartedMain] t.m.metrichall.wxpay.config.WxPayConfig : 从证书管理器中获取验签器
2022-03-11 15:54:28.365 INFO 4944 --- [ restartedMain] t.m.metrichall.wxpay.config.WxPayConfig : 构造httpClient
2022-03-11 15:54:28.364 INFO 4944 --- [ate_cert_thread] c.w.p.c.a.h.cert.CertificatesManager : Begin update Certificates.Date:2022-03-11T07:54:28.364Z
2022-03-11 15:54:28.367 INFO 4944 --- [ restartedMain] t.m.metrichall.wxpay.config.WxPayConfig : 构造httpClient成功
2022-03-11 15:54:28.626 INFO 4944 --- [ate_cert_thread] c.w.p.c.a.h.cert.CertificatesManager : Finish update Certificates.Date:2022-03-11T07:54:28.626Z

可以看到,我们注册Bean实例已启动,下载更新证书线程也启动了,一切准备完毕,静候差遣

集中封装一些枚举类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@AllArgsConstructor
@Getter
public enum PayType {
/**
* 微信
*/
WXPAY("微信"),


/**
* 支付宝
*/
ALIPAY("支付宝");

/**
* 类型
*/
private final String type;
}
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
@AllArgsConstructor
@Getter
public enum OrderStatus {
/**
* 未支付
*/
NOTPAY("未支付"),

/**
* 支付成功
*/
SUCCESS("支付成功"),

/**
* 已关闭
*/
CLOSED("超时已关闭"),

/**
* 已取消
*/
CANCEL("用户已取消"),

/**
* 退款中
*/
REFUND_PROCESSING("退款中"),

/**
* 已退款
*/
REFUND_SUCCESS("已退款"),

/**
* 退款异常
*/
REFUND_ABNORMAL("退款异常");

/**
* 类型
*/
private final String type;
}
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
@AllArgsConstructor
@Getter
public enum WxApiType {

/**
* Native下单
*/
NATIVE_PAY("/v3/pay/transactions/native"),

/**
* 查询订单
*/
ORDER_QUERY_BY_NO("/v3/pay/transactions/out-trade-no/%s"),

/**
* 关闭订单
*/
CLOSE_ORDER_BY_NO("/v3/pay/transactions/out-trade-no/%s/close"),

/**
* 申请退款
*/
DOMESTIC_REFUNDS("/v3/refund/domestic/refunds"),

/**
* 查询单笔退款
*/
DOMESTIC_REFUNDS_QUERY("/v3/refund/domestic/refunds/%s"),

/**
* 申请交易账单
*/
TRADE_BILLS("/v3/bill/tradebill"),

/**
* 申请资金账单
*/
FUND_FLOW_BILLS("/v3/bill/fundflowbill");

/**
* 类型
*/
private final String type;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@AllArgsConstructor
@Getter
public enum WxNotifyType {

/**
* 支付通知
*/
NATIVE_NOTIFY("/api/wx-pay/native/notify"),

/**
* 退款结果通知
*/
REFUND_NOTIFY("/api/wx-pay/refunds/notify");

/**
* 类型
*/
private final String type;
}
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
@AllArgsConstructor
@Getter
public enum WxRefundStatus {

/**
* 退款成功
*/
SUCCESS("SUCCESS"),

/**
* 退款关闭
*/
CLOSED("CLOSED"),

/**
* 退款处理中
*/
PROCESSING("PROCESSING"),

/**
* 退款异常
*/
ABNORMAL("ABNORMAL");

/**
* 类型
*/
private final String type;
}
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
@AllArgsConstructor
@Getter
public enum WxTradeState {

/**
* 支付成功
*/
SUCCESS("SUCCESS"),

/**
* 未支付
*/
NOTPAY("NOTPAY"),

/**
* 已关闭
*/
CLOSED("CLOSED"),

/**
* 转入退款
*/
REFUND("REFUND");

/**
* 类型
*/
private final String type;
}

枚举类中的值在业务经常会被用到,封装成枚举类,更为优雅

封装响应消息

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
@Data
@Accessors(chain = true)
public class R {

private Integer code; //响应码
private String message; //响应消息
private Map<String, Object> data = new HashMap<>();

public static R ok(){
R r = new R();
r.setCode(0);
r.setMessage("成功");
return r;
}

public static R error(){
R r = new R();
r.setCode(-1);
r.setMessage("失败");
return r;
}

public R data(String key, Object value){
this.data.put(key, value);
return this;
}

}

其中@Accessors(chain = true)注解可使得该类方法可以链式调用:

1
return R.ok().setMessage("下单成功!")

Native下单

Native下单API字典告知了我们必要的参数,并提供了请求示例:

1
2
3
4
5
6
7
8
9
10
11
{
"mchid": "1900006XXX",
"out_trade_no": "native12177525012014070332333",
"appid": "wxdace645e0bc2cXXX",
"description": "Image形象店-深圳腾大-QQ公仔",
"notify_url": "https://weixin.qq.com/",
"amount": {
"total": 1,
"currency": "CNY"
}
}

返回示例:

1
2
3
{
"code_url": "weixin://wxpay/bizpayurl?pr=p4lpSuKzz"
}

如果成功,就能拿到二维码链接

按照示例,封装我们自己的请求体:

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
/**
* 创建订单,调用Native支付接口
*
* @param productId
* @return code_url 和 订单号
* @throws Exception
*/
@Transactional(rollbackFor = Exception.class)
@Override
public Map<String, Object> nativePay(Long productId) throws Exception {

log.info("生成订单");

//生成订单...


//查找二维码链接是否已经存在 ? 直接retun : 往下走 ...



log.info("调用统一下单API");

//调用统一下单API
HttpPost httpPost = new HttpPost(wxPayConfig.getDomain().concat(WxApiType.NATIVE_PAY.getType()));
httpPost.addHeader("Accept", "application/json");
httpPost.addHeader("Content-type", "application/json; charset=utf-8");

// 请求body参数
Map<String, Object> paramsMap = new HashMap<>();
paramsMap.put("mchid", wxPayConfig.getMchId());
paramsMap.put("out_trade_no", orderInfo.getOrderNo());
paramsMap.put("appid", wxPayConfig.getAppid());
paramsMap.put("description", orderInfo.getTitle());
paramsMap.put("notify_url", wxPayConfig.getNotifyDomain().concat(WxNotifyType.NATIVE_NOTIFY.getType()));
// 组装amount
Map<String, Object> amountMap = new HashMap<>();
amountMap.put("total", orderInfo.getTotalFee());
amountMap.put("currency", "CNY");
paramsMap.put("amount", amountMap);

//将参数转换成json字符串
String jsonParams = JSON.toJSONString(paramsMap);
log.info("请求参数 ===> {}" + jsonParams);

// 配置请求体
httpPost.setEntity(new StringEntity(jsonParams, "UTF-8"));

//完成签名并执行请求

try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
String bodyAsString = EntityUtils.toString(response.getEntity());//响应体
int statusCode = response.getStatusLine().getStatusCode();//响应状态码
if (statusCode == 200) { //处理成功
log.info("成功, 返回结果 = " + bodyAsString);
} else if (statusCode == 204) { //处理成功,无返回Body
log.info("成功");
} else {
log.info("Native下单失败,响应码 = " + statusCode + ",返回结果 = " + bodyAsString);
throw new IOException("request failed");
}

//响应结果
Map<String, String> resultMap = JSON.parseObject(bodyAsString, HashMap.class);
//二维码
codeUrl = resultMap.get("code_url");

//保存二维码链接...


//返回二维码
Map<String, Object> map = new HashMap<>();
map.put("codeUrl", codeUrl);
map.put("orderNo", orderInfo.getOrderNo());

return map;

}
}

其中省略部分则大家个性化开发,使用Mybatis/MybatisPlus/JPA等工具进行数据库的增删改查,接下来类似地方同理

API请求看一下:

image-20220311160006916

返回JSON:

1
2
3
4
5
6
7
8
{
"code": 0,
"message": "成功",
"data": {
"codeUrl": "weixin://wxpay/bizpayurl?pr=HVPisQfzz",
"orderNo": "ORDER_20220311155916957"
}
}

codeUrl就是我们二维码的链接,后端可以采用Zxing工具来解析成二维码图片二进制流返回前端。我这里交给前端同学自行做优化处理。

在这里使用QRcode测试一下该codeUrl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<title>支付测试</title>
<script type="text/javascript" src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="https://cdn.bootcss.com/jquery.qrcode/1.0/jquery.qrcode.min.js"></script>
</head>

<body>
<button onclick="displayDate()">点击支付</button>
<div id="myQrcode"></div>
<script>
function displayDate() {
jQuery('#myQrcode').qrcode({
text: 'weixin://wxpay/bizpayurl?pr=HVPisQfzz'
});
}
</script>
</body>

</html>

取消订单

取消订单API字典告诉了我们需要的参数:

image-20220311160909354

1
2
3
{
"mchid": "1230000109"
}

请求体中放商户号,订单号拼接在URL中即可

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
/**
* 关单接口的调用
* <p>
* API字典: https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_4_3.shtml
*
* @param orderNo
*/
private HashMap<String, String> closeOrder(String orderNo) throws Exception {

log.info("关单接口的调用,订单号 ===> {}", orderNo);

//创建远程请求对象
String url = String.format(WxApiType.CLOSE_ORDER_BY_NO.getType(), orderNo);
url = wxPayConfig.getDomain().concat(url);
HttpPost httpPost = new HttpPost(url);
httpPost.addHeader("Accept", "application/json");
httpPost.addHeader("Content-type", "application/json; charset=utf-8");

// 请求body参数
Map<String, String> paramsMap = new HashMap<>();
paramsMap.put("mchid", wxPayConfig.getMchId());
String jsonParams = JSON.toJSONString(paramsMap);
log.info("请求参数 ===> {}", jsonParams);

StringEntity entity = new StringEntity(jsonParams, "UTF-8");
entity.setContentType("application/json");

//将请求参数设置到请求对象中
httpPost.setEntity(entity);

//完成签名并执行请求
CloseableHttpResponse response = httpClient.execute(httpPost);

HashMap<String, String> res = new HashMap<>();

try {
if (response.getStatusLine().getStatusCode() == 200 || response.getStatusLine().getStatusCode() == 204 ) {
res.put("code", "SUCCESS");
res.put("message", "该订单已成功关闭");
return res;
}

String bodyAsString = EntityUtils.toString(response.getEntity());
res = JSON.parseObject(bodyAsString,HashMap.class);
return res;
} catch (IOException | ParseException e) {
res.put("code", "ERROR");
if (e.toString() != null && !e.toString().equals("")) {
res.put("message", e.toString());
} else {
res.put("message", "发生未知错误");
}
return res;
}
}

打印返回体,能得到具体的相关信息。API字典也做出了说明。

若成功,返回体为空,状态码为200或204。若失败,例如:

image-20220311161512533

查询订单

查询订单API字典

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
/**
* 可通过“微信支付订单号查询”和“商户订单号查询”两种方式查询订单详情
* <p>
* 这里通过后者进行查询
* <p>
* API字典: https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_4_2.shtml
*
* @param orderNo
* @return
* @throws Exception
*/
@Override
public String queryOrder(String orderNo) throws Exception {

log.info("查单接口调用 ===> {}", orderNo);

//拼接请求的第三方API
String url = String.format(WxApiType.ORDER_QUERY_BY_NO.getType(), orderNo);
url = wxPayConfig.getDomain().concat(url).concat("?mchid=").concat(wxPayConfig.getMchId());

HttpGet httpGet = new HttpGet(url);
httpGet.setHeader("Accept", "application/json");

//完成签名并执行请求
CloseableHttpResponse response = httpClient.execute(httpGet);

try {
String bodyAsString = EntityUtils.toString(response.getEntity());//响应体
int statusCode = response.getStatusLine().getStatusCode();//响应状态码
if (statusCode == 200) { //处理成功
log.info("成功, 返回结果 = " + bodyAsString);
} else if (statusCode == 204) { //处理成功,无返回Body
log.info("成功");
} else {
log.info("查单接口调用,响应码 = " + statusCode + ",返回结果 = " + bodyAsString);
throw new IOException("request failed");
}

return bodyAsString;

} finally {
response.close();
}

}

API测试:

image-20220311164040114

返回JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"code": 0,
"message": "查询成功",
"data": {
"result": {
"mchid": "162****542",
"out_trade_no": "ORDER_20220311155916957",
"trade_state": "CLOSED",
"promotion_detail": [],
"appid": "wx32d4*******79746",
"trade_state_desc": "订单已关闭",
"attach": "",
"payer": {}
}
}
}

支付回调

当用户下单后,微信服务器会请求我们的服务器,告知我们支付结果。但这并不安全,因为我们并不能确定请求服务器来自哪里,万一是黑客的恶意请求呢?于是微信强烈建议我们进行签名验证,确认受否微信支付服务器所请求且数据完整未被中途篡改

支付回调API字典详细解释了Request内容和对Resource解密后的内容,以及我们该如何回应微信服务器。如果微信收到商户的应答不符合规范或超时,微信认为通知失败,微信会通过一定的策略定期重新发起通知,尽可能提高通知的成功率,但微信不保证通知最终能成功。(通知频率为15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h - 总计 24h4m)

在看懂SDK文档回调方案中我贴上了SDK提供的做法,这里我如法炮制

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
/**
* 支付通知<br>
* 微信支付通过支付通知接口将用户支付成功消息通知给商户<br>
* 商户应返回应答<br>
* 若商户收到的商户的应答不符合规范或者超时 微信则认为通知失败<br>
* 若通知失败 微信会通过一定的策略定期重新发起通知<br>
* 加密不能保证通知请求来自微信<br>
* 微信会对发送给商户的通知进行签名<br>
* 并将签名值放在通知的HTTP头Wechatpay-Signature<br>
*
* @param request
* @param response
* @return 响应map
*/
@ApiOperation("支付通知")
@PostMapping("/native/notify")
public String nativeNotify(HttpServletRequest request, HttpServletResponse response) {

// 应答对象
Map<String, String> map = new HashMap<>();

try {

// 处理参数
String serialNumber = request.getHeader("Wechatpay-Serial");
String nonce = request.getHeader("Wechatpay-Nonce");
String timestamp = request.getHeader("Wechatpay-Timestamp");
String signature = request.getHeader("Wechatpay-Signature");// 请求头Wechatpay-Signature
// 获取请求体
String body = HttpUtils.readData(request);

// 构造微信请求体
NotificationRequest wxRequest = new NotificationRequest.Builder().withSerialNumber(serialNumber)
.withNonce(nonce)
.withTimestamp(timestamp)
.withSignature(signature)
.withBody(body)
.build();
Notification notification = null;
try {

/**
* 使用微信支付回调请求处理器解析构造的微信请求体
* 在这个过程中会进行签名验证,并解密加密过的内容
* 签名源码: com.wechat.pay.contrib.apache.httpclient.cert; 271行开始
* 解密源码: com.wechat.pay.contrib.apache.httpclient.notification 76行
* com.wechat.pay.contrib.apache.httpclient.notification 147行 使用私钥获取AesUtil
* com.wechat.pay.contrib.apache.httpclient.notification 147行 使用Aes对称解密获得原文
*/
notification = notificationHandler.parse(wxRequest);
} catch (Exception e) {
log.error("通知验签失败");
//失败应答
response.setStatus(500);
map.put("code", "ERROR");
map.put("message", "通知验签失败");
return JSON.toJSONString(map);
}

// 从notification中获取解密报文,并解析为HashMap
String plainText = notification.getDecryptData();
log.info("通知验签成功");

//处理订单
wxPayService.processOrder(plainText);

//成功应答
response.setStatus(200);
map.put("code", "SUCCESS");
map.put("message", "成功");
return JSON.toJSONString(map);

} catch (Exception e) {
e.printStackTrace();
//失败应答
response.setStatus(500);
map.put("code", "ERROR");
map.put("message", "失败");
return JSON.toJSONString(map);
}

}

避坑:serialNumber参数值并不是我们在yml中所配置的,微信会重新发送一个新的证书序列号放在请求头,我们必须拼接这个证书序列号去换取证书实例,换取公钥验签

调试是可以看到:

image-20220311170652326

HttpUtils是我用来读取HttpServletRequest中主体内容的工具类,源码如下:

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
public class HttpUtils {

/**
* 将通知参数转化为字符串
* @param request
* @return
*/
public static String readData(HttpServletRequest request) {
BufferedReader br = null;
try {
StringBuilder result = new StringBuilder();
br = request.getReader();
for (String line; (line = br.readLine()) != null; ) {
if (result.length() > 0) {
result.append("\n");
}
result.append(line);
}
return result.toString();
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
if (br != null) {
try {
br.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}

对于签名和解密是如何实现感到好奇的朋友可以到SDK中查看,相关源码位置我也写在注释中了

简单说明一下:

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
public boolean verify(String serialNumber, byte[] message, String signature) {
if (!serialNumber.isEmpty() && message.length != 0 && !signature.isEmpty()) {
BigInteger serialNumber16Radix = new BigInteger(serialNumber, 16);
ConcurrentHashMap<BigInteger, X509Certificate> merchantCertificates = (ConcurrentHashMap)CertificatesManager.this.certificates.get(this.merchantId);
X509Certificate certificate = (X509Certificate)merchantCertificates.get(serialNumber16Radix);
if (certificate == null) {
CertificatesManager.log.error("商户证书为空,serialNumber:{}", serialNumber);
return false;
} else {
try {
Signature sign = Signature.getInstance("SHA256withRSA");
sign.initVerify(certificate);
sign.update(message);
return sign.verify(Base64.getDecoder().decode(signature));
} catch (NoSuchAlgorithmException var8) {
throw new RuntimeException("当前Java环境不支持SHA256withRSA", var8);
} catch (SignatureException var9) {
throw new RuntimeException("签名验证过程发生了错误", var9);
} catch (InvalidKeyException var10) {
throw new RuntimeException("无效的证书", var10);
}
}
} else {
throw new IllegalArgumentException("serialNumber或message或signature为空");
}
}

在这里进行获取证书换取公钥签名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private void setDecryptData(Notification notification) throws ParseException {
Resource resource = notification.getResource();
String getAssociateddData = "";
if (resource.getAssociatedData() != null) {
getAssociateddData = resource.getAssociatedData();
}

byte[] associatedData = getAssociateddData.getBytes(StandardCharsets.UTF_8);
byte[] nonce = resource.getNonce().getBytes(StandardCharsets.UTF_8);
String ciphertext = resource.getCiphertext();
AesUtil aesUtil = new AesUtil(this.apiV3Key);

String decryptData;
try {
decryptData = aesUtil.decryptToString(associatedData, nonce, ciphertext);
} catch (GeneralSecurityException var10) {
throw new ParseException("AES解密失败,resource:" + resource.toString(), var10);
}

notification.setDecryptData(decryptData);
}

凭借私钥获取AES口令,解密ciphertext中的内容

实测一下:

image-20220311170923166

resource内容是被加密过的

1
2
3
4
5
6
7
2022-03-11 17:11:23.379  INFO 1656 --- [nio-8080-exec-1] t.m.m.w.service.impl.WxPayServiceImpl    : 生成订单
2022-03-11 17:11:23.479 INFO 1656 --- [nio-8080-exec-1] t.m.m.w.service.impl.WxPayServiceImpl : 调用统一下单API
2022-03-11 17:11:23.523 INFO 1656 --- [nio-8080-exec-1] t.m.m.w.service.impl.WxPayServiceImpl : 请求参数 ===> {}{"amount":{"total":1,"currency":"CNY"},"mchid":"1621810542","out_trade_no":"ORDER_20220311171123529","appid":"wx32d4d97357b79746","description":"GBA游戏测评","notify_url":"http://maiqu.sh1.k9s.run:2271/api/wx-pay/native/notify"}
2022-03-11 17:11:23.926 INFO 1656 --- [nio-8080-exec-1] t.m.m.w.service.impl.WxPayServiceImpl : 成功, 返回结果 = {"code_url":"weixin://wxpay/bizpayurl?pr=ICd695Azz"}
2022-03-11 17:11:31.783 ERROR 1656 --- [nio-8080-exec-2] t.m.m.s.f.JWTAuthenticationTokenFilter : Token为空
2022-03-11 17:19:53.337 WARN 1656 --- [l-1 housekeeper] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Thread starvation or clock leap detected (housekeeper delta=8m29s873ms509µs200ns).
2022-03-11 17:19:53.338 INFO 1656 --- [nio-8080-exec-2] t.maiquer.metrichall.wxpay.api.WxPayAPI : 通知验签成功

解密内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"mchid": "1621***42",
"appid": "wx32d*****79746",
"out_trade_no": "ORDER_20220311171123529",
"transaction_id": "4200001348202203119819934409",
"trade_type": "NATIVE",
"trade_state": "SUCCESS",
"trade_state_desc": "支付成功",
"bank_type": "OTHERS",
"attach": "",
"success_time": "2022-03-11T17:11:31+08:00",
"payer": {
"openid": "o0F3X099H******Spqj5p8D-6TI"
},
"amount": {
"total": 1,
"payer_total": 1,
"currency": "CNY",
"payer_currency": "CNY"
}
}

总结

写到这里就告一段落了

相关的数据库表和实体类我没有提供,各位根据业务个性化设计,至于怎么使用微信支付SDK本文已交代的很清楚

后面还有退款、订单超时、下载账单等API。怎么使用都大差不差,无非组装请求体,使用SDK提供的HttpClient请求,省略繁琐的安全验证过程,得到返回结果…大家自己摸索,多说无益

需要完整实例源码的可私信我