第三方接口对接

主要流程

【添加接口管理和密钥管理】

​ ↓

【服务端生成密钥(AccessKey + SecretKey)】

​ ↓

【服务端加密存储SecretKey到数据库】

​ ↓

【通过安全渠道发送AccessKey + SecretKey给调用方】

​ ↓

【调用方每次请求时用SecretKey生成签名】

​ ↓

【服务端用相同的SecretKey验证签名】

1、添加接口管理和密钥管理

主要是三张表:接口表、密钥表、密钥和接口关联表。

参考(只展示主要字段)

接口表,还有一张子表用来记录参数、响应、请求头等信息这里不做展示。

sql 复制代码
CREATE TABLE `information_exchange_father` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
  `name` varchar(100) DEFAULT '' COMMENT '接口名称',
  `dizhi` varchar(200) DEFAULT '' COMMENT '接口地址',
  `fanshi` varchar(100) DEFAULT '' COMMENT '请求方式(1:GET,2:POST,3:PUT,4:DELETE)',
  `jiekou_lx` varchar(10) DEFAULT '' COMMENT '接口类型(1:引接接口,2:推送接口)',
  `qiyong_zt` varchar(10) DEFAULT '' COMMENT '启用状态(1:启用,2:停用)',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='接口表';

API密钥表

sql 复制代码
CREATE TABLE `information_exchange_api_key` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `key_name` varchar(100) NOT NULL COMMENT '密钥名称',
  `access_key` varchar(64) NOT NULL COMMENT '访问密钥(公钥)',
  `secret_key` varchar(128) NOT NULL COMMENT '私密密钥(加密存储)',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态(0-禁用 1-启用)',
  `expire_time` datetime DEFAULT NULL COMMENT '过期时间(为空表示永久有效)',
  `system_name` varchar(100) DEFAULT NULL COMMENT '调用方系统名称',
  `rate_limit` int DEFAULT NULL COMMENT '调用频率限制(次/分钟,为空表示不限制)',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注说明',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_access_key` (`access_key`) COMMENT '访问密钥唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='API密钥管理表';

密钥接口关联表

sql 复制代码
CREATE TABLE `information_exchange_api_key_interface` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `api_key_id` bigint NOT NULL COMMENT '密钥ID',
  `api_information_id` bigint NOT NULL COMMENT '接口ID',
  PRIMARY KEY (`id`),
  KEY `idx_api_key_id` (`api_key_id`) COMMENT '密钥ID索引',
  KEY `idx_interface_id` (`api_information_id`) COMMENT '接口路径索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='API密钥接口关联表';

1、服务端生成密钥

注意:AccessKey + SecretKey一定是后端生成,不能让前端自己输入。

生成AccessKey + SecretKey的工具类

java 复制代码
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;

/**
 * API密钥生成工具类
 */
public class ApiKeyGenerator {

    /**
     * AccessKey前缀
     */
    private static final String ACCESS_KEY_PREFIX = "YD_";

    /**
     * SecretKey前缀
     */
    private static final String SECRET_KEY_PREFIX = "sk_";

    /**
     * SecretKey长度(不包含前缀)
     */
    private static final int SECRET_KEY_LENGTH = 32;

    /**
     * 生成AccessKey
     * 格式: YD_ + UUID(去掉横杠)
     * 示例: YD_a1b2c3d4e5f67890a1b2c3d4e5f67890
     *
     * @return AccessKey
     */
    public static String generateAccessKey() {
        // 使用hutool的IdUtil生成UUID,去掉横杠
        String uuid = IdUtil.simpleUUID();
        return ACCESS_KEY_PREFIX + uuid;
    }

    /**
     * 生成SecretKey
     * 格式: sk_ + 32位随机字符串(包含数字和字母)
     * 示例: sk_9f8e7d6c5b4a3210fedcba0987654321
     *
     * @return SecretKey
     */
    public static String generateSecretKey() {
        // 使用hutool的RandomUtil生成随机字符串
        String randomStr = RandomUtil.randomString(SECRET_KEY_LENGTH);
        return SECRET_KEY_PREFIX + randomStr;
    }

    /**
     * 生成AccessKey(带自定义前缀)
     *
     * @param prefix 自定义前缀
     * @return AccessKey
     */
    public static String generateAccessKey(String prefix) {
        String uuid = IdUtil.simpleUUID();
        return prefix + uuid;
    }

