跟我一起玩转微信支付

3,424

写在前面

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

如果你还不了微信第三方服务生态的,请先阅读一下微信与阿里云第三方服务的一些概念流程梳理,相信读过后你会对微信第三方服务生态有了一定的了解,下面可以按照要求准备开通微信支付的必备条件,并开通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文档

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

我写这篇文章的时间是

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依赖为

<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中:

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

随后配置到

@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依赖,对配置和实体之间更好的依赖:

<!--配置映射-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

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

接下来开始注册Bean

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

    /**
     * 加载商户私钥 <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提供的工具类,它可以帮助我们读取私钥:

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("无效的密钥格式");
        }
    }

获取验签器

    /**
     * 获取平台证书管理器,定时更新证书(默认值为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方法中可见:

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为空");
        }
    }
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

/**
     * 通过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;
}

获取支付回调请求处理器

/**
  * 构建微信支付回调请求处理器
  *
  * @param verifier
  * @return NotificationHandler
  */
@Bean
public NotificationHandler notificationHandler(Verifier verifier) {
    return new NotificationHandler(verifier,apiV3Key.getBytes(StandardCharsets.UTF_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实例已启动,下载更新证书线程也启动了,一切准备完毕,静候差遣

集中封装一些枚举类

@AllArgsConstructor
@Getter
public enum PayType {
    /**
     * 微信
     */
    WXPAY("微信"),


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

    /**
     * 类型
     */
    private final String type;
}
@AllArgsConstructor
@Getter
public enum OrderStatus {
    /**
     * 未支付
     */
    NOTPAY("未支付"),

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

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

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

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

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

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

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

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

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

    /**
     * 类型
     */
    private final String type;
}
@AllArgsConstructor
@Getter
public enum WxRefundStatus {

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

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

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

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

    /**
     * 类型
     */
    private final String type;
}
@AllArgsConstructor
@Getter
public enum WxTradeState {

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

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

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

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

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

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

封装响应消息

@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)注解可使得该类方法可以链式调用:

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

Native下单

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

{
	"mchid": "1900006XXX",
	"out_trade_no": "native12177525012014070332333",
	"appid": "wxdace645e0bc2cXXX",
	"description": "Image形象店-深圳腾大-QQ公仔",
	"notify_url": "https://weixin.qq.com/",
	"amount": {
		"total": 1,
		"currency": "CNY"
	}
}

返回示例:

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

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

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

    /**
     * 创建订单,调用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:

{
  "code": 0,
  "message": "成功",
  "data": {
    "codeUrl": "weixin://wxpay/bizpayurl?pr=HVPisQfzz",
    "orderNo": "ORDER_20220311155916957"
  }
}

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

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

<!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>

image-20220311160638752

取消订单

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

image-20220311160909354

{
	"mchid": "1230000109"
}

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

    /**
     * 关单接口的调用
     * <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字典

    /**
     * 可通过“微信支付订单号查询”和“商户订单号查询”两种方式查询订单详情
     * <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:

{
  "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提供的做法,这里我如法炮制

    /**
     * 支付通知<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中主体内容的工具类,源码如下:

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中查看,相关源码位置我也写在注释中了

简单说明一下:

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为空");
            }
        }

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

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内容是被加密过的

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  : 通知验签成功

解密内容:

{
	"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请求,省略繁琐的安全验证过程,得到返回结果...大家自己摸索,多说无益

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