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.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    public static String aesAeadDecrypt(byte[] key, byte[] associatedData, byte[] nonce,
211                                        byte[] ciphertext) {
212        final String transformation = "AES/GCM/NoPadding";
213        final String algorithm = "AES";
214        final int tagLengthBit = 128;
215
216        try {
217            Cipher cipher = Cipher.getInstance(transformation);
218            cipher.init(
219                    Cipher.DECRYPT_MODE,
220                    new SecretKeySpec(key, algorithm),
221                    new GCMParameterSpec(tagLengthBit, nonce));
222            if (associatedData != null) {
223                cipher.updateAAD(associatedData);
224            }
225            return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
226        } catch (InvalidKeyException
227                 | InvalidAlgorithmParameterException
228                 | BadPaddingException
229                 | IllegalBlockSizeException
230                 | NoSuchAlgorithmException
231                 | NoSuchPaddingException e) {
232            throw new IllegalArgumentException(String.format("AesAeadDecrypt with %s Failed",
233                    transformation), e);
234        }
235    }
236
237    /**
238     * 使用私钥按照指定算法进行签名
239     *
240     * @param message    待签名串
241     * @param algorithm  签名算法,如 SHA256withRSA
242     * @param privateKey 签名用私钥对象
243     * @return 签名结果
244     */
245    public static String sign(String message, String algorithm, PrivateKey privateKey) {
246        byte[] sign;
247        try {
248            Signature signature = Signature.getInstance(algorithm);
249            signature.initSign(privateKey);
250            signature.update(message.getBytes(StandardCharsets.UTF_8));
251            sign = signature.sign();
252        } catch (NoSuchAlgorithmException e) {
253            throw new UnsupportedOperationException("The current Java environment does not support " + algorithm, e);
254        } catch (InvalidKeyException e) {
255            throw new IllegalArgumentException(algorithm + " signature uses an illegal privateKey.", e);
256        } catch (SignatureException e) {
257            throw new RuntimeException("An error occurred during the sign process.", e);
258        }
259        return Base64.getEncoder().encodeToString(sign);
260    }
261
262    /**
263     * 使用公钥按照特定算法验证签名
264     *
265     * @param message   待签名串
266     * @param signature 待验证的签名内容
267     * @param algorithm 签名算法,如:SHA256withRSA
268     * @param publicKey 验签用公钥对象
269     * @return 签名验证是否通过
270     */
271    public static boolean verify(String message, String signature, String algorithm,
272                                 PublicKey publicKey) {
273        try {
274            Signature sign = Signature.getInstance(algorithm);
275            sign.initVerify(publicKey);
276            sign.update(message.getBytes(StandardCharsets.UTF_8));
277            return sign.verify(Base64.getDecoder().decode(signature));
278        } catch (SignatureException e) {
279            return false;
280        } catch (InvalidKeyException e) {
281            throw new IllegalArgumentException("verify uses an illegal publickey.", e);
282        } catch (NoSuchAlgorithmException e) {
283            throw new UnsupportedOperationException("The current Java environment does not support" + algorithm, e);
284        }
285    }
286
287    /**
288     * 根据微信支付APIv3请求签名规则构造 Authorization 签名
289     *
290     * @param mchid               商户号
291     * @param certificateSerialNo 商户API证书序列号
292     * @param privateKey          商户API证书私钥
293     * @param method              请求接口的HTTP方法,请使用全大写表述,如 GET、POST、PUT、DELETE
294     * @param uri                 请求接口的URL
295     * @param body                请求接口的Body
296     * @return 构造好的微信支付APIv3 Authorization 头
297     */
298    public static String buildAuthorization(String mchid, String certificateSerialNo,
299                                            PrivateKey privateKey,
300                                            String method, String uri, String body) {
301        String nonce = createNonce(32);
302        long timestamp = Instant.now().getEpochSecond();
303
304        String message = String.format("%s\n%s\n%d\n%s\n%s\n", method, uri, timestamp, nonce,
305                body == null ? "" : body);
306
307        String signature = sign(message, "SHA256withRSA", privateKey);
308
309        return String.format(
310                "WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\"," +
311                        "timestamp=\"%d\",serial_no=\"%s\"",
312                mchid, nonce, signature, timestamp, certificateSerialNo);
313    }
314
315    /**
316     * 计算输入流的哈希值
317     *
318     * @param inputStream 输入流
319     * @param algorithm   哈希算法名称,如 "SHA-256", "SHA-1"
320     * @return 哈希值的十六进制字符串
321     */
322    private static String calculateHash(InputStream inputStream, String algorithm) {
323        try {
324            MessageDigest digest = MessageDigest.getInstance(algorithm);
325            byte[] buffer = new byte[8192];
326            int bytesRead;
327            while ((bytesRead = inputStream.read(buffer)) != -1) {
328                digest.update(buffer, 0, bytesRead);
329            }
330            byte[] hashBytes = digest.digest();
331            StringBuilder hexString = new StringBuilder();
332            for (byte b : hashBytes) {
333                String hex = Integer.toHexString(0xff & b);
334                if (hex.length() == 1) {
335                    hexString.append('0');
336                }
337                hexString.append(hex);
338            }
339            return hexString.toString();
340        } catch (NoSuchAlgorithmException e) {
341            throw new UnsupportedOperationException(algorithm + " algorithm not available", e);
342        } catch (IOException e) {
343            throw new RuntimeException("Error reading from input stream", e);
344        }
345    }
346
347    /**
348     * 计算输入流的 SHA256 哈希值
349     *
350     * @param inputStream 输入流
351     * @return SHA256 哈希值的十六进制字符串
352     */
353    public static String sha256(InputStream inputStream) {
354        return calculateHash(inputStream, "SHA-256");
355    }
356
357    /**
358     * 计算输入流的 SHA1 哈希值
359     *
360     * @param inputStream 输入流
361     * @return SHA1 哈希值的十六进制字符串
362     */
363    public static String sha1(InputStream inputStream) {
364        return calculateHash(inputStream, "SHA-1");
365    }
366
367    /**
368     * 计算输入流的 SM3 哈希值
369     *
370     * @param inputStream 输入流
371     * @return SM3 哈希值的十六进制字符串
372     */
373    public static String sm3(InputStream inputStream) {
374        // 确保Bouncy Castle Provider已注册
375        if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
376            Security.addProvider(new BouncyCastleProvider());
377        }
378        
379        try {
380            SM3Digest digest = new SM3Digest();
381            byte[] buffer = new byte[8192];
382            int bytesRead;
383            while ((bytesRead = inputStream.read(buffer)) != -1) {
384                digest.update(buffer, 0, bytesRead);
385            }
386            byte[] hashBytes = new byte[digest.getDigestSize()];
387            digest.doFinal(hashBytes, 0);
388            
389            StringBuilder hexString = new StringBuilder();
390            for (byte b : hashBytes) {
391                String hex = Integer.toHexString(0xff & b);
392                if (hex.length() == 1) {
393                    hexString.append('0');
394                }
395                hexString.append(hex);
396            }
397            return hexString.toString();
398        } catch (IOException e) {
399            throw new RuntimeException("Error reading from input stream", e);
400        }
401    }
402
403    /**
404     * 对参数进行 URL 编码
405     *
406     * @param content 参数内容
407     * @return 编码后的内容
408     */
409    public static String urlEncode(String content) {
410        try {
411            return URLEncoder.encode(content, StandardCharsets.UTF_8.name());
412        } catch (UnsupportedEncodingException e) {
413            throw new RuntimeException(e);
414        }
415    }
416
417    /**
418     * 对参数Map进行 URL 编码,生成 QueryString
419     *
420     * @param params Query参数Map
421     * @return QueryString
422     */
423    public static String urlEncode(Map<String, Object> params) {
424        if (params == null || params.isEmpty()) {
425            return "";
426        }
427
428        StringBuilder result = new StringBuilder();
429        for (Entry<String, Object> entry : params.entrySet()) {
430            if (entry.getValue() == null) {
431                continue;
432            }
433
434            String key = entry.getKey();
435            Object value = entry.getValue();
436            if (value instanceof List) {
437                List<?> list = (List<?>) entry.getValue();
438                for (Object temp : list) {
439                    appendParam(result, key, temp);
440                }
441            } else {
442                appendParam(result, key, value);
443            }
444        }
445        return result.toString();
446    }
447
448    /**
449     * 将键值对 放入返回结果
450     *
451     * @param result 返回的query string
452     * @param key 属性
453     * @param value 属性值
454     */
455    private static void appendParam(StringBuilder result, String key, Object value) {
456        if (result.length() > 0) {
457            result.append("&");
458        }
459
460        String valueString;
461        // 如果是基本类型、字符串或枚举,直接转换;如果是对象,序列化为JSON
462        if (value instanceof String || value instanceof Number ||
463                value instanceof Boolean || value instanceof Enum) {
464            valueString = value.toString();
465        } else {
466            valueString = toJson(value);
467        }
468
469        result.append(key)
470                .append("=")
471                .append(urlEncode(valueString));
472    }
473
474    /**
475     * 从应答中提取 Body
476     *
477     * @param response HTTP 请求应答对象
478     * @return 应答中的Body内容,Body为空时返回空字符串
479     */
480    public static String extractBody(Response response) {
481        if (response.body() == null) {
482            return "";
483        }
484
485        try {
486            BufferedSource source = response.body().source();
487            return source.readUtf8();
488        } catch (IOException e) {
489            throw new RuntimeException(String.format("An error occurred during reading response body. " +
490                    "Status: %d", response.code()), e);
491        }
492    }
493
494    /**
495     * 根据微信支付APIv3应答验签规则对应答签名进行验证,验证不通过时抛出异常
496     *
497     * @param wechatpayPublicKeyId 微信支付公钥ID
498     * @param wechatpayPublicKey   微信支付公钥对象
499     * @param headers              微信支付应答 Header 列表
500     * @param body                 微信支付应答 Body
501     */
502    public static void validateResponse(String wechatpayPublicKeyId, PublicKey wechatpayPublicKey,
503                                        Headers headers,
504                                        String body) {
505        String timestamp = headers.get("Wechatpay-Timestamp");
506        String requestId = headers.get("Request-ID");
507        try {
508            Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
509            // 拒绝过期请求
510            if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
511                throw new IllegalArgumentException(
512                        String.format("Validate response failed, timestamp[%s] is expired, request-id[%s]",
513                                timestamp, requestId));
514            }
515        } catch (DateTimeException | NumberFormatException e) {
516            throw new IllegalArgumentException(
517                    String.format("Validate response failed, timestamp[%s] is invalid, request-id[%s]",
518                            timestamp, requestId));
519        }
520        String serialNumber = headers.get("Wechatpay-Serial");
521        if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
522            throw new IllegalArgumentException(
523                    String.format("Validate response failed, Invalid Wechatpay-Serial, Local: %s, Remote: " +
524                            "%s", wechatpayPublicKeyId, serialNumber));
525        }
526
527        String signature = headers.get("Wechatpay-Signature");
528        String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
529                body == null ? "" : body);
530
531        boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
532        if (!success) {
533            throw new IllegalArgumentException(
534                    String.format("Validate response failed,the WechatPay signature is incorrect.%n"
535                                    + "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]",
536                            headers.get("Request-ID"), headers, body));
537        }
538    }
539
540    /**
541     * 根据微信支付APIv3通知验签规则对通知签名进行验证,验证不通过时抛出异常
542     * @param wechatpayPublicKeyId 微信支付公钥ID
543     * @param wechatpayPublicKey 微信支付公钥对象
544     * @param headers 微信支付通知 Header 列表
545     * @param body 微信支付通知 Body
546     */
547    public static void validateNotification(String wechatpayPublicKeyId,
548                                            PublicKey wechatpayPublicKey, Headers headers,
549                                            String body) {
550        String timestamp = headers.get("Wechatpay-Timestamp");
551        try {
552            Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
553            // 拒绝过期请求
554            if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
555                throw new IllegalArgumentException(
556                        String.format("Validate notification failed, timestamp[%s] is expired", timestamp));
557            }
558        } catch (DateTimeException | NumberFormatException e) {
559            throw new IllegalArgumentException(
560                    String.format("Validate notification failed, timestamp[%s] is invalid", timestamp));
561        }
562        String serialNumber = headers.get("Wechatpay-Serial");
563        if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
564            throw new IllegalArgumentException(
565                    String.format("Validate notification failed, Invalid Wechatpay-Serial, Local: %s, " +
566                                    "Remote: %s",
567                            wechatpayPublicKeyId,
568                            serialNumber));
569        }
570
571        String signature = headers.get("Wechatpay-Signature");
572        String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
573                body == null ? "" : body);
574
575        boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
576        if (!success) {
577            throw new IllegalArgumentException(
578                    String.format("Validate notification failed, WechatPay signature is incorrect.\n"
579                                    + "responseHeader[%s]\tresponseBody[%.1024s]",
580                            headers, body));
581        }
582    }
583
584    /**
585     * 对微信支付通知进行签名验证、解析,同时将业务数据解密。验签名失败、解析失败、解密失败时抛出异常
586     * @param apiv3Key 商户的 APIv3 Key
587     * @param wechatpayPublicKeyId 微信支付公钥ID
588     * @param wechatpayPublicKey   微信支付公钥对象
589     * @param headers              微信支付请求 Header 列表
590     * @param body                 微信支付请求 Body
591     * @return 解析后的通知内容,解密后的业务数据可以使用 Notification.getPlaintext() 访问
592     */
593    public static Notification parseNotification(String apiv3Key, String wechatpayPublicKeyId,
594                                                 PublicKey wechatpayPublicKey, Headers headers,
595                                                 String body) {
596        validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body);
597        Notification notification = gson.fromJson(body, Notification.class);
598        notification.decrypt(apiv3Key);
599        return notification;
600    }
601
602    /**
603     * 微信支付API错误异常,发送HTTP请求成功,但返回状态码不是 2XX 时抛出本异常
604     */
605    public static class ApiException extends RuntimeException {
606        private static final long serialVersionUID = 2261086748874802175L;
607
608        private final int statusCode;
609        private final String body;
610        private final Headers headers;
611        private final String errorCode;
612        private final String errorMessage;
613
614        public ApiException(int statusCode, String body, Headers headers) {
615            super(String.format("微信支付API访问失败,StatusCode: [%s], Body: [%s], Headers: [%s]", statusCode,
616                    body, headers));
617            this.statusCode = statusCode;
618            this.body = body;
619            this.headers = headers;
620
621            if (body != null && !body.isEmpty()) {
622                JsonElement code;
623                JsonElement message;
624
625                try {
626                    JsonObject jsonObject = gson.fromJson(body, JsonObject.class);
627                    code = jsonObject.get("code");
628                    message = jsonObject.get("message");
629                } catch (JsonSyntaxException ignored) {
630                    code = null;
631                    message = null;
632                }
633                this.errorCode = code == null ? null : code.getAsString();
634                this.errorMessage = message == null ? null : message.getAsString();
635            } else {
636                this.errorCode = null;
637                this.errorMessage = null;
638            }
639        }
640
641        /**
642         * 获取 HTTP 应答状态码
643         */
644        public int getStatusCode() {
645            return statusCode;
646        }
647
648        /**
649         * 获取 HTTP 应答包体内容
650         */
651        public String getBody() {
652            return body;
653        }
654
655        /**
656         * 获取 HTTP 应答 Header
657         */
658        public Headers getHeaders() {
659            return headers;
660        }
661
662        /**
663         * 获取 错误码 (错误应答中的 code 字段)
664         */
665        public String getErrorCode() {
666            return errorCode;
667        }
668
669        /**
670         * 获取 错误消息 (错误应答中的 message 字段)
671         */
672        public String getErrorMessage() {
673            return errorMessage;
674        }
675    }
676
677    public static class Notification {
678        @SerializedName("id")
679        private String id;
680        @SerializedName("create_time")
681        private String createTime;
682        @SerializedName("event_type")
683        private String eventType;
684        @SerializedName("resource_type")
685        private String resourceType;
686        @SerializedName("summary")
687        private String summary;
688        @SerializedName("resource")
689        private Resource resource;
690        private String plaintext;
691
692        public String getId() {
693            return id;
694        }
695
696        public String getCreateTime() {
697            return createTime;
698        }
699
700        public String getEventType() {
701            return eventType;
702        }
703
704        public String getResourceType() {
705            return resourceType;
706        }
707
708        public String getSummary() {
709            return summary;
710        }
711
712        public Resource getResource() {
713            return resource;
714        }
715
716        /**
717         * 获取解密后的业务数据(JSON字符串,需要自行解析)
718         */
719        public String getPlaintext() {
720            return plaintext;
721        }
722
723        private void validate() {
724            if (resource == null) {
725                throw new IllegalArgumentException("Missing required field `resource` in notification");
726            }
727            resource.validate();
728        }
729
730        /**
731         * 使用 APIv3Key 对通知中的业务数据解密,解密结果可以通过 getPlainText 访问。
732         * 外部拿到的 Notification 一定是解密过的,因此本方法没有设置为 public
733         * @param apiv3Key 商户APIv3 Key
734         */
735        private void decrypt(String apiv3Key) {
736            validate();
737
738            plaintext = aesAeadDecrypt(
739                    apiv3Key.getBytes(StandardCharsets.UTF_8),
740                    resource.associatedData.getBytes(StandardCharsets.UTF_8),
741                    resource.nonce.getBytes(StandardCharsets.UTF_8),
742                    Base64.getDecoder().decode(resource.ciphertext)
743            );
744        }
745
746        public static class Resource {
747            @SerializedName("algorithm")
748            private String algorithm;
749
750            @SerializedName("ciphertext")
751            private String ciphertext;
752
753            @SerializedName("associated_data")
754            private String associatedData;
755
756            @SerializedName("nonce")
757            private String nonce;
758
759            @SerializedName("original_type")
760            private String originalType;
761
762            public String getAlgorithm() {
763                return algorithm;
764            }
765
766            public String getCiphertext() {
767                return ciphertext;
768            }
769
770            public String getAssociatedData() {
771                return associatedData;
772            }
773
774            public String getNonce() {
775                return nonce;
776            }
777
778            public String getOriginalType() {
779                return originalType;
780            }
781
782            private void validate() {
783                if (algorithm == null || algorithm.isEmpty()) {
784                    throw new IllegalArgumentException("Missing required field `algorithm` in Notification" +
785                            ".Resource");
786                }
787                if (!Objects.equals(algorithm, "AEAD_AES_256_GCM")) {
788                    throw new IllegalArgumentException(String.format("Unsupported `algorithm`[%s] in " +
789                            "Notification.Resource", algorithm));
790                }
791
792                if (ciphertext == null || ciphertext.isEmpty()) {
793                    throw new IllegalArgumentException("Missing required field `ciphertext` in Notification" +
794                            ".Resource");
795                }
796
797                if (associatedData == null || associatedData.isEmpty()) {
798                    throw new IllegalArgumentException("Missing required field `associatedData` in " +
799                            "Notification.Resource");
800                }
801
802                if (nonce == null || nonce.isEmpty()) {
803                    throw new IllegalArgumentException("Missing required field `nonce` in Notification" +
804                            ".Resource");
805                }
806
807                if (originalType == null || originalType.isEmpty()) {
808                    throw new IllegalArgumentException("Missing required field `originalType` in " +
809                            "Notification.Resource");
810                }
811            }
812        }
813    }
814    /**
815     * 根据文件名获取对应的Content-Type
816     * @param fileName 文件名
817     * @return Content-Type字符串
818     */
819    public static String getContentTypeByFileName(String fileName) {
820        if (fileName == null || fileName.isEmpty()) {
821            return "application/octet-stream";
822        }
823
824        // 获取文件扩展名
825        String extension = "";
826        int lastDotIndex = fileName.lastIndexOf('.');
827        if (lastDotIndex > 0 && lastDotIndex < fileName.length() - 1) {
828            extension = fileName.substring(lastDotIndex + 1).toLowerCase();
829        }
830
831        // 常见文件类型映射
832        Map<String, String> contentTypeMap = new HashMap<>();
833        // 图片类型
834        contentTypeMap.put("png", "image/png");
835        contentTypeMap.put("jpg", "image/jpeg");
836        contentTypeMap.put("jpeg", "image/jpeg");
837        contentTypeMap.put("gif", "image/gif");
838        contentTypeMap.put("bmp", "image/bmp");
839        contentTypeMap.put("webp", "image/webp");
840        contentTypeMap.put("svg", "image/svg+xml");
841        contentTypeMap.put("ico", "image/x-icon");
842
843        // 文档类型
844        contentTypeMap.put("pdf", "application/pdf");
845        contentTypeMap.put("doc", "application/msword");
846        contentTypeMap.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
847        contentTypeMap.put("xls", "application/vnd.ms-excel");
848        contentTypeMap.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
849        contentTypeMap.put("ppt", "application/vnd.ms-powerpoint");
850        contentTypeMap.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation");
851
852        // 文本类型
853        contentTypeMap.put("txt", "text/plain");
854        contentTypeMap.put("html", "text/html");
855        contentTypeMap.put("css", "text/css");
856        contentTypeMap.put("js", "application/javascript");
857        contentTypeMap.put("json", "application/json");
858        contentTypeMap.put("xml", "application/xml");
859        contentTypeMap.put("csv", "text/csv");
860
861        // 音视频类型
862        contentTypeMap.put("mp3", "audio/mpeg");
863        contentTypeMap.put("wav", "audio/wav");
864        contentTypeMap.put("mp4", "video/mp4");
865        contentTypeMap.put("avi", "video/x-msvideo");
866        contentTypeMap.put("mov", "video/quicktime");
867
868        // 压缩文件类型
869        contentTypeMap.put("zip", "application/zip");
870        contentTypeMap.put("rar", "application/x-rar-compressed");
871        contentTypeMap.put("7z", "application/x-7z-compressed");
872
873
874        return contentTypeMap.getOrDefault(extension, "application/octet-stream");
875    }
876}