Java

更新时间:2025.05.27

一、概述

本工具类 WXPayUtility 为使用 Java 接入微信支付的开发者提供了一系列实用的功能,包括 JSON 处理、密钥加载、加密签名、请求头构建、响应验证等。通过使用这个工具类,开发者可以更方便地完成与微信支付相关的开发工作。

二、安装(引入依赖的第三方库)

本工具类依赖以下第三方库:

  1. Google Gson:用于 JSON 数据的序列化和反序列化。

  2. OkHttp:用于 HTTP 请求处理。

你可以通过 Maven 或 Gradle 来引入这些依赖。

如果你使用的 Gradle,请在 build.gradle 中加入:

1implementation 'com.google.code.gson:gson:${VERSION}'
2implementation 'com.squareup.okhttp3:okhttp:${VERSION}'

如果你使用的 Maven,请在 pom.xml 中加入:

1<!-- Google Gson -->
2<dependency>
3    <groupId>com.google.code.gson</groupId>
4    <artifactId>gson</artifactId>
5    <version>${VERSION}</version>
6</dependency>
7<!-- OkHttp -->
8<dependency>
9    <groupId>com.squareup.okhttp3</groupId>
10    <artifactId>okhttp</artifactId>
11    <version>${VERSION}</version>
12</dependency>

三、必需的证书和密钥

运行 SDK 必需以下的商户身份信息,用于构造请求的签名和验证应答的签名:

、工具类代码