    /**
     * 生成SecretKey(带自定义前缀和长度)
     *
     * @param prefix 自定义前缀
     * @param length 随机字符串长度
     * @return SecretKey
     */
    public static String generateSecretKey(String prefix, int length) {
        String randomStr = RandomUtil.randomString(length);
        return prefix + randomStr;
    }
}

2、服务端加密存储SecretKey到数据库

SecretKey存到数据库时加密解密的工具类

java 复制代码
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.symmetric.AES;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.nio.charset.StandardCharsets;

/**
 * AES-256 加密工具类
 */
@Slf4j
@Component
public class AesEncryptUtil {

    @Value("${system.aes.secret-key}")
    private String secretKey; // 这个secretKey跟密钥里面的secretKey不是一个东西,这个里面的是专门用来加密解密的

    private AES aes;

    @PostConstruct
    public void init() {
        // 确保密钥长度为32字节(256位)
        if (secretKey.length() != 32) {
            throw new IllegalArgumentException("AES-256密钥长度必须为32字符");
        }
        aes = SecureUtil.aes(secretKey.getBytes(StandardCharsets.UTF_8));
    }

    /**
     * 加密
     */
    public String encrypt(String content) {
        if (content == null || content.isEmpty()) {
            return content;
        }
        String jmh = content;
        try {
            jmh = aes.encryptHex(content);
        } catch (Exception e) {
            log.error("AES加密失败", e);
        }
        return jmh;
    }

    /**
     * 解密
     */
    public String decrypt(String encryptedContent) {
        if (encryptedContent == null || encryptedContent.isEmpty()) {
            return encryptedContent;
        }
        String jmh = encryptedContent;
        try {
            jmh = aes.decryptStr(encryptedContent);
        } catch (Exception e) {
            log.error("AES解密失败", e);
        }
        return jmh;
    }
}

yaml文件添加ase加密解密时用的system.aes.secret-key

yaml 复制代码
system:
  aes:
    # AES-256密钥,必须是32位字符串,随意填写
    secret-key: ck345678901234567890123456789012

3、通过安全渠道发送AccessKey + SecretKey给调用方

通过钉钉、企业微信、邮箱、线下交付等

4、调用方每次请求时用SecretKey生成签名

这是当时写的测试类,注意用到的SignatureUtil.generateSign在下面第五步里面有

java 复制代码
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.framework.common.util.exchangeapikey.SignatureUtil;

import java.util.HashMap;
import java.util.Map;

/**
 * HTTP客户端模拟测试
 * 模拟调用方如何调用API
 * 
 * 使用前请确保:
 * 1. 服务已启动
 * 2. 数据库中已有测试密钥数据
 * 3. 修改下面的测试数据(accessKey、secretKey、apiUrl)
 *
 */
public class ApiClientSimulatorTest {

    /**
     * 服务器地址
     */
    private static final String BASE_URL = "http://127.0.0.1:48081";
    
    /**
     * 测试用的AccessKey
     */
    private static final String ACCESS_KEY = "YD_a36f9d287c5f4e38afc99c9dd68f10db";
    
    /**
     * 测试用的SecretKey
     */
    private static final String SECRET_KEY = "sk_WklmDcZMlL2uNC1Mi0KYSff9YMLT2HHt";
    

    public static void main(String[] args) {
        System.out.println("========== API客户端模拟测试 ==========\n");
        
        // 测试1: 正常请求(应该成功)
        test1_正常请求_get();
        test1_正常请求_post();

        // 测试2: 缺少签名(应该失败)
        test2_缺少签名();

        // 测试3: 签名错误(应该失败)
        test3_签名错误();

        // 测试4: 时间戳过期(应该失败)
        test4_时间戳过期();

        // 测试5: 密钥不存在(应该失败)
        test5_密钥不存在();
        
        System.out.println("\n========== 所有测试完成 ==========");
    }

