Java

更新时间:2025.05.28

一、概述

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