微信支付v3

原创💡 Abner Mai 2021-09-29 Pay
  • 微信支付
  • 支付
  • Java
约 2658 字 大约 9 分钟

# 微信支付v3

此文章以小程序支付为例,上一篇文章微信支付流程讲的是微信支付的V2开发流程,V2与V3有些区别,如:sdk不一样、请求接口地址不一样。但开发流程方式基本一致。

# 准备工作

  • 小程序
    • 小程序 appId
  • 商户
    • 商户号
    • 商户私钥
    • 商户V3密钥
    • 商户证书序列号
  • 微信支付平台
    • 微信支付平台证书

# 商户号

image-20210929102841246

# 商户v3密钥

【商户平台】->【API安全】的页面设置该密钥。密钥的长度为32个字节。

# 商户证书 序列号

登录商户平台【API安全】->【API证书】->【查看证书】,可查看商户API证书序列号。

# 微信支付平台证书

V3版本支付,不能使用商户的公钥,所有的签名都必须使用由微信支付平台提供的证书,首次申请支付平台证书需要通过微信提供的方式获取。

获取方式:

a. 下载微信支付平台提供的jar包:

CertificateDownloader-1.1.jar

b. 通过执行相关语句获取证书

## 查看帮助
java -jar CertificateDownloader-1.1.jar -h

## 获取证书
java -jar CertificateDownloader-1.1.jar -f ./apiclient_key.pem  -k ** -m ** -o ./CertTrustChain.pem  -s **
1
2
3
4
5

参数介绍:

  • 商户的私钥文件,即 -f
  • 证书解密的密钥,即 -k
  • 商户号,即 -m
  • 保存证书的路径,即 -o
  • 商户证书的序列号,即 -s

:值得注意的是微信为了保证安全,商户平台支付证书是有一定的时效性的,并不能保证永久生效,所以需要在证书失效之后重新获取,但不推荐这样操作。

  • 证书到期后,必须更换。(目前是五年)
  • 证书到期前,例行更换。(每年一次)

# 微信支付官方sdk

微信官方提供的sdk:https://github.com/wechatpay-apiv3/wechatpay-apache-httpclient (opens new window)

代码结构:

wechatpay-apache-httpclient
|----com.wechat.pay.contrib.apache.httpclient
|	|----auth
|	|	AutoUpdateCertofocatesVerifier.class	## 用于自动更新微信支付平台商户证书(公钥)、验签类
|	|	CertificatesVerifier.class	## 公钥证书的检查、验签类
|	|	PrivateKeySigner.class	## 商户私钥签名类
|	|	Signer.class	## 签名接口
|	|	Verifier.class	## 验签接口
|	|	WechatPay2Credentials.class	## 可用于生成随机数、签名体的构造、获取商户的token,主要用于支付请求时对请求前的签名操作
|	|	WechatPayValidator.class	## 主要用于对响应操作的验签,验证请求是否符合微信支付要求
|	|----util
|	|	AesUtil.class	## 用于解密回调密文
|	|	PemUtil.class	## 用于解析证书
|	|	RsaCryptoUtil.class
|	Credentials.class	## 证书接口
|	SignatureExec.class	## 与微信支付平台交互类
|	Validator.class	## 验签接口,用于校验响应报文是否符合微信支付平台
|	WechatPayHttpClientBuilder.class	## 构建CloseableHttpClient
|	WechatPayUploadHttpPost.class	## 上传数据使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 开始接入

# 构建CloseableHttpClient