    /**
     * 测试1.1: 正常请求
     */
    private static void test1_正常请求_get() {
        System.out.println("【测试1.1】GET正常请求(应该成功)");
        
        try {
            // 准备参数
            Map<String, String> params = new HashMap<>();
            params.put("id", "11");
            
            // 生成时间戳
            long timestamp = System.currentTimeMillis();
            
            // 生成签名
            String sign = SignatureUtil.generateSign(params, timestamp, SECRET_KEY);

            // 发送请求(转换为 Object 类型)
            Map<String, Object> formParams = new HashMap<>(params);

            // 发送请求
            String url = BASE_URL + "/admin-api/exchange/test001/get";
            HttpResponse response = HttpRequest.get(url)
                    .header("X-Access-Key", ACCESS_KEY)
                    .header("X-Timestamp", String.valueOf(timestamp))
                    .header("X-Sign", sign)
                    .form(formParams)
                    .execute();
            
            // 打印结果
            System.out.println("  请求URL: " + url);
            System.out.println("  请求参数: " + params);
            System.out.println("  AccessKey: " + ACCESS_KEY);
            System.out.println("  Timestamp: " + timestamp);
            System.out.println("  Sign: " + sign);
            System.out.println("  响应状态码: " + response.getStatus());
            System.out.println("  响应内容: " + response.body());
            
            if (response.getStatus() == 200) {
                System.out.println("  ✅ 请求成功\n");
            } else {
                System.out.println("  ❌ 请求失败\n");
            }
            
        } catch (Exception e) {
            System.out.println("  ❌ 请求异常: " + e.getMessage());
            System.out.println("  提示:请确保服务已启动,且URL配置正确\n");
        }
    }

    /**
     * 测试1.2: 正常请求
     */
    private static void test1_正常请求_post() {
        System.out.println("【测试1.2】POST正常请求(应该成功)");

        try {
            // 准备参数
            Map<String, String> body  = new HashMap<>();
            body.put("id", "22");
            body.put("name", "王室");
            body.put("dizhi", "666asf");
            body.put("aaa", "214325");

            // 将参数转换为JSON字符串
            String jsonBody = JSONUtil.toJsonStr(body);

            // 生成时间戳
            long timestamp = System.currentTimeMillis();

            Map<String, String> params = new HashMap<>();
            params.put("body", jsonBody);
            // 生成签名
            String sign = SignatureUtil.generateSign(params, timestamp, SECRET_KEY);

            // 发送请求
            String url = BASE_URL + "/admin-api/exchange/test001/post";
            HttpResponse response = HttpRequest.post(url)
                    .header("X-Access-Key", ACCESS_KEY)
                    .header("X-Timestamp", String.valueOf(timestamp))
                    .header("X-Sign", sign)
                    .header("Content-Type", "application/json;charset=UTF-8")
                    .body(jsonBody)
                    .execute();

            // 打印结果
            System.out.println("  请求URL: " + url);
            System.out.println("  请求参数: " + params);
            System.out.println("  AccessKey: " + ACCESS_KEY);
            System.out.println("  Timestamp: " + timestamp);
            System.out.println("  Sign: " + sign);
            System.out.println("  响应状态码: " + response.getStatus());
            System.out.println("  响应内容: " + response.body());

            if (response.getStatus() == 200) {
                System.out.println("  ✅ 请求成功\n");
            } else {
                System.out.println("  ❌ 请求失败\n");
            }

        } catch (Exception e) {
            System.out.println("  ❌ 请求异常: " + e.getMessage());
            System.out.println("  提示:请确保服务已启动,且URL配置正确\n");
        }
    }

    /**
     * 测试2: 缺少签名
     */
    private static void test2_缺少签名() {
        System.out.println("【测试2】缺少签名(应该失败)");
        
        try {
            Map<String, String> params = new HashMap<>();
            params.put("userId", "123");
            
            long timestamp = System.currentTimeMillis();

            // 发送请求(转换为 Object 类型)
            Map<String, Object> formParams = new HashMap<>(params);

            // 故意不发送签名
            String url = BASE_URL + "/admin-api/exchange/test001/get";
            HttpResponse response = HttpRequest.get(url)
                    .header("X-Access-Key", ACCESS_KEY)
                    .header("X-Timestamp", String.valueOf(timestamp))
                    // .header("X-Sign", sign)  // ← 故意不发送
                    .form(formParams)
                    .execute();
            
            System.out.println("  响应状态码: " + response.getStatus());
            System.out.println("  响应内容: " + response.body());
            
            if (response.body().contains("请求头未包含X-Sign")) {
                System.out.println("  ✅ 正确拒绝:缺少签名\n");
            } else {
                System.out.println("  ❌ 应该拒绝但没有拒绝\n");
            }
            
        } catch (Exception e) {
            System.out.println("  ❌ 请求异常: " + e.getMessage() + "\n");
        }
    }

