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