构建一个满足业务用于与微信支付平台交互的 CloseableHttpClient

 /**
     * 生成 httpclient
     *
     * @param merchantInfo 商户信息,承载商户号、商户Api与Apiv3密钥、商户证书序列号、公钥|私钥|首次微信支付平台证书地址
     */
    public static CloseableHttpClient generatedCloseableHttpClient(MerchantInfo merchantInfo) {
    	// 商户号
        String merchantId = merchantInfo.getMerchantId();
		// 加载商户私钥证书
        InputStream keyInputStream =
                WechatMiniAppPrePayOrderServiceImpl.class.getClassLoader().getResourceAsStream(merchantInfo.getMerchantKeyPem());
        PrivateKey privateKey = PemUtil.loadPrivateKey(keyInputStream);
        // 传递实体
        PrivateKeySigner privateKeySigner = new PrivateKeySigner(merchantInfo.getMerchantSerialNo(), privateKey);
        WechatPay2Credentials credentials = new WechatPay2Credentials(merchantInfo.getMerchantId(), privateKeySigner);
        // 构建自动更新微信支付平台证书实体
        AutoUpdateCertificatesVerifier autoUpdateCertificatesVerifier =
                new AutoUpdateCertificatesVerifier(credentials, merchantInfo.getMerchantApiV3Key().getBytes(StandardCharsets.UTF_8));
		// 构建微信支付交互客户端
        return WechatPayHttpClientBuilder
                .create()
                .withMerchant(merchantId, merchantInfo.getMerchantSerialNo(), privateKey)
                .withValidator(new WechatPay2Validator(autoUpdateCertificatesVerifier))
                .build();
    }
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

# 执行交互

