From bbd9c436f23f5fdbe712c4a22d90b457066bdf38 Mon Sep 17 00:00:00 2001
From: k94314517 <8417338+k94314517@user.noreply.gitee.com>
Date: 星期四, 17 七月 2025 19:25:47 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/master'

---
 server/services/src/main/java/com/doumee/config/wx/WXPayUtility.java |  456 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 456 insertions(+), 0 deletions(-)

diff --git a/server/services/src/main/java/com/doumee/config/wx/WXPayUtility.java b/server/services/src/main/java/com/doumee/config/wx/WXPayUtility.java
new file mode 100644
index 0000000..bf29e52
--- /dev/null
+++ b/server/services/src/main/java/com/doumee/config/wx/WXPayUtility.java
@@ -0,0 +1,456 @@
+package com.doumee.config.wx;
+
+
+import com.google.gson.ExclusionStrategy;
+import com.google.gson.FieldAttributes;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.annotations.Expose;
+import com.wechat.pay.java.core.util.GsonUtil;
+import okhttp3.Headers;
+import okhttp3.Response;
+import okio.BufferedSource;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.NoSuchPaddingException;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.security.InvalidKeyException;
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.SecureRandom;
+import java.security.Signature;
+import java.security.SignatureException;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.time.DateTimeException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Map;
+import java.util.Objects;
+
+public class WXPayUtility {
+    private static final Gson gson = new GsonBuilder()
+            .disableHtmlEscaping()
+            .addSerializationExclusionStrategy(new ExclusionStrategy() {
+                @Override
+                public boolean shouldSkipField(FieldAttributes fieldAttributes) {
+                    final Expose expose = fieldAttributes.getAnnotation(Expose.class);
+                    return expose != null && !expose.serialize();
+                }
+
+                @Override
+                public boolean shouldSkipClass(Class<?> aClass) {
+                    return false;
+                }
+            })
+            .addDeserializationExclusionStrategy(new ExclusionStrategy() {
+                @Override
+                public boolean shouldSkipField(FieldAttributes fieldAttributes) {
+                    final Expose expose = fieldAttributes.getAnnotation(Expose.class);
+                    return expose != null && !expose.deserialize();
+                }
+
+                @Override
+                public boolean shouldSkipClass(Class<?> aClass) {
+                    return false;
+                }
+            })
+            .create();
+    private static final char[] SYMBOLS =
+            "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
+    private static final SecureRandom random = new SecureRandom();
+
+    /**
+     * 灏� Object 杞崲涓� JSON 瀛楃涓�
+     */
+    public static String toJson(Object object) {
+        return gson.toJson(object);
+    }
+
+    /**
+     * 灏� JSON 瀛楃涓茶В鏋愪负鐗瑰畾绫诲瀷鐨勫疄渚�
+     */
+    public static <T> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException {
+        return gson.fromJson(json, classOfT);
+    }
+
+    /**
+     * 浠庡叕绉侀挜鏂囦欢璺緞涓鍙栨枃浠跺唴瀹�
+     *
+     * @param keyPath 鏂囦欢璺緞
+     * @return 鏂囦欢鍐呭
+     */
+    private static String readKeyStringFromPath(String keyPath) {
+        try {
+            return new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    /**
+     * 璇诲彇 PKCS#8 鏍煎紡鐨勭閽ュ瓧绗︿覆骞跺姞杞戒负绉侀挜瀵硅薄
+     *
+     * @param keyString 绉侀挜鏂囦欢鍐呭锛屼互 -----BEGIN PRIVATE KEY----- 寮�澶�
+     * @return PrivateKey 瀵硅薄
+     */
+    public static PrivateKey loadPrivateKeyFromString(String keyString) {
+        try {
+            keyString = keyString.replace("-----BEGIN PRIVATE KEY-----", "")
+                    .replace("-----END PRIVATE KEY-----", "")
+                    .replaceAll("\\s+", "");
+            return KeyFactory.getInstance("RSA").generatePrivate(
+                    new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyString)));
+        } catch (NoSuchAlgorithmException e) {
+            throw new UnsupportedOperationException(e);
+        } catch (InvalidKeySpecException e) {
+            throw new IllegalArgumentException(e);
+        }
+    }
+
+    /**
+     * 浠� PKCS#8 鏍煎紡鐨勭閽ユ枃浠朵腑鍔犺浇绉侀挜
+     *
+     * @param keyPath 绉侀挜鏂囦欢璺緞
+     * @return PrivateKey 瀵硅薄
+     */
+    public static PrivateKey loadPrivateKeyFromPath(String keyPath) {
+        return loadPrivateKeyFromString(readKeyStringFromPath(keyPath));
+    }
+
+    /**
+     * 璇诲彇 PKCS#8 鏍煎紡鐨勫叕閽ュ瓧绗︿覆骞跺姞杞戒负鍏挜瀵硅薄
+     *
+     * @param keyString 鍏挜鏂囦欢鍐呭锛屼互 -----BEGIN PUBLIC KEY----- 寮�澶�
+     * @return PublicKey 瀵硅薄
+     */
+    public static PublicKey loadPublicKeyFromString(String keyString) {
+        try {
+            keyString = keyString.replace("-----BEGIN PUBLIC KEY-----", "")
+                    .replace("-----END PUBLIC KEY-----", "")
+                    .replaceAll("\\s+", "");
+            return KeyFactory.getInstance("RSA").generatePublic(
+                    new X509EncodedKeySpec(Base64.getDecoder().decode(keyString)));
+        } catch (NoSuchAlgorithmException e) {
+            throw new UnsupportedOperationException(e);
+        } catch (InvalidKeySpecException e) {
+            throw new IllegalArgumentException(e);
+        }
+    }
+
+    /**
+     * 浠� PKCS#8 鏍煎紡鐨勫叕閽ユ枃浠朵腑鍔犺浇鍏挜
+     *
+     * @param keyPath 鍏挜鏂囦欢璺緞
+     * @return PublicKey 瀵硅薄
+     */
+    public static PublicKey loadPublicKeyFromPath(String keyPath) {
+        return loadPublicKeyFromString(readKeyStringFromPath(keyPath));
+    }
+
+    /**
+     * 鍒涘缓鎸囧畾闀垮害鐨勯殢鏈哄瓧绗︿覆锛屽瓧绗﹂泦涓篬0-9a-zA-Z]锛屽彲鐢ㄤ簬瀹夊叏鐩稿叧鐢ㄩ��
+     */
+    public static String createNonce(int length) {
+        char[] buf = new char[length];
+        for (int i = 0; i < length; ++i) {
+            buf[i] = SYMBOLS[random.nextInt(SYMBOLS.length)];
+        }
+        return new String(buf);
+    }
+
+    /**
+     * 浣跨敤鍏挜鎸夌収 RSA_PKCS1_OAEP_PADDING 绠楁硶杩涜鍔犲瘑
+     *
+     * @param publicKey 鍔犲瘑鐢ㄥ叕閽ュ璞�
+     * @param plaintext 寰呭姞瀵嗘槑鏂�
+     * @return 鍔犲瘑鍚庡瘑鏂�
+     */
+    public static String encrypt(PublicKey publicKey, String plaintext) {
+        final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";
+
+        try {
+            Cipher cipher = Cipher.getInstance(transformation);
+            cipher.init(Cipher.ENCRYPT_MODE, publicKey);
+            return Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)));
+        } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
+            throw new IllegalArgumentException("The current Java environment does not support " + transformation, e);
+        } catch (InvalidKeyException e) {
+            throw new IllegalArgumentException("RSA encryption using an illegal publicKey", e);
+        } catch (BadPaddingException | IllegalBlockSizeException e) {
+            throw new IllegalArgumentException("Plaintext is too long", e);
+        }
+    }
+
+    /**
+     * 浣跨敤绉侀挜鎸夌収鎸囧畾绠楁硶杩涜绛惧悕
+     *
+     * @param message 寰呯鍚嶄覆
+     * @param algorithm 绛惧悕绠楁硶锛屽 SHA256withRSA
+     * @param privateKey 绛惧悕鐢ㄧ閽ュ璞�
+     * @return 绛惧悕缁撴灉
+     */
+    public static String sign(String message, String algorithm, PrivateKey privateKey) {
+        byte[] sign;
+        try {
+            Signature signature = Signature.getInstance(algorithm);
+            signature.initSign(privateKey);
+            signature.update(message.getBytes(StandardCharsets.UTF_8));
+            sign = signature.sign();
+        } catch (NoSuchAlgorithmException e) {
+            throw new UnsupportedOperationException("The current Java environment does not support " + algorithm, e);
+        } catch (InvalidKeyException e) {
+            throw new IllegalArgumentException(algorithm + " signature uses an illegal privateKey.", e);
+        } catch (SignatureException e) {
+            throw new RuntimeException("An error occurred during the sign process.", e);
+        }
+        return Base64.getEncoder().encodeToString(sign);
+    }
+
+    /**
+     * 浣跨敤鍏挜鎸夌収鐗瑰畾绠楁硶楠岃瘉绛惧悕
+     *
+     * @param message 寰呯鍚嶄覆
+     * @param signature 寰呴獙璇佺殑绛惧悕鍐呭
+     * @param algorithm 绛惧悕绠楁硶锛屽锛歋HA256withRSA
+     * @param publicKey 楠岀鐢ㄥ叕閽ュ璞�
+     * @return 绛惧悕楠岃瘉鏄惁閫氳繃
+     */
+    public static boolean verify(String message, String signature, String algorithm,
+                                 PublicKey publicKey) {
+        try {
+            Signature sign = Signature.getInstance(algorithm);
+            sign.initVerify(publicKey);
+            sign.update(message.getBytes(StandardCharsets.UTF_8));
+            return sign.verify(Base64.getDecoder().decode(signature));
+        } catch (SignatureException e) {
+            return false;
+        } catch (InvalidKeyException e) {
+            throw new IllegalArgumentException("verify uses an illegal publickey.", e);
+        } catch (NoSuchAlgorithmException e) {
+            throw new UnsupportedOperationException("The current Java environment does not support" + algorithm, e);
+        }
+    }
+
+    /**
+     * 鏍规嵁寰俊鏀粯APIv3璇锋眰绛惧悕瑙勫垯鏋勯�� Authorization 绛惧悕
+     *
+     * @param mchid 鍟嗘埛鍙�
+     * @param certificateSerialNo 鍟嗘埛API璇佷功搴忓垪鍙�
+     * @param privateKey 鍟嗘埛API璇佷功绉侀挜
+     * @param method 璇锋眰鎺ュ彛鐨凥TTP鏂规硶锛岃浣跨敤鍏ㄥぇ鍐欒〃杩帮紝濡� GET銆丳OST銆丳UT銆丏ELETE
+     * @param uri 璇锋眰鎺ュ彛鐨刄RL
+     * @param body 璇锋眰鎺ュ彛鐨凚ody
+     * @return 鏋勯�犲ソ鐨勫井淇℃敮浠楢PIv3 Authorization 澶�
+     */
+    public static String buildAuthorization(String mchid, String certificateSerialNo,
+                                            PrivateKey privateKey,
+                                            String method, String uri, String body) {
+        String nonce = createNonce(32);
+        long timestamp = Instant.now().getEpochSecond();
+
+        String message = String.format("%s\n%s\n%d\n%s\n%s\n", method, uri, timestamp, nonce,
+                body == null ? "" : body);
+
+        String signature = sign(message, "SHA256withRSA", privateKey);
+
+        return String.format(
+                "WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\"," +
+                        "timestamp=\"%d\",serial_no=\"%s\"",
+                mchid, nonce, signature, timestamp, certificateSerialNo);
+    }
+
+    /**
+     * 瀵瑰弬鏁拌繘琛� URL 缂栫爜
+     *
+     * @param content 鍙傛暟鍐呭
+     * @return 缂栫爜鍚庣殑鍐呭
+     */
+    public static String urlEncode(String content) {
+        try {
+            return URLEncoder.encode(content, StandardCharsets.UTF_8.name());
+        } catch (UnsupportedEncodingException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * 瀵瑰弬鏁癕ap杩涜 URL 缂栫爜锛岀敓鎴� QueryString
+     *
+     * @param params Query鍙傛暟Map
+     * @return QueryString
+     */
+    public static String urlEncode(Map<String, Object> params) {
+        if (params == null || params.isEmpty()) {
+            return "";
+        }
+
+        int index = 0;
+        StringBuilder result = new StringBuilder();
+        for (Map.Entry<String, Object> entry : params.entrySet()) {
+            result.append(entry.getKey())
+                    .append("=")
+                    .append(urlEncode(entry.getValue().toString()));
+            index++;
+            if (index < params.size()) {
+                result.append("&");
+            }
+        }
+        return result.toString();
+    }
+
+    /**
+     * 浠庡簲绛斾腑鎻愬彇 Body
+     *
+     * @param response HTTP 璇锋眰搴旂瓟瀵硅薄
+     * @return 搴旂瓟涓殑Body鍐呭锛孊ody涓虹┖鏃惰繑鍥炵┖瀛楃涓�
+     */
+    public static String extractBody(Response response) {
+        if (response.body() == null) {
+            return "";
+        }
+
+        try {
+            BufferedSource source = response.body().source();
+            return source.readUtf8();
+        } catch (IOException e) {
+            throw new RuntimeException(String.format("An error occurred during reading response body. Status: %d", response.code()), e);
+        }
+    }
+
+    /**
+     * 鏍规嵁寰俊鏀粯APIv3搴旂瓟楠岀瑙勫垯瀵瑰簲绛旂鍚嶈繘琛岄獙璇侊紝楠岃瘉涓嶉�氳繃鏃舵姏鍑哄紓甯�
+     *
+     * @param wechatpayPublicKeyId 寰俊鏀粯鍏挜ID
+     * @param wechatpayPublicKey 寰俊鏀粯鍏挜瀵硅薄
+     * @param headers 寰俊鏀粯搴旂瓟 Header 鍒楄〃
+     * @param body 寰俊鏀粯搴旂瓟 Body
+     */
+    public static void validateResponse(String wechatpayPublicKeyId, PublicKey wechatpayPublicKey,
+                                        Headers headers,
+                                        String body) {
+        String timestamp = headers.get("Wechatpay-Timestamp");
+        try {
+            Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
+            // 鎷掔粷杩囨湡璇锋眰
+            if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
+                throw new IllegalArgumentException(
+                        String.format("Validate http response,timestamp[%s] of httpResponse is expires, "
+                                        + "request-id[%s]",
+                                timestamp, headers.get("Request-ID")));
+            }
+        } catch (DateTimeException | NumberFormatException e) {
+            throw new IllegalArgumentException(
+                    String.format("Validate http response,timestamp[%s] of httpResponse is invalid, " +
+                                    "request-id[%s]", timestamp,
+                            headers.get("Request-ID")));
+        }
+        String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
+                body == null ? "" : body);
+        String serialNumber = headers.get("Wechatpay-Serial");
+        if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
+            throw new IllegalArgumentException(
+                    String.format("Invalid Wechatpay-Serial, Local: %s, Remote: %s", wechatpayPublicKeyId,
+                            serialNumber));
+        }
+        String signature = headers.get("Wechatpay-Signature");
+
+        boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
+        if (!success) {
+            throw new IllegalArgumentException(
+                    String.format("Validate response failed,the WechatPay signature is incorrect.%n"
+                                    + "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]",
+                            headers.get("Request-ID"), headers, body));
+        }
+    }
+
+    /**
+     * 寰俊鏀粯API閿欒寮傚父锛屽彂閫丠TTP璇锋眰鎴愬姛锛屼絾杩斿洖鐘舵�佺爜涓嶆槸 2XX 鏃舵姏鍑烘湰寮傚父
+     */
+    public static class ApiException extends RuntimeException {
+        private static final long serialVersionUID = 2261086748874802175L;
+
+        private final int statusCode;
+        private final String body;
+        private final Headers headers;
+        private final String errorCode;
+        private final String errorMessage;
+
+        public ApiException(int statusCode, String body, Headers headers) {
+            super(String.format("寰俊鏀粯API璁块棶澶辫触锛孲tatusCode: [%s], Body: [%s], Headers: [%s]", statusCode, body, headers));
+            this.statusCode = statusCode;
+            this.body = body;
+            this.headers = headers;
+
+            if (body != null && !body.isEmpty()) {
+                JsonElement code;
+                JsonElement message;
+
+                try {
+                    JsonObject jsonObject = GsonUtil.getGson().fromJson(body, JsonObject.class);
+                    code = jsonObject.get("code");
+                    message = jsonObject.get("message");
+                } catch (JsonSyntaxException ignored) {
+                    code = null;
+                    message = null;
+                }
+                this.errorCode = code == null ? null : code.getAsString();
+                this.errorMessage = message == null ? null : message.getAsString();
+            } else {
+                this.errorCode = null;
+                this.errorMessage = null;
+            }
+        }
+
+        /**
+         * 鑾峰彇 HTTP 搴旂瓟鐘舵�佺爜
+         */
+        public int getStatusCode() {
+            return statusCode;
+        }
+
+        /**
+         * 鑾峰彇 HTTP 搴旂瓟鍖呬綋鍐呭
+         */
+        public String getBody() {
+            return body;
+        }
+
+        /**
+         * 鑾峰彇 HTTP 搴旂瓟 Header
+         */
+        public Headers getHeaders() {
+            return headers;
+        }
+
+        /**
+         * 鑾峰彇 閿欒鐮� 锛堥敊璇簲绛斾腑鐨� code 瀛楁锛�
+         */
+        public String getErrorCode() {
+            return errorCode;
+        }
+
+        /**
+         * 鑾峰彇 閿欒娑堟伅 锛堥敊璇簲绛斾腑鐨� message 瀛楁锛�
+         */
+        public String getErrorMessage() {
+            return errorMessage;
+        }
+    }
+}
\ No newline at end of file

--
Gitblit v1.9.3