    /**
     * 测试3: 签名错误
     */
    private static void test3_签名错误() {
        System.out.println("【测试3】签名错误(应该失败)");
        
        try {
            Map<String, String> params = new HashMap<>();
            params.put("userId", "123");
            
            long timestamp = System.currentTimeMillis();
            
            // 故意使用错误的签名
            String wrongSign = "wrongsignature123456";

            // 发送请求(转换为 Object 类型)
            Map<String, Object> formParams = new HashMap<>(params);

            String url = BASE_URL + "/admin-api/exchange/test001/get";
            HttpResponse response = HttpRequest.get(url)
                    .header("X-Access-Key", ACCESS_KEY)
                    .header("X-Timestamp", String.valueOf(timestamp))
                    .header("X-Sign", wrongSign)
                    .form(formParams)
                    .execute();
            
            System.out.println("  响应状态码: " + response.getStatus());
            System.out.println("  响应内容: " + response.body());
            
            if (response.body().contains("签名验证失败")) {
                System.out.println("  ✅ 正确拒绝:签名错误\n");
            } else {
                System.out.println("  ❌ 应该拒绝但没有拒绝\n");
            }
            
        } catch (Exception e) {
            System.out.println("  ❌ 请求异常: " + e.getMessage() + "\n");
        }
    }

    /**
     * 测试4: 时间戳过期
     */
    private static void test4_时间戳过期() {
        System.out.println("【测试4】时间戳过期(应该失败)");
        
        try {
            Map<String, String> params = new HashMap<>();
            params.put("userId", "123");
            
            // 使用10分钟前的时间戳(超过5分钟有效期)
            long timestamp = System.currentTimeMillis() - (10 * 60 * 1000);
            
            String sign = SignatureUtil.generateSign(params, timestamp, SECRET_KEY);

            // 发送请求(转换为 Object 类型)
            Map<String, Object> formParams = new HashMap<>(params);

            String url = BASE_URL + "/admin-api/exchange/test001/get";
            HttpResponse response = HttpRequest.get(url)
                    .header("X-Access-Key", ACCESS_KEY)
                    .header("X-Timestamp", String.valueOf(timestamp))
                    .header("X-Sign", sign)
                    .form(formParams)
                    .execute();
            
            System.out.println("  响应状态码: " + response.getStatus());
            System.out.println("  响应内容: " + response.body());
            
            if (response.body().contains("请求已过期")) {
                System.out.println("  ✅ 正确拒绝:时间戳过期\n");
            } else {
                System.out.println("  ❌ 应该拒绝但没有拒绝\n");
            }
            
        } catch (Exception e) {
            System.out.println("  ❌ 请求异常: " + e.getMessage() + "\n");
        }
    }

    /**
     * 测试5: 密钥不存在
     */
    private static void test5_密钥不存在() {
        System.out.println("【测试5】密钥不存在(应该失败)");
        
        try {
            Map<String, String> params = new HashMap<>();
            params.put("userId", "123");
            
            long timestamp = System.currentTimeMillis();
            String sign = SignatureUtil.generateSign(params, timestamp, SECRET_KEY);
            
            // 使用不存在的AccessKey
            String wrongAccessKey = "YD_notexist123456";

            // 发送请求(转换为 Object 类型)
            Map<String, Object> formParams = new HashMap<>(params);

            String url = BASE_URL + "/admin-api/exchange/test001/get";
            HttpResponse response = HttpRequest.get(url)
                    .header("X-Access-Key", wrongAccessKey)
                    .header("X-Timestamp", String.valueOf(timestamp))
                    .header("X-Sign", sign)
                    .form(formParams)
                    .execute();
            
            System.out.println("  响应状态码: " + response.getStatus());
            System.out.println("  响应内容: " + response.body());
            
            if (response.body().contains("密钥不存在")) {
                System.out.println("  ✅ 正确拒绝:密钥不存在\n");
            } else {
                System.out.println("  ❌ 应该拒绝但没有拒绝\n");
            }
            
        } catch (Exception e) {
            System.out.println("  ❌ 请求异常: " + e.getMessage() + "\n");
        }
    }
}

5、服务端用相同的SecretKey验证签名

注册验证过滤器

java 复制代码
import cn.iocoder.yudao.framework.apilog.core.filter.ApiAccessLogFilter;
import cn.iocoder.yudao.framework.apilog.core.filter.ApiKeyAuthFilter;
import cn.iocoder.yudao.framework.apilog.core.interceptor.ApiAccessLogInterceptor;
import cn.iocoder.yudao.framework.common.biz.infra.logger.ApiAccessLogCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.exchangeapikey.ExchangeApiKeyApi;
import cn.iocoder.yudao.framework.common.enums.WebFilterOrderEnum;
import cn.iocoder.yudao.framework.web.config.WebProperties;
import cn.iocoder.yudao.framework.web.config.YudaoWebAutoConfiguration;
import javax.servlet.Filter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@AutoConfiguration(after = YudaoWebAutoConfiguration.class)
public class YudaoApiLogAutoConfiguration implements WebMvcConfigurer {