1package com.java.utils;
2
3import com.google.gson.ExclusionStrategy;
4import com.google.gson.FieldAttributes;
5import com.google.gson.Gson;
6import com.google.gson.GsonBuilder;
7import com.google.gson.JsonElement;
8import com.google.gson.JsonObject;
9import com.google.gson.JsonParser;
10import com.google.gson.JsonSyntaxException;
11import com.google.gson.annotations.Expose;
12import com.wechat.pay.java.core.util.GsonUtil;
13import okhttp3.Headers;
14import okhttp3.Response;
15import okio.BufferedSource;
16
17import javax.crypto.BadPaddingException;
18import javax.crypto.Cipher;
19import javax.crypto.IllegalBlockSizeException;
20import javax.crypto.NoSuchPaddingException;
21import java.io.IOException;
22import java.io.UncheckedIOException;
23import java.io.UnsupportedEncodingException;
24import java.net.URLEncoder;
25import java.nio.charset.StandardCharsets;
26import java.nio.file.Files;
27import java.nio.file.Paths;
28import java.security.InvalidKeyException;
29import java.security.KeyFactory;
30import java.security.NoSuchAlgorithmException;
31import java.security.PrivateKey;
32import java.security.PublicKey;
33import java.security.SecureRandom;
34import java.security.Signature;
35import java.security.SignatureException;
36import java.security.spec.InvalidKeySpecException;
37import java.security.spec.PKCS8EncodedKeySpec;
38import java.security.spec.X509EncodedKeySpec;
39import java.time.DateTimeException;
40import java.time.Duration;
41import java.time.Instant;
42import java.util.Base64;
43import java.util.Map;
44import java.util.Objects;
45
46public class WXPayUtility {
47  private static final Gson gson = new GsonBuilder()
48      .disableHtmlEscaping()
49      .addSerializationExclusionStrategy(new ExclusionStrategy() {
50        @Override
51        public boolean shouldSkipField(FieldAttributes fieldAttributes) {
52          final Expose expose = fieldAttributes.getAnnotation(Expose.class);
53          return expose != null && !expose.serialize();
54        }
55
56        @Override
57        public boolean shouldSkipClass(Class<?> aClass) {
58          return false;
59        }
60      })
61      .addDeserializationExclusionStrategy(new ExclusionStrategy() {
62        @Override
63        public boolean shouldSkipField(FieldAttributes fieldAttributes) {
64          final Expose expose = fieldAttributes.getAnnotation(Expose.class);
65          return expose != null && !expose.deserialize();
66        }
67
68        @Override
69        public boolean shouldSkipClass(Class<?> aClass) {
70          return false;
71        }
72      })
73      .create();
74  private static final char[] SYMBOLS =
75      "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
76  private static final SecureRandom random = new SecureRandom();
77
78  /**
79   * 将 Object 转换为 JSON 字符串
80   */
81  public static String toJson(Object object) {
82    return gson.toJson(object);
83  }
84
85  /**
86   * 将 JSON 字符串解析为特定类型的实例
87   */
88  public static <T> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException {
89    return gson.fromJson(json, classOfT);
90  }
91
92  /**
93   * 从公私钥文件路径中读取文件内容
94   *
95   * @param keyPath 文件路径
96   * @return 文件内容
97   */
98  private static String readKeyStringFromPath(String keyPath) {
99    try {
100      return new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8);
101    } catch (IOException e) {
102      throw new UncheckedIOException(e);
103    }
104  }
105
106  /**
107   * 读取 PKCS#8 格式的私钥字符串并加载为私钥对象
108   *
109   * @param keyString 私钥文件内容,以 -----BEGIN PRIVATE KEY----- 开头
110   * @return PrivateKey 对象
111   */
112  public static PrivateKey loadPrivateKeyFromString(String keyString) {
113    try {
114      keyString = keyString.replace("-----BEGIN PRIVATE KEY-----", "")
115          .replace("-----END PRIVATE KEY-----", "")
116          .replaceAll("\\s+", "");
117      return KeyFactory.getInstance("RSA").generatePrivate(
118          new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyString)));
119    } catch (NoSuchAlgorithmException e) {
120      throw new UnsupportedOperationException(e);
121    } catch (InvalidKeySpecException e) {
122      throw new IllegalArgumentException(e);
123    }
124  }
125
126  /**
127   * 从 PKCS#8 格式的私钥文件中加载私钥
128   *
129   * @param keyPath 私钥文件路径
130   * @return PrivateKey 对象
131   */
132  public static PrivateKey loadPrivateKeyFromPath(String keyPath) {
133    return loadPrivateKeyFromString(readKeyStringFromPath(keyPath));
134  }
135
136  /**
137   * 读取 PKCS#8 格式的公钥字符串并加载为公钥对象
138   *
139   * @param keyString 公钥文件内容,以 -----BEGIN PUBLIC KEY----- 开头
140   * @return PublicKey 对象
141   */
142  public static PublicKey loadPublicKeyFromString(String keyString) {
143    try {
144      keyString = keyString.replace("-----BEGIN PUBLIC KEY-----", "")
145          .replace("-----END PUBLIC KEY-----", "")
146          .replaceAll("\\s+", "");
147      return KeyFactory.getInstance("RSA").generatePublic(
148          new X509EncodedKeySpec(Base64.getDecoder().decode(keyString)));
149    } catch (NoSuchAlgorithmException e) {
150      throw new UnsupportedOperationException(e);
151    } catch (InvalidKeySpecException e) {
152      throw new IllegalArgumentException(e);
153    }
154  }
155
156  /**
157   * 从 PKCS#8 格式的公钥文件中加载公钥
158   *
159   * @param keyPath 公钥文件路径
160   * @return PublicKey 对象
161   */
162  public static PublicKey loadPublicKeyFromPath(String keyPath) {
163    return loadPublicKeyFromString(readKeyStringFromPath(keyPath));
164  }
165
166  /**
167   * 创建指定长度的随机字符串,字符集为[0-9a-zA-Z],可用于安全相关用途
168   */
169  public static String createNonce(int length) {
170    char[] buf = new char[length];
171    for (int i = 0; i < length; ++i) {
172      buf[i] = SYMBOLS[random.nextInt(SYMBOLS.length)];
173    }
174    return new String(buf);
175  }
176
177  /**
178   * 使用公钥按照 RSA_PKCS1_OAEP_PADDING 算法进行加密
179   *
180   * @param publicKey 加密用公钥对象
181   * @param plaintext 待加密明文
182   * @return 加密后密文
183   */
184  public static String encrypt(PublicKey publicKey, String plaintext) {
185    final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";
186
187    try {
188      Cipher cipher = Cipher.getInstance(transformation);
189      cipher.init(Cipher.ENCRYPT_MODE, publicKey);
190      return Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)));
191    } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
192      throw new IllegalArgumentException("The current Java environment does not support " + transformation, e);
193    } catch (InvalidKeyException e) {
194      throw new IllegalArgumentException("RSA encryption using an illegal publicKey", e);
195    } catch (BadPaddingException | IllegalBlockSizeException e) {
196      throw new IllegalArgumentException("Plaintext is too long", e);
197    }
198  }
199
200  /**
201   * 使用私钥按照指定算法进行签名
202   *
203   * @param message 待签名串
204   * @param algorithm 签名算法,如 SHA256withRSA
205   * @param privateKey 签名用私钥对象
206   * @return 签名结果
207   */
208  public static String sign(String message, String algorithm, PrivateKey privateKey) {
209    byte[] sign;
210    try {
211      Signature signature = Signature.getInstance(algorithm);
212      signature.initSign(privateKey);
213      signature.update(message.getBytes(StandardCharsets.UTF_8));
214      sign = signature.sign();
215    } catch (NoSuchAlgorithmException e) {
216      throw new UnsupportedOperationException("The current Java environment does not support " + algorithm, e);
217    } catch (InvalidKeyException e) {
218      throw new IllegalArgumentException(algorithm + " signature uses an illegal privateKey.", e);
219    } catch (SignatureException e) {
220      throw new RuntimeException("An error occurred during the sign process.", e);
221    }
222    return Base64.getEncoder().encodeToString(sign);
223  }
224
225  /**
226   * 使用公钥按照特定算法验证签名
227   *
228   * @param message 待签名串
229   * @param signature 待验证的签名内容
230   * @param algorithm 签名算法,如:SHA256withRSA
231   * @param publicKey 验签用公钥对象
232   * @return 签名验证是否通过
233   */
234  public static boolean verify(String message, String signature, String algorithm,
235                               PublicKey publicKey) {
236    try {
237      Signature sign = Signature.getInstance(algorithm);
238      sign.initVerify(publicKey);
239      sign.update(message.getBytes(StandardCharsets.UTF_8));
240      return sign.verify(Base64.getDecoder().decode(signature));
241    } catch (SignatureException e) {
242      return false;
243    } catch (InvalidKeyException e) {
244      throw new IllegalArgumentException("verify uses an illegal publickey.", e);
245    } catch (NoSuchAlgorithmException e) {
246      throw new UnsupportedOperationException("The current Java environment does not support" + algorithm, e);
247    }
248  }
249
250  /**
251   * 根据微信支付APIv3请求签名规则构造 Authorization 签名
252   *
253   * @param mchid 商户号
254   * @param certificateSerialNo 商户API证书序列号
255   * @param privateKey 商户API证书私钥
256   * @param method 请求接口的HTTP方法,请使用全大写表述,如 GET、POST、PUT、DELETE
257   * @param uri 请求接口的URL
258   * @param body 请求接口的Body
259   * @return 构造好的微信支付APIv3 Authorization 头
260   */
261  public static String buildAuthorization(String mchid, String certificateSerialNo,
262                                          PrivateKey privateKey,
263                                          String method, String uri, String body) {
264    String nonce = createNonce(32);
265    long timestamp = Instant.now().getEpochSecond();
266
267    String message = String.format("%s\n%s\n%d\n%s\n%s\n", method, uri, timestamp, nonce,
268        body == null ? "" : body);
269
270    String signature = sign(message, "SHA256withRSA", privateKey);
271
272    return String.format(
273        "WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\"," +
274            "timestamp=\"%d\",serial_no=\"%s\"",
275        mchid, nonce, signature, timestamp, certificateSerialNo);
276  }
277
278  /**
279   * 对参数进行 URL 编码
280   *
281   * @param content 参数内容
282   * @return 编码后的内容
283   */
284  public static String urlEncode(String content) {
285    try {
286      return URLEncoder.encode(content, StandardCharsets.UTF_8.name());
287    } catch (UnsupportedEncodingException e) {
288      throw new RuntimeException(e);
289    }
290  }
291
292  /**
293   * 对参数Map进行 URL 编码,生成 QueryString
294   *
295   * @param params Query参数Map
296   * @return QueryString
297   */
298  public static String urlEncode(Map<String, Object> params) {
299    if (params == null || params.isEmpty()) {
300      return "";
301    }
302
303    int index = 0;
304    StringBuilder result = new StringBuilder();
305    for (Map.Entry<String, Object> entry : params.entrySet()) {
306      result.append(entry.getKey())
307          .append("=")
308          .append(urlEncode(entry.getValue().toString()));
309      index++;
310      if (index < params.size()) {
311        result.append("&");
312      }
313    }
314    return result.toString();
315  }
316
317  /**
318   * 从应答中提取 Body
319   *
320   * @param response HTTP 请求应答对象
321   * @return 应答中的Body内容,Body为空时返回空字符串
322   */
323  public static String extractBody(Response response) {
324    if (response.body() == null) {
325      return "";
326    }
327
328    try {
329      BufferedSource source = response.body().source();
330      return source.readUtf8();
331    } catch (IOException e) {
332      throw new RuntimeException(String.format("An error occurred during reading response body. Status: %d", response.code()), e);
333    }
334  }
335
336  /**
337   * 根据微信支付APIv3应答验签规则对应答签名进行验证,验证不通过时抛出异常
338   *
339   * @param wechatpayPublicKeyId 微信支付公钥ID
340   * @param wechatpayPublicKey 微信支付公钥对象
341   * @param headers 微信支付应答 Header 列表
342   * @param body 微信支付应答 Body
343   */
344  public static void validateResponse(String wechatpayPublicKeyId, PublicKey wechatpayPublicKey,
345                                      Headers headers,
346                                      String body) {
347    String timestamp = headers.get("Wechatpay-Timestamp");
348    try {
349      Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
350      // 拒绝过期请求
351      if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
352        throw new IllegalArgumentException(
353            String.format("Validate http response,timestamp[%s] of httpResponse is expires, "
354                    + "request-id[%s]",
355                timestamp, headers.get("Request-ID")));
356      }
357    } catch (DateTimeException | NumberFormatException e) {
358      throw new IllegalArgumentException(
359          String.format("Validate http response,timestamp[%s] of httpResponse is invalid, " +
360                  "request-id[%s]", timestamp,
361              headers.get("Request-ID")));
362    }
363    String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
364        body == null ? "" : body);
365    String serialNumber = headers.get("Wechatpay-Serial");
366    if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
367      throw new IllegalArgumentException(
368          String.format("Invalid Wechatpay-Serial, Local: %s, Remote: %s", wechatpayPublicKeyId,
369              serialNumber));
370    }
371    String signature = headers.get("Wechatpay-Signature");
372
373    boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
374    if (!success) {
375      throw new IllegalArgumentException(
376          String.format("Validate response failed,the WechatPay signature is incorrect.%n"
377                  + "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]",
378              headers.get("Request-ID"), headers, body));
379    }
380  }
381
382  /**
383   * 微信支付API错误异常,发送HTTP请求成功,但返回状态码不是 2XX 时抛出本异常
384   */
385  public static class ApiException extends RuntimeException {
386    private static final long serialVersionUID = 2261086748874802175L;
387
388    private final int statusCode;
389    private final String body;
390    private final Headers headers;
391    private final String errorCode;
392    private final String errorMessage;
393
394    public ApiException(int statusCode, String body, Headers headers) {
395      super(String.format("微信支付API访问失败,StatusCode: [%s], Body: [%s], Headers: [%s]", statusCode, body, headers));
396      this.statusCode = statusCode;
397      this.body = body;
398      this.headers = headers;
399
400      if (body != null && !body.isEmpty()) {
401        JsonElement code;
402        JsonElement message;
403
404        try {
405          JsonObject jsonObject = GsonUtil.getGson().fromJson(body, JsonObject.class);
406          code = jsonObject.get("code");
407          message = jsonObject.get("message");
408        } catch (JsonSyntaxException ignored) {
409          code = null;
410          message = null;
411        }
412        this.errorCode = code == null ? null : code.getAsString();
413        this.errorMessage = message == null ? null : message.getAsString();
414      } else {
415        this.errorCode = null;
416        this.errorMessage = null;
417      }
418    }
419
420    /**
421     * 获取 HTTP 应答状态码
422     */
423    public int getStatusCode() {
424      return statusCode;
425    }
426
427    /**
428     * 获取 HTTP 应答包体内容
429     */
430    public String getBody() {
431      return body;
432    }
433
434    /**
435     * 获取 HTTP 应答 Header
436     */
437    public Headers getHeaders() {
438      return headers;
439    }
440
441    /**
442     * 获取 错误码 (错误应答中的 code 字段)
443     */
444    public String getErrorCode() {
445      return errorCode;
446    }
447
448    /**
449     * 获取 错误消息 (错误应答中的 message 字段)
450     */
451    public String getErrorMessage() {
452      return errorMessage;
453    }
454  }
455}
456