Java

更新时间:2025.10.30

一、概述

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