    /**
     * 密钥验证过滤器
     * @param webProperties
     * @param exchangeApiKeyApi后续用来查询密钥信息的
     * @return
     */
    @Bean
    public FilterRegistrationBean<ApiKeyAuthFilter> apiKeyAuthFilter(WebProperties webProperties,
                                                                     ExchangeApiKeyApi exchangeApiKeyApi) {
        ApiKeyAuthFilter filter = new ApiKeyAuthFilter(webProperties, exchangeApiKeyApi);
        return createFilterBean(filter, WebFilterOrderEnum.EXCHANGE_API_FILTER);
    }

    private static <T extends Filter> FilterRegistrationBean<T> createFilterBean(T filter, Integer order) {
        FilterRegistrationBean<T> bean = new FilterRegistrationBean<>(filter);
        bean.setOrder(order);
        return bean;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new ApiAccessLogInterceptor());
    }

}

过滤器

java 复制代码
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.biz.system.exchangeapikey.ExchangeApiKeyApi;
import cn.iocoder.yudao.framework.common.biz.system.exchangeapikey.dto.ExchangeApiKeyDTO;
import cn.iocoder.yudao.framework.common.biz.system.exchangeapikey.dto.ExchangeApiKeyInterfaceDTO;
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.exchangeapikey.RateLimitUtil;
import cn.iocoder.yudao.framework.common.util.exchangeapikey.SignatureUtil;
import cn.iocoder.yudao.framework.common.util.exchangeapikey.UrlPathValidator;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
import cn.iocoder.yudao.framework.web.config.WebProperties;
import cn.iocoder.yudao.framework.web.core.filter.ApiRequestFilter;

import lombok.extern.slf4j.Slf4j;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

/**
 * API密钥验证过滤器
 * 
 * 目的:在请求到达Controller之前进行密钥验证和鉴权
 *
 * @author your name
 */
@Slf4j
public class ApiKeyAuthFilter extends ApiRequestFilter {

    /**
     * Header中AccessKey的键名
     */
    private static final String HEADER_ACCESS_KEY = "X-Access-Key";

    /**
     * Header中时间戳的键名
     */
    private static final String HEADER_TIMESTAMP = "X-Timestamp";

    /**
     * Header中签名的键名
     */
    private static final String HEADER_SIGN = "X-Sign";

    /**
     * 时间戳有效期(毫秒),默认5分钟
     */
    private static final long TIMESTAMP_VALIDITY = 5 * 60 * 1000;

    private final ExchangeApiKeyApi exchangeApiKeyApi;

