拦截器校验签名实现应用接入

2025-04-09

2025-04-09-拦截器校验签名实现应用接入

1、场景分析

当外部第三方应用需要接入自身系统时,我们使用数据签名来校验第三方数据的正确性,保证第三方传输的数据不会在中途被篡改。同时还需要防止重放攻击、国产化签名方式(SM3)。

2、实现思路

实现签名的前提时提供应用接入的管理方案,例如为每个第三方应用颁布appId和appSecret,appId相当于账号,appSecret相当于密码,appSecret会被用于数据签名。

  1. 第三方接口发送请求时,需要在header​携带参数:

    • appId​应用id,用于识别身份
    • timestamp​时间戳
    • signature​签名
  2. 构成签名的数据如下:

    appId​ + timestamp​ + urlParams​ + bodyAsString

    • appId​应用id
    • timestamp​时间戳
    • urlParams​为url上携带的参数
    • bodyAsString​为body携带的参数
  3. 拦截器的执行步骤:

    • 检查必要的请求头参数:appId​、timestamp​、signature
    • 校验时间戳,防止重放攻击:接入应用的请求时间与当前服务时间不能超过5分钟
    • 获取请求参数(需要注意reqeust获取流只能获取一次的问题1),计算签名
    • 校验签名
    • 请求完成后清空ThreadLocal
  4. 使用ThreadLocal​在业务代码中随时获取当前请求的应用信息

3、源码

1.ThreadLocal存储当前请求的应用信息

import com.alibaba.ttl.TransmittableThreadLocal;
import com.hymake.biz.modular.product.entity.ProductApp;
import lombok.experimental.UtilityClass;

import java.util.Objects;

/**
 * 应用信息上下文线程变量
 *
 * @author huanghwh
 * @date 2025/04/03 09:16
 */
@UtilityClass
public final class AppContextLocal {

    private static final ThreadLocal<ProductApp> THREAD_LOCAL = new TransmittableThreadLocal<>();

    /**
     * 获取
     */
    public static ProductApp get(){
        ProductApp productApp = THREAD_LOCAL.get();
        if (Objects.isNull(productApp)){
            productApp = new ProductApp();
            THREAD_LOCAL.set(productApp);
        }
        return productApp;
    }

    /**
     * 设置
     *
     * @author huanghwh
     * @date 2025/04/03 09:13
     */
    public static void set(ProductApp productApp) {
        THREAD_LOCAL.set(productApp);
    }

    /**
     * 清除
     */
    public static void clear() {
        THREAD_LOCAL.remove();
    }
}

2.SM3签名工具

  1. 引入工具包

     <!-- sm3国密 -->
     <dependency>
     	<groupId>org.bouncycastle</groupId>
     	<artifactId>bcprov-jdk18on</artifactId>
     	<version>1.76</version>
     </dependency>
    
  2. 工具类

     import org.bouncycastle.crypto.digests.SM3Digest;
     import org.bouncycastle.crypto.macs.HMac;
     import org.bouncycastle.crypto.params.KeyParameter;
    
     import java.nio.charset.StandardCharsets;
    
     public class SM3Util {
    
         /**
          * 计算 SM3 HMAC 签名
          *
          * @param message   需要签名的内容
          * @param secretKey 签名密钥
          * @return 签名后的字符串
          */
         public static String hmacSM3(String message, String secretKey) {
             HMac hmac = new HMac(new SM3Digest());
             hmac.init(new KeyParameter(secretKey.getBytes(StandardCharsets.UTF_8)));
    
             byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8);
             hmac.update(messageBytes, 0, messageBytes.length);
    
             byte[] result = new byte[hmac.getMacSize()];
             hmac.doFinal(result, 0);
    
             return bytesToHex(result);
         }
    
    
         private static String bytesToHex(byte[] bytes) {
             StringBuilder sb = new StringBuilder();
             for (byte b : bytes) {
                 sb.append(String.format("%02x", b));
             }
             return sb.toString();
         }
    
     }
    

3.拦截器实现

