Java

更新时间:2025.12.04

一、概述

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