    public ApiKeyAuthFilter(WebProperties webProperties, ExchangeApiKeyApi exchangeApiKeyApi) {
        super(webProperties);
        this.exchangeApiKeyApi = exchangeApiKeyApi;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            // 判断是否是信息交换接口
            if(UrlPathValidator.validateExchangeSubPath(request.getRequestURI())){
                // 执行密钥验证
                boolean authSuccess = authenticate(request, response);
                if (!authSuccess) {
                    return; // 验证失败,已经写入响应,直接返回
                }
                // 验证通过,继续执行后续过滤器
                filterChain.doFilter(request, response);
            }else {
                // 不是对外开发接口,放行
                filterChain.doFilter(request, response);
            }

        } finally {}
    }

    /**
     * 执行认证逻辑
     *
     * @return true-验证通过,false-验证失败
     */
    private boolean authenticate(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 1. 提取Header信息
        String accessKey = request.getHeader(HEADER_ACCESS_KEY);
        String timestamp = request.getHeader(HEADER_TIMESTAMP);
        String clientSign = request.getHeader(HEADER_SIGN);

        try {
            // 2. 校验必要参数
            if (StrUtil.isEmpty(accessKey)) {
                writeErrorResponse(response, GlobalErrorCodeConstants.BAD_REQUEST.getCode(), "请求头未包含" + HEADER_ACCESS_KEY);
                return false;
            }
            if (StrUtil.isEmpty(timestamp)) {
                writeErrorResponse(response, GlobalErrorCodeConstants.BAD_REQUEST.getCode(), "请求头未包含" + HEADER_TIMESTAMP);
                return false;
            }
            if (StrUtil.isEmpty(clientSign)) {
                writeErrorResponse(response, GlobalErrorCodeConstants.BAD_REQUEST.getCode(), "请求头未包含" + HEADER_SIGN);
                return false;
            }

            // 3. 查询密钥信息
            ExchangeApiKeyDTO exchangeDTO = exchangeApiKeyApi.getExchangeApiKey(accessKey);
            if (exchangeDTO == null) {
                writeErrorResponse(response, GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(), "密钥不存在");
                return false;
            }

            // 4. 验证密钥状态
            if (exchangeDTO.getStatus() == null || exchangeDTO.getStatus() != 1) {
                writeErrorResponse(response, GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(), "密钥已禁用");
                return false;
            }

            // 5. 验证密钥是否过期
            if (exchangeDTO.getExpireTime() != null && exchangeDTO.getExpireTime().isBefore(LocalDateTime.now())) {
                writeErrorResponse(response, GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(), "密钥已过期");
                return false;
            }

            // 6. 验证时间戳
            long requestTime;
            try {
                requestTime = Long.parseLong(timestamp);
            } catch (NumberFormatException e) {
                writeErrorResponse(response, GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(), HEADER_TIMESTAMP + "格式错误");
                return false;
            }
            long currentTime = System.currentTimeMillis();
            if (Math.abs(currentTime - requestTime) > TIMESTAMP_VALIDITY) {
                writeErrorResponse(response, GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(), "请求已过期,请检查系统时间");
                return false;
            }

            // 7. 验证签名
            Map<String, String> params = ServletUtils.getParamMap(request);
            // 如果是JSON请求,从Body中获取参数
            if (ServletUtils.isJsonRequest(request)) {
                String body = ServletUtils.getBody(request);
                if (StrUtil.isNotEmpty(body)) {
                    params.put("body", body);
                }
            }

            // 使用工具类计算签名
            String serverSign = SignatureUtil.generateSign(params, requestTime, exchangeDTO.getSecretKey());
            if (!serverSign.equals(clientSign)) {
                writeErrorResponse(response, GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(), "签名验证失败");
                return false;
            }

            // 8. 验证接口权限
            String currentApiPath = request.getRequestURI();
            boolean hasPermission = checkApiPermission(exchangeDTO, currentApiPath);
            if (!hasPermission) {
                writeErrorResponse(response, GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(), "无权限访问此接口");
                return false;
            }

            // 10. 验证频率限制(可选,如果配置了频率限制)
            if (exchangeDTO.getRateLimit() != null && exchangeDTO.getRateLimit() > 0) {
                boolean exceedLimit = RateLimitUtil.checkRateLimit(exchangeDTO.getId(), exchangeDTO.getRateLimit());
                if (exceedLimit) {
                    writeErrorResponse(response, GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(), "调用频率超限");
                    return false;
                }
            }

            log.info("[ApiKeyAuth][验证通过][AccessKey:{}][接口:{}]", accessKey, currentApiPath);

            return true;

        } catch (Exception e) {
            log.error("[ApiKeyAuth][验证异常][AccessKey:{}]", accessKey, e);
            writeErrorResponse(response, GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(), "密钥验证异常");
            return false;
        }
    }

    /**
     * 检查接口权限
     */
    private boolean checkApiPermission(ExchangeApiKeyDTO apiKey, String currentApiPath) {
        List<ExchangeApiKeyInterfaceDTO> interfaces = apiKey.getExchangeApiKeyInterfaces();
        if (CollUtil.isEmpty(interfaces)) {
            return false;
        }

        // 遍历密钥关联的接口列表,判断当前请求路径是否匹配
        for (ExchangeApiKeyInterfaceDTO apiInterface : interfaces) {
            // 判断接口是否启用
            if (!"1".equals(apiInterface.getQiyongZt())) {
                continue;
            }
            // 判断接口是否推送接口
            if (!"2".equals(apiInterface.getJiekouLx())) {
                continue;
            }

            String interfacePath = apiInterface.getDizhi();
            // 支持通配符匹配
            if (pathMatches(currentApiPath, interfacePath)) {
                return true;
            }
        }

        return false;
    }

    /**
     * 路径匹配(支持通配符)
     */
    private boolean pathMatches(String currentPath, String pattern) {
        // 精确匹配
        if (currentPath.equals(pattern)) {
            return true;
        }

        // 通配符匹配:/api/user/* 可以匹配 /api/user/info
        if (pattern.endsWith("/*")) {
            String prefix = pattern.substring(0, pattern.length() - 2);
            return currentPath.startsWith(prefix);
        }

        // 通配符匹配:/api/**/info 可以匹配 /api/user/info、/api/admin/user/info
        if (pattern.contains("**")) {
            String regex = pattern.replace("**", ".*");
            return currentPath.matches(regex);
        }

        return false;
    }

    /**
     * 写入错误响应
     */
    private void writeErrorResponse(HttpServletResponse response, Integer code, String message) throws IOException {
        response.setStatus(HttpServletResponse.SC_OK); // 统一返回200,通过code区分错误
        response.setContentType("application/json;charset=UTF-8");

        CommonResult<?> result = CommonResult.error(code, message);
        String json = JsonUtils.toJsonString(result);
        response.getWriter().write(json);
    }
}

