Pārlūkot izejas kodu

小程序加解密

刘欣 1 nedēļu atpakaļ
vecāks
revīzija
2dca5bb10e

+ 232 - 0
fs-user-app/src/main/java/com/fs/core/filter/RsaCryptoUtil.java

@@ -0,0 +1,232 @@
+package com.fs.core.filter;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.SecureRandom;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+
+/**
+ * RSA 加解密工具
+ * 默认:RSA/ECB/PKCS1Padding
+ * 公钥:X.509(SPKI),对应 -----BEGIN PUBLIC KEY-----
+ * 私钥:PKCS#8,对应 -----BEGIN PRIVATE KEY-----
+ */
+public final class RsaCryptoUtil {
+
+    private static final String ALGORITHM = "RSA";
+    /**
+     * 与多数 Android/前端 JSEncrypt 默认兼容
+     */
+    private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding";
+
+    /**
+     * 默认公钥
+     */
+    private static final String PUBLIC_KEY =
+            "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApYhPgzvqTInznDg4FJJs\n" +
+                    "PWFuXk+oqPTMQViWAL3mzsLP4bj1hpnSELlwrDOEEfuJEqE5L3M9+svUuGGcUUQ6\n" +
+                    "LikQPJ1VSYPHdoPcmeX7UJEmhi9T8IUBGzgI197nC6w0sJEfuuLh+HDRCoFKSAZE\n" +
+                    "T196WqmVEhRZi6RRF7H1UCORqTDsF7hIA2cUJMo8SKreqhVyUkbhitsJN5SlwEEs\n" +
+                    "RmPkvBgw+94EH5WeV+7t6vbruyvIX1kYUXCXHp9WNTGj2zaxM26Fu7XmJoQVb6uS\n" +
+                    "Lf11m6TjozR3Yh/9/PZ41XHdKk7N3yT6L1tF8LUlWxFpBfOVqw61mLtlohga8HUy\n" +
+                    "8QIDAQAB";
+
+    /**
+     * 默认私钥(建议放到环境变量里)
+     */
+    private static final String PRIVATE_KEY =
+            "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCliE+DO+pMifOc\n" +
+                    "ODgUkmw9YW5eT6io9MxBWJYAvebOws/huPWGmdIQuXCsM4QR+4kSoTkvcz36y9S4\n" +
+                    "YZxRRDouKRA8nVVJg8d2g9yZ5ftQkSaGL1PwhQEbOAjX3ucLrDSwkR+64uH4cNEK\n" +
+                    "gUpIBkRPX3paqZUSFFmLpFEXsfVQI5GpMOwXuEgDZxQkyjxIqt6qFXJSRuGK2wk3\n" +
+                    "lKXAQSxGY+S8GDD73gQflZ5X7u3q9uu7K8hfWRhRcJcen1Y1MaPbNrEzboW7teYm\n" +
+                    "hBVvq5It/XWbpOOjNHdiH/389njVcd0qTs3fJPovW0XwtSVbEWkF85WrDrWYu2Wi\n" +
+                    "GBrwdTLxAgMBAAECggEBAKSjaeouCoBr6Pdk1ypm4+8828hD1I2PRSRtVmyQdGML\n" +
+                    "UjXAf7uLfGqT9mPfrSkxlAkKf9UY+vlaCE4HD7GoeXF1+CdBmRVQU3AmAKC1JHvi\n" +
+                    "kn19zy/3Kb2HwwvqMMTkV1eSUAvSI0y18MRRCa+PRBvp68kGyDS+Nr30BY/auRIl\n" +
+                    "ZMrL0Smq+Ugn4bP6x/9K3Xvud0HzeRWpEgjKcdhDnR3Jvgvr8jpUxarH2II1RvUr\n" +
+                    "i0l4RAXzbfoYNWQckVsWMBryGHCeW3gG5npurP82CW6CroztaXXyDlxiuIjFTS85\n" +
+                    "ZDdLTFyZGH3H+ggXb2qQFONVwqU6FeCWqayHkfNvht0CgYEA1B3SFBQBXXa85ZJJ\n" +
+                    "VOArcfQCVSbMNvNW3E0EXqXDwvxCwZ4SZ5NFN7lqsyzi7kDrerGD7GUiWn0GaTe4\n" +
+                    "8AoJH06w9GgYgtaa+F1Gc5eIpf9rkDn3eGcju1SxyD/GWnODNTObVKet8ECPvRAS\n" +
+                    "JFCrqBcGtvdss/TZwwjyjVDd7WcCgYEAx8dK3IBqu4y6GqsyeEcvoOc+93MMn9GH\n" +
+                    "AyIWXFsWBllMMadnOp9uaPN8RQ3wEv5ADR/e8G80m/YwkPkwqRnMCNTLMS4baQCE\n" +
+                    "W6hpNYDbihvJ4oYgt9VUjkPaK6/V3vv8gZ8lOmr5m+MtC4W3KlVWl5N7IOHnE8c3\n" +
+                    "0i2P9d5UTecCgYEAoVqR3L4fD7bsGIJAEDV6NvPT9mxN+hr/XxOjGFBmauOz8E7L\n" +
+                    "V0i65ZnBJc0mTvDJcAqq/N8t08G5Z76AivBHYi5dA35P7u9K8VUa64FA7G0wqIS/\n" +
+                    "0DI9Bxp1JowE2fd2vrikFzB6awDhloMsGb3X5kSl0fbDFN1T7T3YLFFwkE8CgYEA\n" +
+                    "pZ2nh+jFkrl1RsqJyoBpEaDi6ExuOMeAf65wjxvLxHOe4MMApu6on0TKzoxha71u\n" +
+                    "5/J15Sirmmebl3OdTXJfXSrbT+gDPSb6kumoXnx2g6XJ1YqP8RZfRJkncnQQfwyz\n" +
+                    "JdoDCMzKJNtN6nFtiSC56mqO9pGP+St8MTcoUxF/Hh0CgYA143jfnulzsQrsIH1X\n" +
+                    "6N+fn1oIhABPbzaGq/NT9KZHSIKyYEzi2IDmm27jpyVjbfY5wFQzJzo74H7KhZNG\n" +
+                    "5/T0eonnMX2CfODuRuht0a6PAEYAXNOZiGwL/wNSvV2GqWN36tI3c2Ofv1IY6reW\n" +
+                    "lutD+fSQjViKAdI3Ls91B59z+Q==";
+
+    private RsaCryptoUtil() {
+    }
+
+    public static String generateAesKey() {
+        String chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+        StringBuilder sb = new StringBuilder(16);
+        SecureRandom random = new SecureRandom();
+        for (int i = 0; i < 16; i++) {
+            sb.append(chars.charAt(random.nextInt(chars.length())));
+        }
+        return sb.toString();
+    }
+
+    public static String aesEncrypt(String plainText, String aesKey) {
+        try {
+            byte[] keyBytes = aesKey.getBytes(StandardCharsets.UTF_8);
+            SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
+            IvParameterSpec ivSpec = new IvParameterSpec(keyBytes);
+            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+            cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
+            byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
+            return Base64.getEncoder().encodeToString(encrypted);
+        } catch (Exception e) {
+            throw new RuntimeException("AES encrypt failed", e);
+        }
+    }
+
+    public static String aesDecrypt(String cipherTextBase64, String aesKey) {
+        try {
+            byte[] keyBytes = aesKey.getBytes(StandardCharsets.UTF_8);
+            SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
+            IvParameterSpec ivSpec = new IvParameterSpec(keyBytes);
+            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+            cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
+            byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherTextBase64));
+            return new String(decrypted, StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            throw new RuntimeException("AES decrypt failed", e);
+        }
+    }
+
+    public static String rsaDecryptSign(String encryptedSign) {
+        return decryptByPrivateKey(encryptedSign);
+    }
+
+    public static String rsaEncryptAesKey(String aesKey) {
+        return encryptByPublicKey(aesKey);
+    }
+
+    public static String encryptByPublicKey(String plainText) {
+        return encryptByPublicKey(plainText, PUBLIC_KEY);
+    }
+
+    /**
+     * 公钥加密(明文过长时建议加密「AES密钥」,不要直接加密整段业务 JSON)
+     */
+    public static String encryptByPublicKey(String plainText, String publicKeyBase64) {
+        try {
+            PublicKey publicKey = loadPublicKey(publicKeyBase64);
+            Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+            cipher.init(Cipher.ENCRYPT_MODE, publicKey);
+            byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
+            return Base64.getEncoder().encodeToString(encrypted);
+        } catch (Exception e) {
+            throw new RuntimeException("RSA encrypt failed", e);
+        }
+    }
+
+    public static String decryptByPrivateKey(String cipherTextBase64) {
+        return decryptByPrivateKey(cipherTextBase64, PRIVATE_KEY);
+    }
+
+    public static String decryptByPrivateKey(String cipherTextBase64, String privateKeyBase64) {
+        try {
+            PrivateKey privateKey = loadPrivateKey(privateKeyBase64);
+            Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+            cipher.init(Cipher.DECRYPT_MODE, privateKey);
+            byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherTextBase64));
+            return new String(decrypted, StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            throw new RuntimeException("RSA decrypt failed", e);
+        }
+    }
+
+    public static String sign(String content, String privateKeyBase64) {
+        try {
+            PrivateKey privateKey = loadPrivateKey(privateKeyBase64);
+            java.security.Signature signature = java.security.Signature.getInstance("SHA256withRSA");
+            signature.initSign(privateKey);
+            signature.update(content.getBytes(StandardCharsets.UTF_8));
+            return Base64.getEncoder().encodeToString(signature.sign());
+        } catch (Exception e) {
+            throw new RuntimeException("RSA sign failed", e);
+        }
+    }
+
+    public static boolean verify(String content, String signBase64, String publicKeyBase64) {
+        try {
+            PublicKey publicKey = loadPublicKey(publicKeyBase64);
+            java.security.Signature signature = java.security.Signature.getInstance("SHA256withRSA");
+            signature.initVerify(publicKey);
+            signature.update(content.getBytes(StandardCharsets.UTF_8));
+            return signature.verify(Base64.getDecoder().decode(signBase64));
+        } catch (Exception e) {
+            throw new RuntimeException("RSA verify failed", e);
+        }
+    }
+
+    public static PublicKey loadPublicKey(String publicKey) throws Exception {
+        String key = normalizeKey(publicKey);
+        byte[] keyBytes = Base64.getDecoder().decode(key);
+        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
+        return KeyFactory.getInstance(ALGORITHM).generatePublic(keySpec);
+    }
+
+    public static PrivateKey loadPrivateKey(String privateKey) throws Exception {
+        String key = normalizeKey(privateKey);
+        byte[] keyBytes = Base64.getDecoder().decode(key);
+        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
+        return KeyFactory.getInstance(ALGORITHM).generatePrivate(keySpec);
+    }
+
+    private static String normalizeKey(String key) {
+        if (key == null) {
+            throw new IllegalArgumentException("key is null");
+        }
+        return key
+                .replace("-----BEGIN PUBLIC KEY-----", "")
+                .replace("-----END PUBLIC KEY-----", "")
+                .replace("-----BEGIN PRIVATE KEY-----", "")
+                .replace("-----END PRIVATE KEY-----", "")
+                .replace("-----BEGIN RSA PRIVATE KEY-----", "")
+                .replace("-----END RSA PRIVATE KEY-----", "")
+                .replaceAll("\\s+", "");
+    }
+
+    public static void main(String[] args) {
+//        String aesKey = generateAesKey();
+//        System.out.println("aesKey = " + aesKey);
+//
+//        long timestamp = System.currentTimeMillis();
+//        System.out.println("timestamp=" + timestamp);
+//
+//        String tmp = "timestamp=" + timestamp + "&aesKey=" + aesKey;
+//        String string = RsaCryptoUtil.rsaEncryptAesKey(tmp);
+//        System.out.println("sign=" + string);
+//
+//        String plain = "{\"userId\":1,\"score\":10}";
+//        String cipher = aesEncrypt(plain, aesKey);
+//        System.out.println("cipher = " + cipher);
+
+        String back = aesDecrypt("", "tbEV9eUe8SUguMac");
+        System.out.println("plain  = " + back);
+
+//        String sign = rsaEncryptAesKey(aesKey);
+//        System.out.println("sign = " + sign);
+
+        String decryptedSign = rsaDecryptSign("dQH/TWe9thbuhoGaZxzvMhzoBAlLLRvrKBQvCiJ9hwsjtV6ATsj0V/cvRWXWEzvANbFxnpoykO9R5Kz9citqubLmbRBXwEXESeTJUOn6r2oKv/KvE/922h8husFthl21LTnPCagdnxTBpry/oIE43K6ixGPQM78TZ+G1t+VbJTsFssSElZFyTh/TQDdfCwIRQg0/FKZwXscJ52Xaf/6qk5OoNAoTNyRdQ6r/7ZMxKnwbRyBtTOOH94BTRnZR9dKc1U7zHtUbWCpZdMPXxk0LQI6JtOlqYiK+PhBy132Px+YUG9Eo2YHa+fUAHraJFw+VmbW/eHaMRiy/u1jX5Wj2rg==");
+        System.out.println("decryptedSign = " + decryptedSign);
+    }
+}