使用 CloseableHttpClient 执行对应的接口,在SDK中,SignatureExec 类承接此类操作,如:

  • 代码执行:
 
    /**
     * 执行post方法,获取CloseableHttpClient,并执行
     *
     * @param uri                请求地址
     * @param merchantInfo		 商户信息
     * @param requestParam       请求参数
     * @return 结果
     * @throws IOException 调用异常
     */
    public static CloseableHttpResponse executePost(String uri, MerchantInfo merchantInfo, String requestParam) throws IOException {
        // 获取客户端
        CloseableHttpClient httpClient = generatedCloseableHttpClient(merchantInfo);
        HttpPost httpPost = new HttpPost(uri);
        httpPost.addHeader("Accept", "application/json");
        httpPost.addHeader("Content-type", "application/json; charset=utf-8");

        // 请求
        httpPost.setEntity(new StringEntity(requestParam, "UTF-8"));
        return httpClient.execute(httpPost);
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  • SDK处理
public class SignatureExec implements ClientExecChain {
  final ClientExecChain mainExec;
  final Credentials credentials;
  final Validator validator;

  SignatureExec(Credentials credentials, Validator validator, ClientExecChain mainExec) {
    this.credentials = credentials;
    this.validator = validator;
    this.mainExec = mainExec;
  }

  protected void convertToRepeatableResponseEntity(CloseableHttpResponse response)
      throws IOException {
    HttpEntity entity = response.getEntity();
    if (entity != null) {
      response.setEntity(new BufferedHttpEntity(entity));
    }
  }

  protected void convertToRepeatableRequestEntity(HttpRequestWrapper request) throws IOException {
    if (request instanceof HttpEntityEnclosingRequest) {
      HttpEntity entity = ((HttpEntityEnclosingRequest) request).getEntity();
      if (entity != null) {
        ((HttpEntityEnclosingRequest) request).setEntity(new BufferedHttpEntity(entity));
      }
    }
  }

  @Override
  public CloseableHttpResponse execute(HttpRoute route, HttpRequestWrapper request,
      HttpClientContext context, HttpExecutionAware execAware) throws IOException, HttpException {
    if (request.getTarget().getHostName().endsWith(".mch.weixin.qq.com")) {
      return executeWithSignature(route, request, context, execAware);
    } else {
      return mainExec.execute(route, request, context, execAware);
    }
  }

  private CloseableHttpResponse executeWithSignature(HttpRoute route, HttpRequestWrapper request,
      HttpClientContext context, HttpExecutionAware execAware) throws IOException, HttpException {
    // 上传类不需要消耗两次故不做转换
    if (!(request.getOriginal() instanceof WechatPayUploadHttpPost)) {
      convertToRepeatableRequestEntity(request);
    }
    // 添加认证信息
    request.addHeader("Authorization",
        credentials.getSchema() + " " + credentials.getToken(request));

    // 执行
    CloseableHttpResponse response = mainExec.execute(route, request, context, execAware);

    // 对成功应答验签
    StatusLine statusLine = response.getStatusLine();
    if (statusLine.getStatusCode() >= 200 && statusLine.getStatusCode() < 300) {
      convertToRepeatableResponseEntity(response);
      if (!validator.validate(response)) {
        throw new HttpException("应答的微信支付签名验证失败");
      }
    }
    return response;
  }
}
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

# 回调验签与参数解密

在支付系统中,涉及到了传递回调接口地址,目的是用于在支付或退款成功后,支付系统会将结果传递通知业务系统,但是回调接口在暴露出去的同时,就会面临被非支付系统调用的情况(接口攻击),如果误将这一类请求当成正常请求处理,则会导致业务数据异常。所以支付系统中也特别提醒需要对调用的请求进行验签处理。

且微信支付平台为了防止在平台调用回调接口的过程中,请求被拦截而造成的消息泄露,会将正式的消息体进行加密处理,我们在获取到参数之后,需要将参数进行解密即可。

# 回调验签

在我们主动请求微信支付系统时,SDK都会对响应进行验签处理,但是SDK中,并不提供对请求验签的处理封装,所以需要我们自行封装:

    /**
     * 验签
     *
     * @param request      请求
     * @param requestParam 请求参数
     * @param merchantInfo 商户信息
     * @throws Exception 异常
     */
    public static void validRequestSource(HttpServletRequest request, String requestParam, MerchantProperties merchantInfo) throws Exception {
        // 获取微信小程序应答签名、时间戳、随机串
        // 请求签名
        String wechatPaySignature = request.getHeader("Wechatpay-Signature");
        // 请求时间戳
        String wechatPayTimestamp = request.getHeader("Wechatpay-Timestamp");
        // 请求随机数
        String wechatPayNonce = request.getHeader("Wechatpay-Nonce");
        // 请求序列号
        String wechatPaySerial = request.getHeader("Wechatpay-Serial");

        // 这里是为了处理请求体参数的一个顺序而设置的
        WechatMiniAppPayNotifyMainDTO wechatMiniAppPayNotifyMainDTO =
                JSON.parseObject(requestParam, WechatMiniAppPayNotifyMainDTO.class);
        requestParam = JSON.toJSONString(BeanUtil.beanToMap(wechatMiniAppPayNotifyMainDTO));

        // 按要求转换消息体
        String message = Stream.of(wechatPayTimestamp, wechatPayNonce, requestParam)
                .collect(Collectors.joining("\n", "", "\n"));

        InputStream keyInputStream =
                WechatMiniAppPrePayOrderServiceImpl.class.getClassLoader().getResourceAsStream(merchantInfo.getMerchantKeyPem());
        PrivateKey privateKey = PemUtil.loadPrivateKey(keyInputStream);
        PrivateKeySigner privateKeySigner = new PrivateKeySigner(merchantInfo.getMerchantSerialNo(), privateKey);
        WechatPay2Credentials credentials = new WechatPay2Credentials(merchantInfo.getMerchantId(), privateKeySigner);
        AutoUpdateCertificatesVerifier autoUpdateCertificatesVerifier =
                new AutoUpdateCertificatesVerifier(credentials, merchantInfo.getMerchantApiV3Key().getBytes(StandardCharsets.UTF_8));
        // 签名验证
        boolean verify = autoUpdateCertificatesVerifier.verify(wechatPaySerial, message.getBytes(StandardCharsets.UTF_8), wechatPaySignature);
        if (!verify) {
            throw new BizException("当前请求签名验证失败,根据微信文档,判定当前请求为非微信支付发起");
        }
    }
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

# 参数解密

在微信提供的SDK中,有提供了对加密内容进行解密的工具类:AesUtil.class

我们只需使用即可:

  /**
     * 解密步骤如下:
     * (1)对加密串A做base64解码,得到加密串B
     * (2)对商户key做md5,得到32位小写key* ( key设置路径:微信商户平台(pay.weixin.qq.com)-->账户设置-->API安全-->密钥设置 )
     * <p>
     * (3)用key*对加密串B做AES-256-ECB解密(PKCS7Padding)
     *
     * @param merchantKey    apiv3密钥
     * @param ciphertext     加密信息
     * @param nonce          回调参数:加密使用的随机串
     * @param associatedData 回调参数:附加数据
     * @return 解密之后的映射关系
     */
    public static String decoderNotifyRequestInfo(byte[] merchantKey, String ciphertext, byte[] nonce, byte[] associatedData) {
        try {
            AesUtil aesUtil = new AesUtil(merchantKey);
            return aesUtil.decryptToString(associatedData, nonce, ciphertext);
        } catch (Exception e) {
            log.error("解密通知信息失败", e);
            throw new BizException("解密通知信息失败");
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 回调验签一直失败?

在使用SDk进行开发的过程中,参照微信支付平台对小程序支付通知回调验签的文档方法,对回调请求进行验签,但是发现一直验签失败,分析如下:

# 从请求中获取到的参数

{
    "summary": "支付成功",
    "event_type": "TRANSACTION.SUCCESS",
    "create_time": "2021-09-27T19:21:18+08:00",
    "resource": {
        "associated_data": "transaction",
        "ciphertext": "**",
        "original_type": "transaction",
        "nonce": "VovbLlSvF0KE",
        "algorithm": "AEAD_AES_256_GCM"
    },
    "resource_type": "**",
    "id": "**"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 对消息体进行签名

需同时获取时间戳和随机数,格式为:

应答时间戳\n
应答随机串\n
应答报文主体\n
1
2
3

签名前的消息体封装:

1632741921
QaA8L6Mx3OWaIvWhpMTbHZUN697ArQM4
{"summary":"支付成功","event_type":"TRANSACTION.SUCCESS","create_time":"2021-09-27T19:21:18+08:00","resource":{"associated_data":"transaction","ciphertext":"**","original_type":"transaction","nonce":"VovbLlSvF0KE","algorithm":"AEAD_AES_256_GCM"},"resource_type":"**","id":"**"}

1
2
3
4

注:\n为换行符,必须存在,不能是字符。

# 使用微信签名工具验签

这里不演示验签过程,因为这边是之后写的,不想太麻烦还要去生成模拟一遍,口述就可以了。

工具(微信支付平台提供):

image-20210929153758237

验证签名发现一直失败,故咨询微信技术人员

# 与技术支持的讨论

页面刷新,聊天记录没有保存,经过多番讨论,技术人员告知是接收到的消息体的字段乱序而导致了签名与微信支付平台签名不一致

微信支付平台签名的参数字段顺序:

{
    "id": "**",
    "create_time": "2021-09-27T19:21:18+08:00",
    "resource_type": "**",
    "event_type": "TRANSACTION.SUCCESS",
    "summary": "支付成功",
    "resource": {
        "original_type": "transaction",
        "algorithm": "AEAD_AES_256_GCM",
        "ciphertext": "**",
        "associated_data": "transaction",
        "nonce": "VovbLlSvF0KE"
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

注:去除字段间的空格。

经过一番修改,签名之后就可以与微信支付平台签名验签通过了。

# 与对方讨论过程中发现的问题

在讨论过程中,向对方索取对应请求的报文,但是一直发现对方提供的报文随机数,与当前请求的报文随机数不一致,得到的签名也一样不一致。

而后发现对方并没有根据唯一条件去查询,而是随意给你一个,导致了在沟通过程中一直认为了是对方签名的提供的随机字串与签名使用的随机字串不一致。

而实际上是在处理参数顺序时,没有关注到子对象的参数顺序导致的签名失败。

# 总结

与三方对接,一旦发现根据接口文档处理,忍让出现问题,要及时与三方对接人员进行反馈,寻求帮助,避免在此类问题上浪费太多时间。

# 参考文献

- [1] 微信支付开发文档 (opens new window)

上次编辑于: 2021年10月9日 17:12