过滤器里面用到的工具类和其他类

java 复制代码
public class UrlPathValidator {

    /**
     * 验证URL路径是否严格在 /admin-api/exchange 的子路径下(不包含 /admin-api/exchange 本身)
     *
     * @param path 要验证的URL字符串
     * @return true-验证通过,false-验证失败
     */
    public static boolean validateExchangeSubPath(String path) {
        return StrUtil.isNotBlank(path) && path.startsWith("/admin-api/exchange/");
    }
}
java 复制代码
@Data
public class ExchangeApiKeyDTO {

    /**
     * 主键ID
     */
    private Long id;

    /**
     * 密钥名称
     */
    private String keyName;

    /**
     * 访问密钥(公钥)
     */
    private String accessKey;

    /**
     * 私密密钥(加密存储)
     */
    private String secretKey;

    /**
     * 状态(0-禁用 1-启用)
     */
    private Integer status;

    /**
     * 过期时间(为空表示永久有效)
     */
    private LocalDateTime expireTime;

    /**
     * 调用方系统名称
     */
    private String systemName;

    /**
     * 调用频率限制(次/分钟,为空表示不限制)
     */
    private Integer rateLimit;

    /**
     * 备注说明
     */
    private String remark;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    /**
     * API密钥接口关联列表
     */
    private List<ExchangeApiKeyInterfaceDTO> exchangeApiKeyInterfaces;

}
java 复制代码
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.servlet.ServletUtil;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import org.springframework.http.MediaType;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.util.Map;

/**
 * 客户端工具类
 *
 * @author 芋道源码
 */
public class ServletUtils {

    public static boolean isJsonRequest(ServletRequest request) {
        return StrUtil.startWithIgnoreCase(request.getContentType(), MediaType.APPLICATION_JSON_VALUE);
    }

    public static String getBody(HttpServletRequest request) {
        // 只有在 json 请求在读取,因为只有 CacheRequestBodyFilter 才会进行缓存,支持重复读取
        if (isJsonRequest(request)) {
            return ServletUtil.getBody(request);
        }
        return null;
    }

    public static byte[] getBodyBytes(HttpServletRequest request) {
        // 只有在 json 请求在读取,因为只有 CacheRequestBodyFilter 才会进行缓存,支持重复读取
        if (isJsonRequest(request)) {
            return ServletUtil.getBodyBytes(request);
        }
        return null;
    }

    public static String getClientIP(HttpServletRequest request) {
        return ServletUtil.getClientIP(request);
    }

    public static Map<String, String> getParamMap(HttpServletRequest request) {
        return ServletUtil.getParamMap(request);
    }

    public static Map<String, String> getHeaderMap(HttpServletRequest request) {
        return ServletUtil.getHeaderMap(request);
    }

}
java 复制代码
import cn.hutool.crypto.digest.HMac;
import cn.hutool.crypto.digest.HmacAlgorithm;

import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.TreeMap;

/**
 * 签名工具类
 *
 * @author your name
 */
public class SignatureUtil {

    /**
     * 生成签名
     *
     * @param params    请求参数
     * @param timestamp 时间戳
     * @param secretKey 密钥(需要先解密)
     * @return 签名字符串
     */
    public static String generateSign(Map<String, String> params, long timestamp, String secretKey) {
        // 1. 参数排序(使用TreeMap自动按key排序)
        TreeMap<String, String> sortedParams = new TreeMap<>(params);

        // 2. 拼接字符串
        StringBuilder signString = new StringBuilder();
        for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
            signString.append(entry.getKey())
                    .append("=")
                    .append(entry.getValue())
                    .append("&");
        }
        signString.append("timestamp=").append(timestamp);