+ 282 - 0
fs-user-app/src/main/java/com/fs/core/filter/TransmissionWrapperFilter.java

@@ -0,0 +1,282 @@
+package com.fs.core.filter;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.core.wrapper.CachedBodyHttpServletRequestWrapper;
+import com.fs.core.wrapper.CachedBodyHttpServletResponseWrapper;
+import com.fs.core.wrapper.DecryptedParameterRequestWrapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StreamUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE + 20)
+public class TransmissionWrapperFilter extends OncePerRequestFilter {
+
+    private static final long TIMESTAMP_THRESHOLD_MS = 10 * 1000;
+
+    private static final String NONCE_CACHE_PREFIX = "api:nonce:";
+
+    private static final String DEVICE_NONCE_CACHE_PREFIX = "api:device:nonce:";
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private TransmissionWrapperProperties wrapperProperties;
+
+    private final AntPathMatcher pathMatcher = new AntPathMatcher();
+
+    @Override
+    protected boolean shouldNotFilter(HttpServletRequest request) {
+        String uri = request.getRequestURI();
+        List<String> excludePatterns = wrapperProperties.getExcludePatterns();
+        if (!CollectionUtils.isEmpty(excludePatterns)) {
+            for (String pattern : excludePatterns) {
+                if (pathMatcher.match(pattern, uri)) {
+                    return true;
+                }
+            }
+        }
+        String contentType = request.getContentType();
+        return contentType != null && contentType.toLowerCase().contains("multipart/form-data");
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+            throws ServletException, IOException {
+        //版本(旧版不会传递)
+        String version = request.getHeader("AppVersion");
+        if (ObjectUtil.isEmpty(version)) {
+            filterChain.doFilter(request, response);
+            return;
+        }
+
+        String signHeader = request.getHeader("X-Api-Sign");
+        if (signHeader == null || signHeader.isEmpty()) {
+            writeError(response, 401, "非法请求");
+            return;
+        }
+
+        String timestampHeader = request.getHeader("X-Api-Timestamp");
+        if (timestampHeader == null || timestampHeader.isEmpty()) {
+            writeError(response, 401, "非法请求");
+            return;
+        }
+
+        long headerTimestamp;
+        try {
+            headerTimestamp = Long.parseLong(timestampHeader);
+        } catch (NumberFormatException e) {
+            writeError(response, 401, "非法请求");
+            return;
+        }
+
+//        long now = System.currentTimeMillis();
+//        if (Math.abs(now - headerTimestamp) > TIMESTAMP_THRESHOLD_MS) {
+//            writeError(response, 401, "请求已过期");
+//            return;
+//        }
+
+        String nonce = request.getHeader("X-Api-Nonce");
+        if (nonce != null && !nonce.isEmpty()) {
+            String nonceKey = NONCE_CACHE_PREFIX + nonce;
+            if (redisCache.getCacheObject(nonceKey) != null) {
+                writeError(response, 401, "重复请求");
+                return;
+            }
+            redisCache.setCacheObject(nonceKey, "1", 15, TimeUnit.SECONDS);
+        }
+
+        String deviceId = request.getHeader("X-Api-DeviceId");
+        if (deviceId != null && !deviceId.isEmpty() && nonce != null && !nonce.isEmpty()) {
+            String deviceNonceKey = DEVICE_NONCE_CACHE_PREFIX + deviceId + ":" + nonce;
+            if (redisCache.getCacheObject(deviceNonceKey) != null) {
+                writeError(response, 401, "设备重复请求");
+                return;
+            }
+            redisCache.setCacheObject(deviceNonceKey, "1", 15, TimeUnit.SECONDS);
+        }
+
+        String decryptedSign = RsaCryptoUtil.rsaDecryptSign(signHeader);
+        long signTimestamp = 0;
+        String aesKey = null;
+        String[] pairs = decryptedSign.split("&");
+        for (String pair : pairs) {
+            String[] kv = pair.split("=", 2);
+            if (kv.length == 2) {
+                if ("timestamp".equals(kv[0])) {
+                    signTimestamp = Long.parseLong(kv[1]);
+                } else if ("aesKey".equals(kv[0])) {
+                    aesKey = kv[1];
+                }
+            }
+        }
+
+        if (aesKey == null) {
+            writeError(response, 401, "签名格式错误");
+            return;
+        }
+
+        if (signTimestamp != 0 && signTimestamp != headerTimestamp) {
+            writeError(response, 401, "时间戳不一致");
+            return;
+        }
+        System.out.println(request.getRequestURI());
+        if (request.getRequestURI().contains("/app/integral/withdrawal")) {
+            System.out.println(request.getRequestURI());
+        }
+        HttpServletRequest requestToUse = request;
+        String method = request.getMethod();
+        if ("POST".equalsIgnoreCase(method)
+                || "PUT".equalsIgnoreCase(method)
+                || "PATCH".equalsIgnoreCase(method)
+                || "DELETE".equalsIgnoreCase(method)) {
+            byte[] bodyBytes = StreamUtils.copyToByteArray(request.getInputStream());
+            if (bodyBytes.length > 0) {
+                String body = new String(bodyBytes, StandardCharsets.UTF_8).trim();
+                // 支持:纯 Base64 密文,或 {"encryptedData":"..."} / {"data":"..."}
+                String cipherText = extractCipherText(body);
+                try {
+                    String plainJson = RsaCryptoUtil.aesDecrypt(cipherText, aesKey);
+                    requestToUse = new CachedBodyHttpServletRequestWrapper(
+                            request, plainJson.getBytes(StandardCharsets.UTF_8));
+                } catch (RuntimeException e) {
+                    writeError(response, 401, "请求体解密失败");
+                    return;
+                }
+            }
+        } else if ("GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method)) {
+            try {
+                requestToUse = wrapGetWithDecryptedParams(request, aesKey);
+            } catch (RuntimeException e) {
+                writeError(response, 401, "请求参数解密失败");
+                return;
+            }
+        }
+
+        CachedBodyHttpServletResponseWrapper responseWrapper = new CachedBodyHttpServletResponseWrapper(response);
+        filterChain.doFilter(requestToUse, responseWrapper);
+
+        byte[] respBytes = responseWrapper.getCachedBody();
+        if (respBytes.length == 0) {
+            return;
+        }
+        String plainResp = new String(respBytes, StandardCharsets.UTF_8);
+        String responseAesKey = RsaCryptoUtil.generateAesKey();
+        String encryptedData = RsaCryptoUtil.aesEncrypt(plainResp, responseAesKey);
+        String encryptedKey = RsaCryptoUtil.rsaEncryptAesKey(responseAesKey);
+        JSONObject out = new JSONObject();
+        out.put("encryptedKey", encryptedKey);
+        out.put("encryptedData", encryptedData);
+        if (request.getRequestURI().startsWith("/h5/userAgreementNew")) {
+            System.out.println("key数据:"+encryptedKey);
+            System.out.println("Data数据:"+encryptedData);
+        }
+        byte[] outBytes = out.toJSONString().getBytes(StandardCharsets.UTF_8);
+        response.setContentType("application/json;charset=UTF-8");
+        response.setContentLength(outBytes.length);
+        response.getOutputStream().write(outBytes);
+        response.getOutputStream().flush();
+    }
+
+    /**
+     * GET:从 query 的 data/encryptedData,或 request body 密文中解密出 JSON,并注入为 request 参数。
+     * 若无密文载体则原样返回(兼容明文 query)。
+     */
+    private HttpServletRequest wrapGetWithDecryptedParams(HttpServletRequest request, String aesKey) throws IOException {
+        String cipherText = request.getParameter("encryptedData");
+        if (cipherText == null || cipherText.isEmpty()) {
+            cipherText = request.getParameter("data");
+        }
+
+        byte[] bodyBytes = StreamUtils.copyToByteArray(request.getInputStream());
+        if ((cipherText == null || cipherText.isEmpty()) && bodyBytes.length > 0) {
+            String body = new String(bodyBytes, StandardCharsets.UTF_8).trim();
+            if (!body.isEmpty()) {
+                cipherText = extractCipherText(body);
+            }
+        }
+
+        if (cipherText == null || cipherText.isEmpty()) {
+            // 未加密:若已读空 body,无需特殊处理
+            if (bodyBytes.length > 0) {
+                return new CachedBodyHttpServletRequestWrapper(request, bodyBytes, request.getContentType());
+            }
+            return request;
+        }
+
+        String plainJson = RsaCryptoUtil.aesDecrypt(cipherText.trim(), aesKey);
+        Map<String, String> params = jsonToParamMap(plainJson);
+        HttpServletRequest paramWrapped = new DecryptedParameterRequestWrapper(request, params);
+        // body 已被消费时用空/明文 JSON 补回,避免下游再读流异常;GET 主要靠参数绑定
+        return new CachedBodyHttpServletRequestWrapper(
+                paramWrapped, plainJson.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private Map<String, String> jsonToParamMap(String plainJson) {
+        Map<String, String> result = new LinkedHashMap<>();
+        if (plainJson == null || plainJson.isEmpty()) {
+            return result;
+        }
+        JSONObject json = JSON.parseObject(plainJson);
+        if (json == null) {
+            return result;
+        }
+        for (String key : json.keySet()) {
+            Object value = json.get(key);
+            if (value == null || value instanceof JSONObject || value instanceof com.alibaba.fastjson.JSONArray) {
+                continue;
+            }
+            result.put(key, String.valueOf(value));
+        }
+        return result;
+    }
+
+    /**
+     * 请求体可为纯 Base64,或 JSON 包装中的 encryptedData / data 字段。
+     */
+    private String extractCipherText(String body) {
+        if (body.startsWith("{")) {
+            JSONObject json = JSON.parseObject(body);
+            String cipher = json.getString("encryptedData");
+            if (cipher == null || cipher.isEmpty()) {
+                cipher = json.getString("data");
+            }
+            if (cipher == null || cipher.isEmpty()) {
+                throw new IllegalArgumentException("密文字段缺失");
+            }
+            return cipher.trim();
+        }
+        // 去掉可能的首尾引号
+        if (body.length() >= 2 && body.startsWith("\"") && body.endsWith("\"")) {
+            return body.substring(1, body.length() - 1);
+        }
+        return body;
+    }
+
+    private void writeError(HttpServletResponse response, int status, String message) throws IOException {
+        response.setStatus(status);
+        response.setContentType("application/json;charset=UTF-8");
+        response.getWriter().write(JSON.toJSONString(R.error(status, message)));
+    }
+}

+ 19 - 0
fs-user-app/src/main/java/com/fs/core/filter/TransmissionWrapperProperties.java

@@ -0,0 +1,19 @@
+package com.fs.core.filter;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "transmission.wrapper")
+public class TransmissionWrapperProperties {
+
+    /**
+     * 不走加解密的路径 Ant 匹配列表
+     */
+    private List<String> excludePatterns = new ArrayList<>();
+}

+ 97 - 0
fs-user-app/src/main/java/com/fs/core/wrapper/CachedBodyHttpServletRequestWrapper.java

@@ -0,0 +1,97 @@
+package com.fs.core.wrapper;
+
+import javax.servlet.ReadListener;
+import javax.servlet.ServletInputStream;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Enumeration;
+
+public class CachedBodyHttpServletRequestWrapper extends HttpServletRequestWrapper {
+
+    private static final String DEFAULT_CONTENT_TYPE = "application/json;charset=UTF-8";
+
+    private final byte[] cachedBody;
+    private final String contentType;
+
+    public CachedBodyHttpServletRequestWrapper(HttpServletRequest request, byte[] cachedBody) {
+        this(request, cachedBody, DEFAULT_CONTENT_TYPE);
+    }
+
+    public CachedBodyHttpServletRequestWrapper(HttpServletRequest request, byte[] cachedBody, String contentType) {
+        super(request);
+        this.cachedBody = cachedBody == null ? new byte[0] : cachedBody;
+        this.contentType = contentType;
+    }
+
+    @Override
+    public String getContentType() {
+        return contentType != null ? contentType : super.getContentType();
+    }
+
+    @Override
+    public String getHeader(String name) {
+        if ("Content-Type".equalsIgnoreCase(name) && contentType != null) {
+            return contentType;
+        }
+        if ("Content-Length".equalsIgnoreCase(name)) {
+            return String.valueOf(cachedBody.length);
+        }
+        return super.getHeader(name);
+    }
+
+    @Override
+    public Enumeration<String> getHeaders(String name) {
+        if ("Content-Type".equalsIgnoreCase(name) && contentType != null) {
+            return Collections.enumeration(Collections.singletonList(contentType));
+        }
+        if ("Content-Length".equalsIgnoreCase(name)) {
+            return Collections.enumeration(Collections.singletonList(String.valueOf(cachedBody.length)));
+        }
+        return super.getHeaders(name);
+    }
+
+    @Override
+    public int getContentLength() {
+        return cachedBody.length;
+    }
+
+    @Override
+    public long getContentLengthLong() {
+        return cachedBody.length;
+    }
+
+    @Override
+    public ServletInputStream getInputStream() {
+        ByteArrayInputStream bais = new ByteArrayInputStream(cachedBody);
+        return new ServletInputStream() {
+            @Override
+            public boolean isFinished() {
+                return bais.available() == 0;
+            }
+
+            @Override
+            public boolean isReady() {
+                return true;
+            }
+
+            @Override
+            public void setReadListener(ReadListener readListener) {
+            }
+
+            @Override
+            public int read() {
+                return bais.read();
+            }
+        };
+    }
+
+    @Override
+    public BufferedReader getReader() {
+        return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
+    }
+}

+ 59 - 0
fs-user-app/src/main/java/com/fs/core/wrapper/CachedBodyHttpServletResponseWrapper.java

@@ -0,0 +1,59 @@
+package com.fs.core.wrapper;
+
+import javax.servlet.ServletOutputStream;
+import javax.servlet.WriteListener;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+import java.io.ByteArrayOutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+
+public class CachedBodyHttpServletResponseWrapper extends HttpServletResponseWrapper {
+
+    private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+    private ServletOutputStream outputStream;
+    private PrintWriter writer;
+
+    public CachedBodyHttpServletResponseWrapper(HttpServletResponse response) {
+        super(response);
+    }
+
+    @Override
+    public ServletOutputStream getOutputStream() {
+        if (outputStream == null) {
+            outputStream = new ServletOutputStream() {
+                @Override
+                public boolean isReady() {
+                    return true;
+                }
+
+                @Override
+                public void setWriteListener(WriteListener writeListener) {
+                }
+
+                @Override
+                public void write(int b) {
+                    buffer.write(b);
+                }
+            };
+        }
+        return outputStream;
+    }
+
+    @Override
+    public PrintWriter getWriter() {
+        if (writer == null) {
+            writer = new PrintWriter(new OutputStreamWriter(getOutputStream(), StandardCharsets.UTF_8));
+        }
+        return writer;
+    }
+
+    public byte[] getCachedBody() {
+        if (writer != null) {
+            writer.flush();
+        }
+        return buffer.toByteArray();
+    }
+
+}

+ 60 - 0
fs-user-app/src/main/java/com/fs/core/wrapper/DecryptedParameterRequestWrapper.java

@@ -0,0 +1,60 @@
+package com.fs.core.wrapper;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 将解密后的 JSON 字段注入到 request 参数中,供 GET 等按 query/form 绑定的接口使用。
+ */
+public class DecryptedParameterRequestWrapper extends HttpServletRequestWrapper {
+
+    private final Map<String, String[]> parameterMap;
+
+    public DecryptedParameterRequestWrapper(HttpServletRequest request, Map<String, String> decryptedParams) {
+        super(request);
+        Map<String, String[]> merged = new LinkedHashMap<>();
+        Map<String, String[]> original = request.getParameterMap();
+        if (original != null) {
+            merged.putAll(original);
+        }
+        // 去掉密文载体字段,避免污染业务参数
+        merged.remove("data");
+        merged.remove("encryptedData");
+        if (decryptedParams != null) {
+            for (Map.Entry<String, String> entry : decryptedParams.entrySet()) {
+                String key = entry.getKey();
+                if (key == null) {
+                    continue;
+                }
+                String value = entry.getValue();
+                merged.put(key, new String[]{value == null ? "" : value});
+            }
+        }
+        this.parameterMap = Collections.unmodifiableMap(merged);
+    }
+
+    @Override
+    public String getParameter(String name) {
+        String[] values = parameterMap.get(name);
+        return values == null || values.length == 0 ? null : values[0];
+    }
+
+    @Override
+    public Map<String, String[]> getParameterMap() {
+        return parameterMap;
+    }
+
+    @Override
+    public Enumeration<String> getParameterNames() {
+        return Collections.enumeration(parameterMap.keySet());
+    }
+
+    @Override
+    public String[] getParameterValues(String name) {
+        return parameterMap.get(name);
+    }
+}