Java

更新时间:2025.05.27

一、概述

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

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

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

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

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

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

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

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

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

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

三、必需的证书和密钥

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

、工具类代码

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