        // 3. HMAC-SHA256签名
        return hmacSha256(signString.toString(), secretKey);
    }

    /**
     * HMAC-SHA256算法
     *
     * @param data      待签名数据
     * @param secretKey 密钥
     * @return 签名结果(十六进制字符串)
     */
    private static String hmacSha256(String data, String secretKey) {
        HMac hMac = new HMac(HmacAlgorithm.HmacSHA256, secretKey.getBytes(StandardCharsets.UTF_8));
        return hMac.digestHex(data);
    }
}
java 复制代码
import lombok.extern.slf4j.Slf4j;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;

/**
 * 频率限制工具类(基于内存实现)
 * 使用滑动窗口算法
 *
 * @author your name
 */
@Slf4j
public class RateLimitUtil {

    /**
     * 存储每个密钥的请求时间戳列表
     * Key: apiKeyId
     * Value: 请求时间戳列表
     */
    private static final Map<Long, List<Long>> REQUEST_RECORDS = new ConcurrentHashMap<>();

    /**
     * 时间窗口大小(毫秒),默认1分钟
     */
    private static final long WINDOW_SIZE = 60 * 1000;

    /**
     * 检查是否超过频率限制
     *
     * @param apiKeyId   密钥ID
     * @param rateLimit  频率限制(次/分钟)
     * @return true-超限,false-未超限
     */
    public static boolean checkRateLimit(Long apiKeyId, Integer rateLimit) {
        long currentTime = System.currentTimeMillis();

        // 获取或创建该密钥的请求记录列表
        List<Long> timestamps = REQUEST_RECORDS.computeIfAbsent(apiKeyId, k -> new CopyOnWriteArrayList<>());

        // 移除时间窗口之外的旧记录
        timestamps.removeIf(timestamp -> currentTime - timestamp > WINDOW_SIZE);

        // 判断当前窗口内的请求次数是否超过限制
        if (timestamps.size() >= rateLimit) {
            log.warn("[RateLimitUtil][密钥:{}超过频率限制,限制:{}/分钟,当前:{}/分钟]",
                    apiKeyId, rateLimit, timestamps.size());
            return true;
        }

        // 记录本次请求时间
        timestamps.add(currentTime);
        return false;
    }

    /**
     * 清理指定密钥的请求记录
     *
     * @param apiKeyId 密钥ID
     */
    public static void clear(Long apiKeyId) {
        REQUEST_RECORDS.remove(apiKeyId);
    }

    /**
     * 清理所有请求记录
     */
    public static void clearAll() {
        REQUEST_RECORDS.clear();
    }

    /**
     * 获取指定密钥的当前请求次数
     *
     * @param apiKeyId 密钥ID
     * @return 当前时间窗口内的请求次数
     */
    public static int getCurrentRequestCount(Long apiKeyId) {
        List<Long> timestamps = REQUEST_RECORDS.get(apiKeyId);
        if (timestamps == null) {
            return 0;
        }

        long currentTime = System.currentTimeMillis();
        // 移除过期记录
        timestamps.removeIf(timestamp -> currentTime - timestamp > WINDOW_SIZE);
        return timestamps.size();
    }
}
相关推荐
木辰風4 小时前
PLSQL自定义自动替换(AutoReplace)
java·数据库·sql
heartbeat..4 小时前
Redis 中的锁:核心实现、类型与最佳实践
java·数据库·redis·缓存·并发
5 小时前
java关于内部类
java·开发语言
好好沉淀5 小时前
Java 项目中的 .idea 与 target 文件夹
java·开发语言·intellij-idea
gusijin5 小时前
解决idea启动报错java: OutOfMemoryError: insufficient memory
java·ide·intellij-idea
To Be Clean Coder5 小时前
【Spring源码】createBean如何寻找构造器(二)——单参数构造器的场景
java·后端·spring
吨~吨~吨~5 小时前
解决 IntelliJ IDEA 运行时“命令行过长”问题:使用 JAR
java·ide·intellij-idea
你才是臭弟弟5 小时前
SpringBoot 集成MinIo(根据上传文件.后缀自动归类)
java·spring boot·后端
短剑重铸之日5 小时前
《设计模式》第二篇:单例模式
java·单例模式·设计模式·懒汉式·恶汉式
码农水水5 小时前
得物Java面试被问:消息队列的死信队列和重试机制
java·开发语言·jvm·数据结构·机器学习·面试·职场和发展