Java
更新时间:2025.05.27一、概述
本工具类 WXPayUtility 为使用 Java 接入微信支付的开发者提供了一系列实用的功能,包括 JSON 处理、密钥加载、加密签名、请求头构建、响应验证等。通过使用这个工具类,开发者可以更方便地完成与微信支付相关的开发工作。
二、安装(引入依赖的第三方库)
本工具类依赖以下第三方库:
Google Gson:用于 JSON 数据的序列化和反序列化。
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.JsonSyntaxException; 10import com.google.gson.annotations.Expose; 11import com.google.gson.annotations.SerializedName; 12import java.util.List; 13import java.util.Map.Entry; 14import okhttp3.Headers; 15import okhttp3.Response; 16import okio.BufferedSource; 17 18import javax.crypto.BadPaddingException; 19import javax.crypto.Cipher; 20import javax.crypto.IllegalBlockSizeException; 21import javax.crypto.NoSuchPaddingException; 22import javax.crypto.spec.GCMParameterSpec; 23import javax.crypto.spec.SecretKeySpec; 24import java.io.IOException; 25import java.io.UncheckedIOException; 26import java.io.UnsupportedEncodingException; 27import java.net.URLEncoder; 28import java.nio.charset.StandardCharsets; 29import java.nio.file.Files; 30import java.nio.file.Paths; 31import java.security.InvalidAlgorithmParameterException; 32import java.security.InvalidKeyException; 33import java.security.KeyFactory; 34import java.security.NoSuchAlgorithmException; 35import java.security.PrivateKey; 36import java.security.PublicKey; 37import java.security.SecureRandom; 38import java.security.Signature; 39import java.security.SignatureException; 40import java.security.spec.InvalidKeySpecException; 41import java.security.spec.PKCS8EncodedKeySpec; 42import java.security.spec.X509EncodedKeySpec; 43import java.time.DateTimeException; 44import java.time.Duration; 45import java.time.Instant; 46import java.util.Base64; 47import java.util.HashMap; 48import java.util.Map; 49import java.util.Objects; 50import java.security.MessageDigest; 51import java.io.InputStream; 52import org.bouncycastle.crypto.digests.SM3Digest; 53import org.bouncycastle.jce.provider.BouncyCastleProvider; 54import java.security.Security; 55 56public class WXPayUtility { 57 private static final Gson gson = new GsonBuilder() 58 .disableHtmlEscaping() 59 .addSerializationExclusionStrategy(new ExclusionStrategy() { 60 @Override 61 public boolean shouldSkipField(FieldAttributes fieldAttributes) { 62 final Expose expose = fieldAttributes.getAnnotation(Expose.class); 63 return expose != null && !expose.serialize(); 64 } 65 66 @Override 67 public boolean shouldSkipClass(Class<?> aClass) { 68 return false; 69 } 70 }) 71 .addDeserializationExclusionStrategy(new ExclusionStrategy() { 72 @Override 73 public boolean shouldSkipField(FieldAttributes fieldAttributes) { 74 final Expose expose = fieldAttributes.getAnnotation(Expose.class); 75 return expose != null && !expose.deserialize(); 76 } 77 78 @Override 79 public boolean shouldSkipClass(Class<?> aClass) { 80 return false; 81 } 82 }) 83 .create(); 84 private static final char[] SYMBOLS = 85 "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); 86 private static final SecureRandom random = new SecureRandom(); 87 88 /** 89 * 将 Object 转换为 JSON 字符串 90 */ 91 public static String toJson(Object object) { 92 return gson.toJson(object); 93 } 94 95 /** 96 * 将 JSON 字符串解析为特定类型的实例 97 */ 98 public static <T> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException { 99 return gson.fromJson(json, classOfT); 100 } 101 102 /** 103 * 从公私钥文件路径中读取文件内容 104 * 105 * @param keyPath 文件路径 106 * @return 文件内容 107 */ 108 private static String readKeyStringFromPath(String keyPath) { 109 try { 110 return new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); 111 } catch (IOException e) { 112 throw new UncheckedIOException(e); 113 } 114 } 115 116 /** 117 * 读取 PKCS#8 格式的私钥字符串并加载为私钥对象 118 * 119 * @param keyString 私钥文件内容,以 -----BEGIN PRIVATE KEY----- 开头 120 * @return PrivateKey 对象 121 */ 122 public static PrivateKey loadPrivateKeyFromString(String keyString) { 123 try { 124 keyString = keyString.replace("-----BEGIN PRIVATE KEY-----", "") 125 .replace("-----END PRIVATE KEY-----", "") 126 .replaceAll("\\s+", ""); 127 return KeyFactory.getInstance("RSA").generatePrivate( 128 new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyString))); 129 } catch (NoSuchAlgorithmException e) { 130 throw new UnsupportedOperationException(e); 131 } catch (InvalidKeySpecException e) { 132 throw new IllegalArgumentException(e); 133 } 134 } 135 136 /** 137 * 从 PKCS#8 格式的私钥文件中加载私钥 138 * 139 * @param keyPath 私钥文件路径 140 * @return PrivateKey 对象 141 */ 142 public static PrivateKey loadPrivateKeyFromPath(String keyPath) { 143 return loadPrivateKeyFromString(readKeyStringFromPath(keyPath)); 144 } 145 146 /** 147 * 读取 PKCS#8 格式的公钥字符串并加载为公钥对象 148 * 149 * @param keyString 公钥文件内容,以 -----BEGIN PUBLIC KEY----- 开头 150 * @return PublicKey 对象 151 */ 152 public static PublicKey loadPublicKeyFromString(String keyString) { 153 try { 154 keyString = keyString.replace("-----BEGIN PUBLIC KEY-----", "") 155 .replace("-----END PUBLIC KEY-----", "") 156 .replaceAll("\\s+", ""); 157 return KeyFactory.getInstance("RSA").generatePublic( 158 new X509EncodedKeySpec(Base64.getDecoder().decode(keyString))); 159 } catch (NoSuchAlgorithmException e) { 160 throw new UnsupportedOperationException(e); 161 } catch (InvalidKeySpecException e) { 162 throw new IllegalArgumentException(e); 163 } 164 } 165 166 /** 167 * 从 PKCS#8 格式的公钥文件中加载公钥 168 * 169 * @param keyPath 公钥文件路径 170 * @return PublicKey 对象 171 */ 172 public static PublicKey loadPublicKeyFromPath(String keyPath) { 173 return loadPublicKeyFromString(readKeyStringFromPath(keyPath)); 174 } 175 176 /** 177 * 创建指定长度的随机字符串,字符集为[0-9a-zA-Z],可用于安全相关用途 178 */ 179 public static String createNonce(int length) { 180 char[] buf = new char[length]; 181 for (int i = 0; i < length; ++i) { 182 buf[i] = SYMBOLS[random.nextInt(SYMBOLS.length)]; 183 } 184 return new String(buf); 185 } 186 187 /** 188 * 使用公钥按照 RSA_PKCS1_OAEP_PADDING 算法进行加密 189 * 190 * @param publicKey 加密用公钥对象 191 * @param plaintext 待加密明文 192 * @return 加密后密文 193 */ 194 public static String encrypt(PublicKey publicKey, String plaintext) { 195 final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"; 196 197 try { 198 Cipher cipher = Cipher.getInstance(transformation); 199 cipher.init(Cipher.ENCRYPT_MODE, publicKey); 200 return Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8))); 201 } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { 202 throw new IllegalArgumentException("The current Java environment does not support " + transformation, e); 203 } catch (InvalidKeyException e) { 204 throw new IllegalArgumentException("RSA encryption using an illegal publicKey", e); 205 } catch (BadPaddingException | IllegalBlockSizeException e) { 206 throw new IllegalArgumentException("Plaintext is too long", e); 207 } 208 } 209 210 /** 211 * 使用私钥按照 RSA_PKCS1_OAEP_PADDING 算法进行解密 212 * 213 * @param privateKey 解密用私钥对象 214 * @param ciphertext 待解密密文(Base64编码的字符串) 215 * @return 解密后明文 216 */ 217 public static String rsaOaepDecrypt(PrivateKey privateKey, String ciphertext) { 218 final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"; 219 220 try { 221 Cipher cipher = Cipher.getInstance(transformation); 222 cipher.init(Cipher.DECRYPT_MODE, privateKey); 223 byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext)); 224 return new String(decryptedBytes, StandardCharsets.UTF_8); 225 } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { 226 throw new IllegalArgumentException("The current Java environment does not support " + transformation, e); 227 } catch (InvalidKeyException e) { 228 throw new IllegalArgumentException("RSA decryption using an illegal privateKey", e); 229 } catch (BadPaddingException | IllegalBlockSizeException e) { 230 throw new IllegalArgumentException("Ciphertext decryption failed", e); 231 } 232 } 233 234 public static String aesAeadDecrypt(byte[] key, byte[] associatedData, byte[] nonce, 235 byte[] ciphertext) { 236 final String transformation = "AES/GCM/NoPadding"; 237 final String algorithm = "AES"; 238 final int tagLengthBit = 128; 239 240 try { 241 Cipher cipher = Cipher.getInstance(transformation); 242 cipher.init( 243 Cipher.DECRYPT_MODE, 244 new SecretKeySpec(key, algorithm), 245 new GCMParameterSpec(tagLengthBit, nonce)); 246 if (associatedData != null) { 247 cipher.updateAAD(associatedData); 248 } 249 return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); 250 } catch (InvalidKeyException 251 | InvalidAlgorithmParameterException 252 | BadPaddingException 253 | IllegalBlockSizeException 254 | NoSuchAlgorithmException 255 | NoSuchPaddingException e) { 256 throw new IllegalArgumentException(String.format("AesAeadDecrypt with %s Failed", 257 transformation), e); 258 } 259 } 260 261 /** 262 * 使用私钥按照指定算法进行签名 263 * 264 * @param message 待签名串 265 * @param algorithm 签名算法,如 SHA256withRSA 266 * @param privateKey 签名用私钥对象 267 * @return 签名结果 268 */ 269 public static String sign(String message, String algorithm, PrivateKey privateKey) { 270 byte[] sign; 271 try { 272 Signature signature = Signature.getInstance(algorithm); 273 signature.initSign(privateKey); 274 signature.update(message.getBytes(StandardCharsets.UTF_8)); 275 sign = signature.sign(); 276 } catch (NoSuchAlgorithmException e) { 277 throw new UnsupportedOperationException("The current Java environment does not support " + algorithm, e); 278 } catch (InvalidKeyException e) { 279 throw new IllegalArgumentException(algorithm + " signature uses an illegal privateKey.", e); 280 } catch (SignatureException e) { 281 throw new RuntimeException("An error occurred during the sign process.", e); 282 } 283 return Base64.getEncoder().encodeToString(sign); 284 } 285 286 /** 287 * 使用公钥按照特定算法验证签名 288 * 289 * @param message 待签名串 290 * @param signature 待验证的签名内容 291 * @param algorithm 签名算法,如:SHA256withRSA 292 * @param publicKey 验签用公钥对象 293 * @return 签名验证是否通过 294 */ 295 public static boolean verify(String message, String signature, String algorithm, 296 PublicKey publicKey) { 297 try { 298 Signature sign = Signature.getInstance(algorithm); 299 sign.initVerify(publicKey); 300 sign.update(message.getBytes(StandardCharsets.UTF_8)); 301 return sign.verify(Base64.getDecoder().decode(signature)); 302 } catch (SignatureException e) { 303 return false; 304 } catch (InvalidKeyException e) { 305 throw new IllegalArgumentException("verify uses an illegal publickey.", e); 306 } catch (NoSuchAlgorithmException e) { 307 throw new UnsupportedOperationException("The current Java environment does not support" + algorithm, e); 308 } 309 } 310 311 /** 312 * 根据微信支付APIv3请求签名规则构造 Authorization 签名 313 * 314 * @param mchid 商户号 315 * @param certificateSerialNo 商户API证书序列号 316 * @param privateKey 商户API证书私钥 317 * @param method 请求接口的HTTP方法,请使用全大写表述,如 GET、POST、PUT、DELETE 318 * @param uri 请求接口的URL 319 * @param body 请求接口的Body 320 * @return 构造好的微信支付APIv3 Authorization 头 321 */ 322 public static String buildAuthorization(String mchid, String certificateSerialNo, 323 PrivateKey privateKey, 324 String method, String uri, String body) { 325 String nonce = createNonce(32); 326 long timestamp = Instant.now().getEpochSecond(); 327 328 String message = String.format("%s\n%s\n%d\n%s\n%s\n", method, uri, timestamp, nonce, 329 body == null ? "" : body); 330 331 String signature = sign(message, "SHA256withRSA", privateKey); 332 333 return String.format( 334 "WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\"," + 335 "timestamp=\"%d\",serial_no=\"%s\"", 336 mchid, nonce, signature, timestamp, certificateSerialNo); 337 } 338 339 /** 340 * 计算输入流的哈希值 341 * 342 * @param inputStream 输入流 343 * @param algorithm 哈希算法名称,如 "SHA-256", "SHA-1" 344 * @return 哈希值的十六进制字符串 345 */ 346 private static String calculateHash(InputStream inputStream, String algorithm) { 347 try { 348 MessageDigest digest = MessageDigest.getInstance(algorithm); 349 byte[] buffer = new byte[8192]; 350 int bytesRead; 351 while ((bytesRead = inputStream.read(buffer)) != -1) { 352 digest.update(buffer, 0, bytesRead); 353 } 354 byte[] hashBytes = digest.digest(); 355 StringBuilder hexString = new StringBuilder(); 356 for (byte b : hashBytes) { 357 String hex = Integer.toHexString(0xff & b); 358 if (hex.length() == 1) { 359 hexString.append('0'); 360 } 361 hexString.append(hex); 362 } 363 return hexString.toString(); 364 } catch (NoSuchAlgorithmException e) { 365 throw new UnsupportedOperationException(algorithm + " algorithm not available", e); 366 } catch (IOException e) { 367 throw new RuntimeException("Error reading from input stream", e); 368 } 369 } 370 371 /** 372 * 计算输入流的 SHA256 哈希值 373 * 374 * @param inputStream 输入流 375 * @return SHA256 哈希值的十六进制字符串 376 */ 377 public static String sha256(InputStream inputStream) { 378 return calculateHash(inputStream, "SHA-256"); 379 } 380 381 /** 382 * 计算输入流的 SHA1 哈希值 383 * 384 * @param inputStream 输入流 385 * @return SHA1 哈希值的十六进制字符串 386 */ 387 public static String sha1(InputStream inputStream) { 388 return calculateHash(inputStream, "SHA-1"); 389 } 390 391 /** 392 * 计算输入流的 SM3 哈希值 393 * 394 * @param inputStream 输入流 395 * @return SM3 哈希值的十六进制字符串 396 */ 397 public static String sm3(InputStream inputStream) { 398 // 确保Bouncy Castle Provider已注册 399 if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { 400 Security.addProvider(new BouncyCastleProvider()); 401 } 402 403 try { 404 SM3Digest digest = new SM3Digest(); 405 byte[] buffer = new byte[8192]; 406 int bytesRead; 407 while ((bytesRead = inputStream.read(buffer)) != -1) { 408 digest.update(buffer, 0, bytesRead); 409 } 410 byte[] hashBytes = new byte[digest.getDigestSize()]; 411 digest.doFinal(hashBytes, 0); 412 413 StringBuilder hexString = new StringBuilder(); 414 for (byte b : hashBytes) { 415 String hex = Integer.toHexString(0xff & b); 416 if (hex.length() == 1) { 417 hexString.append('0'); 418 } 419 hexString.append(hex); 420 } 421 return hexString.toString(); 422 } catch (IOException e) { 423 throw new RuntimeException("Error reading from input stream", e); 424 } 425 } 426 427 /** 428 * 对参数进行 URL 编码 429 * 430 * @param content 参数内容 431 * @return 编码后的内容 432 */ 433 public static String urlEncode(String content) { 434 try { 435 return URLEncoder.encode(content, StandardCharsets.UTF_8.name()); 436 } catch (UnsupportedEncodingException e) { 437 throw new RuntimeException(e); 438 } 439 } 440 441 /** 442 * 对参数Map进行 URL 编码,生成 QueryString 443 * 444 * @param params Query参数Map 445 * @return QueryString 446 */ 447 public static String urlEncode(Map<String, Object> params) { 448 if (params == null || params.isEmpty()) { 449 return ""; 450 } 451 452 StringBuilder result = new StringBuilder(); 453 for (Entry<String, Object> entry : params.entrySet()) { 454 if (entry.getValue() == null) { 455 continue; 456 } 457 458 String key = entry.getKey(); 459 Object value = entry.getValue(); 460 if (value instanceof List) { 461 List<?> list = (List<?>) entry.getValue(); 462 for (Object temp : list) { 463 appendParam(result, key, temp); 464 } 465 } else { 466 appendParam(result, key, value); 467 } 468 } 469 return result.toString(); 470 } 471 472 /** 473 * 将键值对 放入返回结果 474 * 475 * @param result 返回的query string 476 * @param key 属性 477 * @param value 属性值 478 */ 479 private static void appendParam(StringBuilder result, String key, Object value) { 480 if (result.length() > 0) { 481 result.append("&"); 482 } 483 484 String valueString; 485 // 如果是基本类型、字符串或枚举,直接转换;如果是对象,序列化为JSON 486 if (value instanceof String || value instanceof Number || 487 value instanceof Boolean || value instanceof Enum) { 488 valueString = value.toString(); 489 } else { 490 valueString = toJson(value); 491 } 492 493 result.append(key) 494 .append("=") 495 .append(urlEncode(valueString)); 496 } 497 498 /** 499 * 从应答中提取 Body 500 * 501 * @param response HTTP 请求应答对象 502 * @return 应答中的Body内容,Body为空时返回空字符串 503 */ 504 public static String extractBody(Response response) { 505 if (response.body() == null) { 506 return ""; 507 } 508 509 try { 510 BufferedSource source = response.body().source(); 511 return source.readUtf8(); 512 } catch (IOException e) { 513 throw new RuntimeException(String.format("An error occurred during reading response body. " + 514 "Status: %d", response.code()), e); 515 } 516 } 517 518 /** 519 * 根据微信支付APIv3应答验签规则对应答签名进行验证,验证不通过时抛出异常 520 * 521 * @param wechatpayPublicKeyId 微信支付公钥ID 522 * @param wechatpayPublicKey 微信支付公钥对象 523 * @param headers 微信支付应答 Header 列表 524 * @param body 微信支付应答 Body 525 */ 526 public static void validateResponse(String wechatpayPublicKeyId, PublicKey wechatpayPublicKey, 527 Headers headers, 528 String body) { 529 String timestamp = headers.get("Wechatpay-Timestamp"); 530 String requestId = headers.get("Request-ID"); 531 try { 532 Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp)); 533 // 拒绝过期请求 534 if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) { 535 throw new IllegalArgumentException( 536 String.format("Validate response failed, timestamp[%s] is expired, request-id[%s]", 537 timestamp, requestId)); 538 } 539 } catch (DateTimeException | NumberFormatException e) { 540 throw new IllegalArgumentException( 541 String.format("Validate response failed, timestamp[%s] is invalid, request-id[%s]", 542 timestamp, requestId)); 543 } 544 String serialNumber = headers.get("Wechatpay-Serial"); 545 if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) { 546 throw new IllegalArgumentException( 547 String.format("Validate response failed, Invalid Wechatpay-Serial, Local: %s, Remote: " + 548 "%s", wechatpayPublicKeyId, serialNumber)); 549 } 550 551 String signature = headers.get("Wechatpay-Signature"); 552 String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"), 553 body == null ? "" : body); 554 555 boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey); 556 if (!success) { 557 throw new IllegalArgumentException( 558 String.format("Validate response failed,the WechatPay signature is incorrect.%n" 559 + "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]", 560 headers.get("Request-ID"), headers, body)); 561 } 562 } 563 564 /** 565 * 根据微信支付APIv3通知验签规则对通知签名进行验证,验证不通过时抛出异常 566 * @param wechatpayPublicKeyId 微信支付公钥ID 567 * @param wechatpayPublicKey 微信支付公钥对象 568 * @param headers 微信支付通知 Header 列表 569 * @param body 微信支付通知 Body 570 */ 571 public static void validateNotification(String wechatpayPublicKeyId, 572 PublicKey wechatpayPublicKey, Headers headers, 573 String body) { 574 String timestamp = headers.get("Wechatpay-Timestamp"); 575 try { 576 Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp)); 577 // 拒绝过期请求 578 if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) { 579 throw new IllegalArgumentException( 580 String.format("Validate notification failed, timestamp[%s] is expired", timestamp)); 581 } 582 } catch (DateTimeException | NumberFormatException e) { 583 throw new IllegalArgumentException( 584 String.format("Validate notification failed, timestamp[%s] is invalid", timestamp)); 585 } 586 String serialNumber = headers.get("Wechatpay-Serial"); 587 if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) { 588 throw new IllegalArgumentException( 589 String.format("Validate notification failed, Invalid Wechatpay-Serial, Local: %s, " + 590 "Remote: %s", 591 wechatpayPublicKeyId, 592 serialNumber)); 593 } 594 595 String signature = headers.get("Wechatpay-Signature"); 596 String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"), 597 body == null ? "" : body); 598 599 boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey); 600 if (!success) { 601 throw new IllegalArgumentException( 602 String.format("Validate notification failed, WechatPay signature is incorrect.\n" 603 + "responseHeader[%s]\tresponseBody[%.1024s]", 604 headers, body)); 605 } 606 } 607 608 /** 609 * 对微信支付通知进行签名验证、解析,同时将业务数据解密。验签名失败、解析失败、解密失败时抛出异常 610 * @param apiv3Key 商户的 APIv3 Key 611 * @param wechatpayPublicKeyId 微信支付公钥ID 612 * @param wechatpayPublicKey 微信支付公钥对象 613 * @param headers 微信支付请求 Header 列表 614 * @param body 微信支付请求 Body 615 * @return 解析后的通知内容,解密后的业务数据可以使用 Notification.getPlaintext() 访问 616 */ 617 public static Notification parseNotification(String apiv3Key, String wechatpayPublicKeyId, 618 PublicKey wechatpayPublicKey, Headers headers, 619 String body) { 620 validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); 621 Notification notification = gson.fromJson(body, Notification.class); 622 notification.decrypt(apiv3Key); 623 return notification; 624 } 625 626 /** 627 * 微信支付API错误异常,发送HTTP请求成功,但返回状态码不是 2XX 时抛出本异常 628 */ 629 public static class ApiException extends RuntimeException { 630 private static final long serialVersionUID = 2261086748874802175L; 631 632 private final int statusCode; 633 private final String body; 634 private final Headers headers; 635 private final String errorCode; 636 private final String errorMessage; 637 638 public ApiException(int statusCode, String body, Headers headers) { 639 super(String.format("微信支付API访问失败,StatusCode: [%s], Body: [%s], Headers: [%s]", statusCode, 640 body, headers)); 641 this.statusCode = statusCode; 642 this.body = body; 643 this.headers = headers; 644 645 if (body != null && !body.isEmpty()) { 646 JsonElement code; 647 JsonElement message; 648 649 try { 650 JsonObject jsonObject = gson.fromJson(body, JsonObject.class); 651 code = jsonObject.get("code"); 652 message = jsonObject.get("message"); 653 } catch (JsonSyntaxException ignored) { 654 code = null; 655 message = null; 656 } 657 this.errorCode = code == null ? null : code.getAsString(); 658 this.errorMessage = message == null ? null : message.getAsString(); 659 } else { 660 this.errorCode = null; 661 this.errorMessage = null; 662 } 663 } 664 665 /** 666 * 获取 HTTP 应答状态码 667 */ 668 public int getStatusCode() { 669 return statusCode; 670 } 671 672 /** 673 * 获取 HTTP 应答包体内容 674 */ 675 public String getBody() { 676 return body; 677 } 678 679 /** 680 * 获取 HTTP 应答 Header 681 */ 682 public Headers getHeaders() { 683 return headers; 684 } 685 686 /** 687 * 获取 错误码 (错误应答中的 code 字段) 688 */ 689 public String getErrorCode() { 690 return errorCode; 691 } 692 693 /** 694 * 获取 错误消息 (错误应答中的 message 字段) 695 */ 696 public String getErrorMessage() { 697 return errorMessage; 698 } 699 } 700 701 public static class Notification { 702 @SerializedName("id") 703 private String id; 704 @SerializedName("create_time") 705 private String createTime; 706 @SerializedName("event_type") 707 private String eventType; 708 @SerializedName("resource_type") 709 private String resourceType; 710 @SerializedName("summary") 711 private String summary; 712 @SerializedName("resource") 713 private Resource resource; 714 private String plaintext; 715 716 public String getId() { 717 return id; 718 } 719 720 public String getCreateTime() { 721 return createTime; 722 } 723 724 public String getEventType() { 725 return eventType; 726 } 727 728 public String getResourceType() { 729 return resourceType; 730 } 731 732 public String getSummary() { 733 return summary; 734 } 735 736 public Resource getResource() { 737 return resource; 738 } 739 740 /** 741 * 获取解密后的业务数据(JSON字符串,需要自行解析) 742 */ 743 public String getPlaintext() { 744 return plaintext; 745 } 746 747 private void validate() { 748 if (resource == null) { 749 throw new IllegalArgumentException("Missing required field `resource` in notification"); 750 } 751 resource.validate(); 752 } 753 754 /** 755 * 使用 APIv3Key 对通知中的业务数据解密,解密结果可以通过 getPlainText 访问。 756 * 外部拿到的 Notification 一定是解密过的,因此本方法没有设置为 public 757 * @param apiv3Key 商户APIv3 Key 758 */ 759 private void decrypt(String apiv3Key) { 760 validate(); 761 762 plaintext = aesAeadDecrypt( 763 apiv3Key.getBytes(StandardCharsets.UTF_8), 764 resource.associatedData.getBytes(StandardCharsets.UTF_8), 765 resource.nonce.getBytes(StandardCharsets.UTF_8), 766 Base64.getDecoder().decode(resource.ciphertext) 767 ); 768 } 769 770 public static class Resource { 771 @SerializedName("algorithm") 772 private String algorithm; 773 774 @SerializedName("ciphertext") 775 private String ciphertext; 776 777 @SerializedName("associated_data") 778 private String associatedData; 779 780 @SerializedName("nonce") 781 private String nonce; 782 783 @SerializedName("original_type") 784 private String originalType; 785 786 public String getAlgorithm() { 787 return algorithm; 788 } 789 790 public String getCiphertext() { 791 return ciphertext; 792 } 793 794 public String getAssociatedData() { 795 return associatedData; 796 } 797 798 public String getNonce() { 799 return nonce; 800 } 801 802 public String getOriginalType() { 803 return originalType; 804 } 805 806 private void validate() { 807 if (algorithm == null || algorithm.isEmpty()) { 808 throw new IllegalArgumentException("Missing required field `algorithm` in Notification" + 809 ".Resource"); 810 } 811 if (!Objects.equals(algorithm, "AEAD_AES_256_GCM")) { 812 throw new IllegalArgumentException(String.format("Unsupported `algorithm`[%s] in " + 813 "Notification.Resource", algorithm)); 814 } 815 816 if (ciphertext == null || ciphertext.isEmpty()) { 817 throw new IllegalArgumentException("Missing required field `ciphertext` in Notification" + 818 ".Resource"); 819 } 820 821 if (associatedData == null || associatedData.isEmpty()) { 822 throw new IllegalArgumentException("Missing required field `associatedData` in " + 823 "Notification.Resource"); 824 } 825 826 if (nonce == null || nonce.isEmpty()) { 827 throw new IllegalArgumentException("Missing required field `nonce` in Notification" + 828 ".Resource"); 829 } 830 831 if (originalType == null || originalType.isEmpty()) { 832 throw new IllegalArgumentException("Missing required field `originalType` in " + 833 "Notification.Resource"); 834 } 835 } 836 } 837 } 838 /** 839 * 根据文件名获取对应的Content-Type 840 * @param fileName 文件名 841 * @return Content-Type字符串 842 */ 843 public static String getContentTypeByFileName(String fileName) { 844 if (fileName == null || fileName.isEmpty()) { 845 return "application/octet-stream"; 846 } 847 848 // 获取文件扩展名 849 String extension = ""; 850 int lastDotIndex = fileName.lastIndexOf('.'); 851 if (lastDotIndex > 0 && lastDotIndex < fileName.length() - 1) { 852 extension = fileName.substring(lastDotIndex + 1).toLowerCase(); 853 } 854 855 // 常见文件类型映射 856 Map<String, String> contentTypeMap = new HashMap<>(); 857 // 图片类型 858 contentTypeMap.put("png", "image/png"); 859 contentTypeMap.put("jpg", "image/jpeg"); 860 contentTypeMap.put("jpeg", "image/jpeg"); 861 contentTypeMap.put("gif", "image/gif"); 862 contentTypeMap.put("bmp", "image/bmp"); 863 contentTypeMap.put("webp", "image/webp"); 864 contentTypeMap.put("svg", "image/svg+xml"); 865 contentTypeMap.put("ico", "image/x-icon"); 866 867 // 文档类型 868 contentTypeMap.put("pdf", "application/pdf"); 869 contentTypeMap.put("doc", "application/msword"); 870 contentTypeMap.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); 871 contentTypeMap.put("xls", "application/vnd.ms-excel"); 872 contentTypeMap.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); 873 contentTypeMap.put("ppt", "application/vnd.ms-powerpoint"); 874 contentTypeMap.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"); 875 876 // 文本类型 877 contentTypeMap.put("txt", "text/plain"); 878 contentTypeMap.put("html", "text/html"); 879 contentTypeMap.put("css", "text/css"); 880 contentTypeMap.put("js", "application/javascript"); 881 contentTypeMap.put("json", "application/json"); 882 contentTypeMap.put("xml", "application/xml"); 883 contentTypeMap.put("csv", "text/csv"); 884 885 // 音视频类型 886 contentTypeMap.put("mp3", "audio/mpeg"); 887 contentTypeMap.put("wav", "audio/wav"); 888 contentTypeMap.put("mp4", "video/mp4"); 889 contentTypeMap.put("avi", "video/x-msvideo"); 890 contentTypeMap.put("mov", "video/quicktime"); 891 892 // 压缩文件类型 893 contentTypeMap.put("zip", "application/zip"); 894 contentTypeMap.put("rar", "application/x-rar-compressed"); 895 contentTypeMap.put("7z", "application/x-7z-compressed"); 896 897 898 return contentTypeMap.getOrDefault(extension, "application/octet-stream"); 899 } 900}
文档是否有帮助