import com.hymake.biz.modular.product.entity.ProductApp;
import com.hymake.biz.modular.product.service.ProductService;
import com.hymake.biz.modular.util.AppContextLocal;
import com.hymake.core.util.SM3Util;
import jakarta.annotation.Resource;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;

import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

/**
 * 全局客户端接口拦截器、签名校验
 *
 * @author huanghwh
 * @date 2025/04/02 15:45
 */
@Slf4j
@Component
public class GlobalClientInterceptor implements HandlerInterceptor {

    // 允许的时间戳误差(5分钟)
    private static final long TIME_DIFF_LIMIT = 5 * 60 * 1000;

    @Resource
    private ProductService productService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String appId = request.getHeader("app-id");
        String timestamp = request.getHeader("timestamp");
        String signature = request.getHeader("signature");

        // 检查必要的请求头参数
        if (!StringUtils.hasText(appId) || !StringUtils.hasText(timestamp) || !StringUtils.hasText(signature)) {
            log.error("验签失败:参数缺失,appId:{}", appId);
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Missing authentication headers");
            return false;
        }

        // 校验时间戳,防止重放攻击
        long requestTime;
        try {
            requestTime = Long.parseLong(timestamp);
        } catch (NumberFormatException e) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            response.getWriter().write("Invalid timestamp format");
            return false;
        }

        if (Math.abs(System.currentTimeMillis() - requestTime) > TIME_DIFF_LIMIT) {
            log.error("验签失败:服务器时间不一致,appId:{}", appId);
            log.error(String.valueOf(System.currentTimeMillis()));
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Request expired");
            return false;
        }

        // **获取url参数**
        String urlParams = getSortedQueryParams(request);

        // **获取body参数**
        ServletInputStream inputStream = request.getInputStream();
        String bodyAsString = new String(inputStream.readAllBytes());

        // 构造需要签名的数据
        String dataToSign = appId + timestamp + urlParams + bodyAsString;
        ProductApp productApp = productService.getAppInfoByAppId(appId);
        String serverSignature = SM3Util.hmacSM3(dataToSign, productApp.getAppSecret());

        // 校验签名
        if (!serverSignature.equals(signature)) {
            log.error("验签失败:签名错误,appId:{}", appId);
            log.error(serverSignature);
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Invalid signature");
            return false;
        }

        // 初始化上下文变量
        productApp.setAppSecret(null);
        AppContextLocal.set(productApp);
        //log.info("签名校验成功,appId:{}", appId);
        return true;
    }


    /**
     * 获取url参数,排序输出
     *
     * @author huanghwh
     * @date 2025/04/02 16:53
     */
    public static String getSortedQueryParams(HttpServletRequest request) {
        Map<String, String[]> paramMap = request.getParameterMap();

        if (paramMap == null || paramMap.isEmpty()) {
            // **空格占位,确保签名计算一致**
            return " ";
        }

        TreeMap<String, String> sortedParams = new TreeMap<>();
        for (Map.Entry<String, String[]> entry : paramMap.entrySet()) {
            String key = entry.getKey();
            // 多值用逗号拼接
            String value = String.join(",", entry.getValue());
            sortedParams.put(key, value);
        }

        return sortedParams.entrySet().stream()
                .map(entry -> entry.getKey() + "=" + entry.getValue())
                .collect(Collectors.joining("&"));
    }


    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // **请求完成后清理 ThreadLocal**
        AppContextLocal.clear();
    }
}

4.注册拦截器

import jakarta.annotation.Resource;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Resource
    private GlobalClientInterceptor globalClientInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(globalClientInterceptor)
                .addPathPatterns(
                        "/c/**",
                ).excludePathPatterns("/c/biz/memberOrder/callback/notify");
    }
}

4、第三方应用接入

1.请求时统一生成签名

  1. 工具类同SM3签名工具2
  2. 请求工具类(基于hutool的http请求工具)

     import cn.hutool.core.bean.BeanUtil;
     import cn.hutool.http.HttpRequest;
     import cn.hutool.http.HttpResponse;
     import cn.hutool.http.Method;
     import cn.hutool.json.JSONObject;
     import cn.hutool.json.JSONUtil;
     import com.fasterxml.jackson.databind.ObjectMapper;
     import com.hymake.common.cache.CommonCacheOperator;
     import com.hymake.common.exception.CommonException;
     import jakarta.annotation.Resource;
     import org.apache.commons.lang3.StringUtils;
     import org.springframework.beans.factory.annotation.Value;
     import org.springframework.stereotype.Component;
    
     import java.text.SimpleDateFormat;
     import java.util.List;
     import java.util.Map;
     import java.util.TreeMap;
    
     /**
      * 会员服务请求工具
      *
      * @author huanghwh
      * @date 2025/04/03 10:06
      */
     @Component
     public class MemberServiceRequest {
    
         private static String host;
         private static String appId;
         private static String appSecret;
         public static final String CACHE_MEMBER_TOKEN = "member:service:token:";
    
         @Value("${member-service.host}")
         public void setHost(String host) {
             MemberServiceRequest.host = host;
         }
    
         @Value("${member-service.app-id}")
         public void setAppId(String appId) {
             MemberServiceRequest.appId = appId;
         }
    
         @Value("${member-service.app-secret}")
         public void setAppSecret(String appSecret) {
             MemberServiceRequest.appSecret = appSecret;
         }
    
         @Resource
         private CommonCacheOperator commonCacheOperator;
    
    
         /**
          * 发送请求
          *
          * @param url      请求 URL
          * @param method   请求方法(GET、POST)
          * @param params   GET 请求参数 或者 POST 表单参数
          * @param jsonBody POST JSON 参数
          * @return 响应字符串
          */
         public String sendRequest(String url, Method method, Map<String, Object> params, String jsonBody) {
             long timestamp = System.currentTimeMillis();
             String signature = generateSignature(appId, timestamp, params, jsonBody);
    
             HttpRequest request = HttpRequest.of(host + url)
                     .method(method)
                     .header("app-id", appId)
                     .header("timestamp", String.valueOf(timestamp))
                     .header("signature", signature);
    
             // 如果 token 不为空,则补充到 Header
             if (StpClientUtil.isLogin()) {
                 Object token = commonCacheOperator.get(CACHE_MEMBER_TOKEN + StpClientUtil.getLoginIdAsString());
                 request.header("Token", token.toString());
             }
    
             if (method == Method.GET) {
                 request.form(params);
             } else if (method == Method.POST) {
                 if (jsonBody != null) {
                     request.body(jsonBody).header("Content-Type", "application/json");
                 } else {
                     request.form(params);
                 }
             }
    
             try (HttpResponse response = request.execute()) {
                 int status = response.getStatus();
                 String responseBody = response.body();
                 // 处理不同状态码
                 switch (status) {
                     case 200:
                         // 解析JSON并提取 data 字段
                         JSONObject jsonResponse = JSONUtil.parseObj(responseBody);
                         String code = jsonResponse.getStr("code");
                         if (!StringUtils.equals("200", code)) {
                             throw new CommonException(jsonResponse.getStr("msg"));
                         }
                         if (jsonResponse.containsKey("data")) {
                             return jsonResponse.getStr("data"); // 直接返回 data 字段内容
                         } else {
                             return responseBody; // 没有 data 字段就返回整个响应
                         }
                     case 401:
                         throw new CommonException("Unauthorized: 签名校验失败");
                     case 403:
                         throw new CommonException("Forbidden: 无访问权限");
                     case 429:
                         throw new CommonException("Too Many Requests: 请求过多,请稍后再试");
                     case 500:
                         throw new CommonException("Server Error: 服务器内部错误");
                     default:
                         throw new CommonException("Unexpected Error: HTTP " + status + ",响应内容:" + responseBody);
                 }
             } catch (Exception e) {
                 throw new CommonException("请求失败:" + e.getMessage(), e);
             }
         }
    
         /**
          * 生成签名
          *
          * @param appId     应用ID
          * @param timestamp 时间戳
          * @param params    请求参数
          * @param jsonBody  JSON 请求体
          * @return 签名
          */
         private String generateSignature(String appId, long timestamp, Map<String, Object> params, String jsonBody) {
             // 排序参数
             TreeMap<String, String> sortedParams = new TreeMap<>();
             if (params != null) {
                 params.forEach((key, value) -> {
                     if (value != null && !String.valueOf(value).isEmpty()) { // 忽略 null 和 空字符串
                         sortedParams.put(key, String.valueOf(value));
                     }
                 });
             }
             String sortedParamsString = sortedParams.entrySet().stream()
                     .map(entry -> entry.getKey() + "=" + entry.getValue())
                     .reduce((a, b) -> a + "&" + b)
                     .orElse(" ");
    
             // 计算签名数据串
             String dataToSign = appId + timestamp + sortedParamsString + (jsonBody != null ? jsonBody : "");
    
             return SM3HmacUtil.hmac(dataToSign, appSecret);
         }
    
         /**
          * 发送 GET 请求
          */
         public String get(String url, Map<String, Object> params) {
             return sendRequest(url, Method.GET, params, null);
         }
    
         /**
          * 发送 GET 请求
          */
         public String get(String url, Object paramObject) {
             Map<String, Object> params = BeanUtil.beanToMap(paramObject);
             return sendRequest(url, Method.GET, params, null);
         }
    
         /**
          * 发送 GET 请求,返回 List<T>
          *
          * @author huanghwh
          * @date 2025/04/03 13:51
          */
         public <T> List<T> getList(String url, Map<String, Object> params, Class<T> responseType) {
             String jsonResponse = sendRequest(url, Method.GET, params, null);
             try {
                return JSONUtil.parseArray(jsonResponse).toList(responseType);
             } catch (Exception e) {
                 throw new CommonException("Failed to parse response:" + e.getMessage(), e);
             }
         }
    
         /**
          * 发送 GET 请求,返回指定类型
          *
          * @author huanghwh
          * @date 2025/04/03 13:51
          */
         public <T> T get(String url, Map<String, Object> params, Class<T> responseType) {
             String jsonResponse = sendRequest(url, Method.GET, params, null);
             try {
                 ObjectMapper objectMapper = new ObjectMapper().setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
                 return objectMapper.readValue(jsonResponse, responseType);
             } catch (Exception e) {
                 throw new CommonException("Failed to parse response:" + e.getMessage(), e);
             }
         }
    
         /**
          * 发送 POST JSON 请求
          */
         public String postJson(String url, String jsonBody) {
             return sendRequest(url, Method.POST, null, jsonBody);
         }
    
         /**
          * 发送 POST JSON 请求,支持对象自动转换为 JSON
          */
         public String postJson(String url, Object body) {
             String jsonBody = JSONUtil.toJsonStr(body);
             return sendRequest(url, Method.POST, null, jsonBody);
         }
    
         /**
          * 发送 POST JSON 请求,返回指定类型
          *
          * @author huanghwh
          * @date 2025/04/03 13:53
          */
         public <T> T postJson(String url, Object body, Class<T> responseType) {
             String jsonBody = JSONUtil.toJsonStr(body);
             String jsonResponse = sendRequest(url, Method.POST, null, jsonBody);
             try {
                 ObjectMapper objectMapper = new ObjectMapper().setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
                 return objectMapper.readValue(jsonResponse, responseType);
             } catch (Exception e) {
                 throw new CommonException("Failed to parse response:" + e.getMessage(), e);
             }
         }
    
         /**
          * 发送 POST 表单请求
          */
         public String postForm(String url, Map<String, Object> params) {
             return sendRequest(url, Method.POST, params, null);
         }
    
    
         /**
          * 发送 POST JSON 请求,返回指定类型
          *
          * @author huanghwh
          * @date 2025/04/03 13:53
          */
         public <T> T postForm(String url, Map<String, Object> params, Class<T> responseType) {
             String jsonResponse = sendRequest(url, Method.POST, params, null);;
             try {
                 ObjectMapper objectMapper = new ObjectMapper().setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
                 return objectMapper.readValue(jsonResponse, responseType);
             } catch (Exception e) {
                 throw new CommonException("Failed to parse response:" + e.getMessage(), e);
             }
         }
     }
    

  1. # 2025-04-02-解决servlet流只能读取一次的问题(拦截器验签)

    1、问题场景分析

    使用拦截器对接口传来的参数进行签名验证,校验数据完整性,为获取请求参数,需要调用HttpServletRequest​的getInputStream​方法获取请求的入参,在拦截器执行完毕,需要进入controller时,发现无法正确接收参数。

    2、原因分析

    经过查看spring源码可知,request​的getInputStream​方法只允许调用一次,原因是因为底层获取参数时是使用下标记录当前的位置,若全部读取完毕后,下标无法回到原来的位置,request​使用usingReader​字段来记录是否读取过数据。

    public ServletInputStream getInputStream() throws IOException {
            if (this.usingReader) {
                throw new IllegalStateException(sm.getString("coyoteRequest.getInputStream.ise"));
            } else {
                this.usingInputStream = true;
                if (this.inputStream == null) {
                    this.inputStream = new CoyoteInputStream(this.inputBuffer);
                }
    
                return this.inputStream;
            }
        }
    

    3、问题解决

    • 第一步:自定义实现ServletRequestWrapper​包装类,缓存HttpServletRequest​的流数据
    • 第二步:使用过滤器将后续传递的HttpServletRequest​都替换为自定义包装类,后续拦截器可实现重复读取

      注意:

      • 后续传递的实际都是ServletRequestWrapper​,它是HttpServletRequest​的子类,所以可以正常使用 HttpServletRequest​ 的所有方法,并且可以调用 MyHttpServletRequestWrapper​ 自己扩展的方法。

      • 在 Spring MVC 控制器方法中,@RequestMapping​ 等注解的方法如果接收 HttpServletRequest​ 参数,得到的也是 MyHttpServletRequestWrapper​,因为整个请求链(过滤器 -> 拦截器 -> 控制器)中传递的都是这个包装后的对象。

    1、实现ServletRequestWrapper

    无需修改,可直接复制使用

    MyHttpServletRequestWrapper​缓存了HttpServletRequest​的流数据,之后直接从缓存中读取,可以做到无限读取。

    public class MyHttpServletRequestWrapper extends HttpServletRequestWrapper {
    
        /**
         * 缓存流
         */
        private byte[] cacheBody;
    
        /**
         * 构造方法
         *
         * @param request
         * @throws IOException
         */
        public MyHttpServletRequestWrapper(HttpServletRequest request) {
            super(request);
        }
    
        /**
         * 获取缓冲流
         *
         * @return
         */
        @Override
        public BufferedReader getReader() throws IOException {
            return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
        }
    
        /**
         * 获取流
         *
         * @return
         */
        @Override
        public ServletInputStream getInputStream() throws IOException {
            // 1. 初始化缓存
            if (cacheBody == null) {
                cacheBody = StreamUtils.copyToByteArray(super.getInputStream());
            }
            // 3. 从缓存中返回流
            final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(cacheBody);
            return new ServletInputStream() {
                @Override
                public boolean isFinished() {
                    return false;
                }
    
                @Override
                public boolean isReady() {
                    return false;
                }
    
                @Override
                public void setReadListener(ReadListener readListener) {
    
                }
    
                @Override
                public int read() throws IOException {
                    return byteArrayInputStream.read();
                }
            };
        }
    }
    

    2、定义过滤器

    使用MyHttpServletRequestWrapper​替代HttpServletRequest​传递下去

    @Component
    public class MyHttpServletRequestWrapperFilter implements Filter {
    
        @Override
        public void init(FilterConfig filterConfig) throws ServletException {
    
        }
    
        /**
         * 对 request 进行包装
         *
         * @param request
         * @param response
         * @param chain
         * @throws IOException
         * @throws ServletException
         */
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    
    		// 使用MyHttpServletRequestWrapper替代ServletRequest传递下去
            MyHttpServletRequestWrapper requestWrapper = new MyHttpServletRequestWrapper((HttpServletRequest) request);
    
            chain.doFilter(requestWrapper, response);
        }
    
        @Override
        public void destroy() {
    
        }
    
    } 
    
    

    4、拦截器获取流数据

    直接调用ServletInputStream inputStream = request.getInputStream();​获取数据

    @Slf4j
    @Component
    public class GlobalClientInterceptor implements HandlerInterceptor {
    
        // 允许的时间戳误差(5分钟)
        private static final long TIME_DIFF_LIMIT = 5 * 60 * 1000;
    
        @Resource
        private ProductService productService;
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            String appId = request.getHeader("app-id");
            String timestamp = request.getHeader("timestamp");
            String signature = request.getHeader("signature");
    
            // 检查必要的请求头参数
            if (!StringUtils.hasText(appId) || !StringUtils.hasText(timestamp) || !StringUtils.hasText(signature)) {
                log.error("验签失败:参数缺失,appId:{}", appId);
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write("Missing authentication headers");
                return false;
            }
    
            // 校验时间戳,防止重放攻击
            long requestTime;
            try {
                requestTime = Long.parseLong(timestamp);
            } catch (NumberFormatException e) {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.getWriter().write("Invalid timestamp format");
                return false;
            }
    
            if (Math.abs(System.currentTimeMillis() - requestTime) > TIME_DIFF_LIMIT) {
                log.error("验签失败:服务器时间不一致,appId:{}", appId);
                log.error(String.valueOf(System.currentTimeMillis()));
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write("Request expired");
                return false;
            }
    
            // **计算 URL 参数哈希**
            String urlParams = getSortedQueryParams(request);
    
            // **计算 Body 哈希** 
            ServletInputStream inputStream = request.getInputStream();
            String bodyAsString = new String(inputStream.readAllBytes());
    
            // 构造需要签名的数据
            String dataToSign = appId + timestamp + urlParams + bodyAsString;
            ProductApp productApp = productService.getAppInfoByAppId(appId);
            String serverSignature = SM3Util.hmacSM3(dataToSign, productApp.getAppSecret());
    
            // 校验签名
            if (!serverSignature.equals(signature)) {
                log.error("验签失败:签名错误,appId:{}", appId);
                log.error(serverSignature);
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write("Invalid signature");
                return false;
            }
    
            // 初始化上下文变量
            productApp.setAppSecret(null);
            AppContextLocal.set(productApp);
            //log.info("签名校验成功,appId:{}", appId);
            return true;
        }
    
    
        /**
         * 获取url参数,排序输出
         *
         * @author huanghwh
         * @date 2025/04/02 16:53
         */
        public static String getSortedQueryParams(HttpServletRequest request) {
            Map<String, String[]> paramMap = request.getParameterMap();
    
            if (paramMap == null || paramMap.isEmpty()) {
                // **空格占位,确保签名计算一致**
                return " ";
            }
    
            TreeMap<String, String> sortedParams = new TreeMap<>();
            for (Map.Entry<String, String[]> entry : paramMap.entrySet()) {
                String key = entry.getKey();
                // 多值用逗号拼接
                String value = String.join(",", entry.getValue());
                sortedParams.put(key, value);
            }
    
            return sortedParams.entrySet().stream()
                    .map(entry -> entry.getKey() + "=" + entry.getValue())
                    .collect(Collectors.joining("&"));
        }
    
    
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            // **请求完成后清理 ThreadLocal**
            AppContextLocal.clear();
        }
    }
    

    拦截器注册

    @Configuration
    public class WebConfig implements WebMvcConfigurer {
    
        @Resource
        private GlobalClientInterceptor globalClientInterceptor;
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(globalClientInterceptor)
                    .addPathPatterns(
                            "/c/**",
                            "/client/c/**",
                            "/auth/c/**",
                            "/client/c/**",
                            "/client/route"
                    ).excludePathPatterns("/c/biz/memberOrder/callback/notify");
        }
    }
    

    ‍ 

  2. ## 2.SM3签名工具