1. 统一包名修改

This commit is contained in:
chenbowen
2025-09-22 11:55:27 +08:00
parent a001fc8f16
commit 0d46897482
2739 changed files with 512 additions and 512 deletions

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>com.zt.plat</groupId>
<artifactId>zt-framework</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>zt-spring-boot-starter-protection</artifactId>
<packaging>jar</packaging>
<name>${project.artifactId}</name>
<description>服务保证,提供分布式锁、幂等、限流、熔断等等功能</description>
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
<dependencies>
<!-- Web 相关 -->
<dependency>
<groupId>com.zt.plat</groupId>
<artifactId>zt-spring-boot-starter-web</artifactId>
<scope>provided</scope> <!-- 设置为 provided只有限流、幂等使用到 -->
</dependency>
<!-- DB 相关 -->
<dependency>
<groupId>com.zt.plat</groupId>
<artifactId>zt-spring-boot-starter-redis</artifactId>
</dependency>
<!-- 服务保障相关 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>lock4j-redisson-spring-boot-starter</artifactId>
<optional>true</optional>
</dependency>
<!-- Test 测试相关 -->
<dependency>
<groupId>com.zt.plat</groupId>
<artifactId>zt-spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,46 @@
package com.zt.plat.framework.idempotent.config;
import com.zt.plat.framework.idempotent.core.aop.IdempotentAspect;
import com.zt.plat.framework.idempotent.core.keyresolver.impl.DefaultIdempotentKeyResolver;
import com.zt.plat.framework.idempotent.core.keyresolver.impl.ExpressionIdempotentKeyResolver;
import com.zt.plat.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
import com.zt.plat.framework.idempotent.core.keyresolver.impl.UserIdempotentKeyResolver;
import com.zt.plat.framework.idempotent.core.redis.IdempotentRedisDAO;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import com.zt.plat.framework.redis.config.CloudRedisAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.List;
@AutoConfiguration(after = CloudRedisAutoConfiguration.class)
public class CloudIdempotentConfiguration {
@Bean
public IdempotentAspect idempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
return new IdempotentAspect(keyResolvers, idempotentRedisDAO);
}
@Bean
public IdempotentRedisDAO idempotentRedisDAO(StringRedisTemplate stringRedisTemplate) {
return new IdempotentRedisDAO(stringRedisTemplate);
}
// ========== 各种 IdempotentKeyResolver Bean ==========
@Bean
public DefaultIdempotentKeyResolver defaultIdempotentKeyResolver() {
return new DefaultIdempotentKeyResolver();
}
@Bean
public UserIdempotentKeyResolver userIdempotentKeyResolver() {
return new UserIdempotentKeyResolver();
}
@Bean
public ExpressionIdempotentKeyResolver expressionIdempotentKeyResolver() {
return new ExpressionIdempotentKeyResolver();
}
}

View File

@@ -0,0 +1,63 @@
package com.zt.plat.framework.idempotent.core.annotation;
import com.zt.plat.framework.idempotent.core.keyresolver.impl.DefaultIdempotentKeyResolver;
import com.zt.plat.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
import com.zt.plat.framework.idempotent.core.keyresolver.impl.ExpressionIdempotentKeyResolver;
import com.zt.plat.framework.idempotent.core.keyresolver.impl.UserIdempotentKeyResolver;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
/**
* 幂等注解
*
* @author ZT
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 幂等的超时时间,默认为 1 秒
*
* 注意,如果执行时间超过它,请求还是会进来
*/
int timeout() default 1;
/**
* 时间单位,默认为 SECONDS 秒
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 提示信息,正在执行中的提示
*/
String message() default "重复请求,请稍后重试";
/**
* 使用的 Key 解析器
*
* @see DefaultIdempotentKeyResolver 全局级别
* @see UserIdempotentKeyResolver 用户级别
* @see ExpressionIdempotentKeyResolver 自定义表达式,通过 {@link #keyArg()} 计算
*/
Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;
/**
* 使用的 Key 参数
*/
String keyArg() default "";
/**
* 删除 Key当发生异常时候
*
* 问题:为什么发生异常时,需要删除 Key 呢?
* 回答:发生异常时,说明业务发生错误,此时需要删除 Key避免下次请求无法正常执行。
*
* 问题:为什么不搞 deleteWhenSuccess 执行成功时,需要删除 Key 呢?
* 回答:这种情况下,本质上是分布式锁,推荐使用 @Lock4j 注解
*/
boolean deleteKeyWhenException() default true;
}

View File

@@ -0,0 +1,68 @@
package com.zt.plat.framework.idempotent.core.aop;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants;
import com.zt.plat.framework.common.util.collection.CollectionUtils;
import com.zt.plat.framework.idempotent.core.annotation.Idempotent;
import com.zt.plat.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
import com.zt.plat.framework.idempotent.core.redis.IdempotentRedisDAO;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.util.Assert;
import java.util.List;
import java.util.Map;
/**
* 拦截声明了 {@link Idempotent} 注解的方法,实现幂等操作
*
* @author ZT
*/
@Aspect
@Slf4j
public class IdempotentAspect {
/**
* IdempotentKeyResolver 集合
*/
private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;
private final IdempotentRedisDAO idempotentRedisDAO;
public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
this.keyResolvers = CollectionUtils.convertMap(keyResolvers, IdempotentKeyResolver::getClass);
this.idempotentRedisDAO = idempotentRedisDAO;
}
@Around(value = "@annotation(idempotent)")
public Object aroundPointCut(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 获得 IdempotentKeyResolver
IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver());
Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver");
// 解析 Key
String key = keyResolver.resolver(joinPoint, idempotent);
// 1. 锁定 Key
boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit());
// 锁定失败,抛出异常
if (!success) {
log.info("[aroundPointCut][方法({}) 参数({}) 存在重复请求]", joinPoint.getSignature().toString(), joinPoint.getArgs());
throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), idempotent.message());
}
// 2. 执行逻辑
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
// 3. 异常时,删除 Key
// 参考美团 GTIS 思路https://tech.meituan.com/2016/09/29/distributed-system-mutually-exclusive-idempotence-cerberus-gtis.html
if (idempotent.deleteKeyWhenException()) {
idempotentRedisDAO.delete(key);
}
throw throwable;
}
}
}

View File

@@ -0,0 +1,22 @@
package com.zt.plat.framework.idempotent.core.keyresolver;
import com.zt.plat.framework.idempotent.core.annotation.Idempotent;
import org.aspectj.lang.JoinPoint;
/**
* 幂等 Key 解析器接口
*
* @author ZT
*/
public interface IdempotentKeyResolver {
/**
* 解析一个 Key
*
* @param idempotent 幂等注解
* @param joinPoint AOP 切面
* @return Key
*/
String resolver(JoinPoint joinPoint, Idempotent idempotent);
}

View File

@@ -0,0 +1,25 @@
package com.zt.plat.framework.idempotent.core.keyresolver.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import com.zt.plat.framework.idempotent.core.annotation.Idempotent;
import com.zt.plat.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
import org.aspectj.lang.JoinPoint;
/**
* 默认(全局级别)幂等 Key 解析器,使用方法名 + 方法参数,组装成一个 Key
*
* 为了避免 Key 过长,使用 MD5 进行“压缩”
*
* @author ZT
*/
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {
@Override
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
String methodName = joinPoint.getSignature().toString();
String argsStr = StrUtil.join(",", joinPoint.getArgs());
return SecureUtil.md5(methodName + argsStr);
}
}

View File

@@ -0,0 +1,64 @@
package com.zt.plat.framework.idempotent.core.keyresolver.impl;
import cn.hutool.core.util.ArrayUtil;
import com.zt.plat.framework.idempotent.core.annotation.Idempotent;
import com.zt.plat.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import java.lang.reflect.Method;
/**
* 基于 Spring EL 表达式,
*
* @author ZT
*/
public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {
private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
private final ExpressionParser expressionParser = new SpelExpressionParser();
@Override
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
// 获得被拦截方法参数名列表
Method method = getMethod(joinPoint);
Object[] args = joinPoint.getArgs();
String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method);
// 准备 Spring EL 表达式解析的上下文
StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
if (ArrayUtil.isNotEmpty(parameterNames)) {
for (int i = 0; i < parameterNames.length; i++) {
evaluationContext.setVariable(parameterNames[i], args[i]);
}
}
// 解析参数
Expression expression = expressionParser.parseExpression(idempotent.keyArg());
return expression.getValue(evaluationContext, String.class);
}
private static Method getMethod(JoinPoint point) {
// 处理,声明在类上的情况
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
if (!method.getDeclaringClass().isInterface()) {
return method;
}
// 处理,声明在接口上的情况
try {
return point.getTarget().getClass().getDeclaredMethod(
point.getSignature().getName(), method.getParameterTypes());
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,28 @@
package com.zt.plat.framework.idempotent.core.keyresolver.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import com.zt.plat.framework.idempotent.core.annotation.Idempotent;
import com.zt.plat.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
import com.zt.plat.framework.web.core.util.WebFrameworkUtils;
import org.aspectj.lang.JoinPoint;
/**
* 用户级别的幂等 Key 解析器,使用方法名 + 方法参数 + userId + userType组装成一个 Key
*
* 为了避免 Key 过长,使用 MD5 进行“压缩”
*
* @author ZT
*/
public class UserIdempotentKeyResolver implements IdempotentKeyResolver {
@Override
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
String methodName = joinPoint.getSignature().toString();
String argsStr = StrUtil.join(",", joinPoint.getArgs());
Long userId = WebFrameworkUtils.getLoginUserId();
Integer userType = WebFrameworkUtils.getLoginUserType();
return SecureUtil.md5(methodName + argsStr + userId + userType);
}
}

View File

@@ -0,0 +1,41 @@
package com.zt.plat.framework.idempotent.core.redis;
import lombok.AllArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
/**
* 幂等 Redis DAO
*
* @author ZT
*/
@AllArgsConstructor
public class IdempotentRedisDAO {
/**
* 幂等操作
*
* KEY 格式idempotent:%s // 参数为 uuid
* VALUE 格式String
* 过期时间:不固定
*/
private static final String IDEMPOTENT = "idempotent:%s";
private final StringRedisTemplate redisTemplate;
public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) {
String redisKey = formatKey(key);
return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit);
}
public void delete(String key) {
String redisKey = formatKey(key);
redisTemplate.delete(redisKey);
}
private static String formatKey(String key) {
return String.format(IDEMPOTENT, key);
}
}

View File

@@ -0,0 +1,12 @@
/**
* 幂等组件,参考 https://github.com/it4alla/idempotent 项目实现
* 实现原理是,相同参数的方法,一段时间内,有且仅能执行一次。通过这样的方式,保证幂等性。
*
* 使用场景:例如说,用户快速的双击了某个按钮,前端没有禁用该按钮,导致发送了两次重复的请求。
*
* 和 it4alla/idempotent 组件的差异点,主要体现在两点:
* 1. 我们去掉了 @Idempotent 注解的 delKey 属性。原因是,本质上 delKey 为 true 时,实现的是分布式锁的能力
* 此时,我们偏向使用 Lock4j 组件。原则上,一个组件只提供一种单一的能力。
* 2. 考虑到组件的通用性,我们并未像 it4alla/idempotent 组件一样使用 Redisson RMap 结构,而是直接使用 Redis 的 String 数据格式。
*/
package com.zt.plat.framework.idempotent;

View File

@@ -0,0 +1,18 @@
package com.zt.plat.framework.lock4j.config;
import com.zt.plat.framework.lock4j.core.DefaultLockFailureStrategy;
import com.baomidou.lock.spring.boot.autoconfigure.LockAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
@AutoConfiguration(before = LockAutoConfiguration.class)
@ConditionalOnClass(name = "com.baomidou.lock.annotation.Lock4j")
public class CloudLock4jConfiguration {
@Bean
public DefaultLockFailureStrategy lockFailureStrategy() {
return new DefaultLockFailureStrategy();
}
}

View File

@@ -0,0 +1,21 @@
package com.zt.plat.framework.lock4j.core;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants;
import com.baomidou.lock.LockFailureStrategy;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.Method;
/**
* 自定义获取锁失败策略,抛出 {@link ServiceException} 异常
*/
@Slf4j
public class DefaultLockFailureStrategy implements LockFailureStrategy {
@Override
public void onLockFailure(String key, Method method, Object[] arguments) {
log.debug("[onLockFailure][线程:{} 获取锁失败key:{} 获取失败:{} ]", Thread.currentThread().getName(), key, arguments);
throw new ServiceException(GlobalErrorCodeConstants.LOCKED);
}
}

View File

@@ -0,0 +1,19 @@
package com.zt.plat.framework.lock4j.core;
/**
* Lock4j Redis Key 枚举类
*
* @author ZT
*/
public interface Lock4jRedisKeyConstants {
/**
* 分布式锁
*
* KEY 格式lock4j:%s // 参数来自 DefaultLockKeyBuilder 类
* VALUE 数据格式HASH // RLock.classRedisson 的 Lock 锁,使用 Hash 数据结构
* 过期时间:不固定
*/
String LOCK4J = "lock4j:%s";
}

View File

@@ -0,0 +1,4 @@
/**
* 分布式锁组件,使用 https://gitee.com/baomidou/lock4j 开源项目
*/
package com.zt.plat.framework.lock4j;

View File

@@ -0,0 +1,55 @@
package com.zt.plat.framework.ratelimiter.config;
import com.zt.plat.framework.ratelimiter.core.aop.RateLimiterAspect;
import com.zt.plat.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
import com.zt.plat.framework.ratelimiter.core.keyresolver.impl.*;
import com.zt.plat.framework.ratelimiter.core.redis.RateLimiterRedisDAO;
import com.zt.plat.framework.redis.config.CloudRedisAutoConfiguration;
import org.redisson.api.RedissonClient;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Bean;
import java.util.List;
@AutoConfiguration(after = CloudRedisAutoConfiguration.class)
public class CloudRateLimiterConfiguration {
@Bean
public RateLimiterAspect rateLimiterAspect(List<RateLimiterKeyResolver> keyResolvers, RateLimiterRedisDAO rateLimiterRedisDAO) {
return new RateLimiterAspect(keyResolvers, rateLimiterRedisDAO);
}
@Bean
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
public RateLimiterRedisDAO rateLimiterRedisDAO(RedissonClient redissonClient) {
return new RateLimiterRedisDAO(redissonClient);
}
// ========== 各种 RateLimiterRedisDAO Bean ==========
@Bean
public DefaultRateLimiterKeyResolver defaultRateLimiterKeyResolver() {
return new DefaultRateLimiterKeyResolver();
}
@Bean
public UserRateLimiterKeyResolver userRateLimiterKeyResolver() {
return new UserRateLimiterKeyResolver();
}
@Bean
public ClientIpRateLimiterKeyResolver clientIpRateLimiterKeyResolver() {
return new ClientIpRateLimiterKeyResolver();
}
@Bean
public ServerNodeRateLimiterKeyResolver serverNodeRateLimiterKeyResolver() {
return new ServerNodeRateLimiterKeyResolver();
}
@Bean
public ExpressionRateLimiterKeyResolver expressionRateLimiterKeyResolver() {
return new ExpressionRateLimiterKeyResolver();
}
}

View File

@@ -0,0 +1,62 @@
package com.zt.plat.framework.ratelimiter.core.annotation;
import com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants;
import com.zt.plat.framework.idempotent.core.keyresolver.impl.ExpressionIdempotentKeyResolver;
import com.zt.plat.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
import com.zt.plat.framework.ratelimiter.core.keyresolver.impl.ClientIpRateLimiterKeyResolver;
import com.zt.plat.framework.ratelimiter.core.keyresolver.impl.DefaultRateLimiterKeyResolver;
import com.zt.plat.framework.ratelimiter.core.keyresolver.impl.ServerNodeRateLimiterKeyResolver;
import com.zt.plat.framework.ratelimiter.core.keyresolver.impl.UserRateLimiterKeyResolver;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
/**
* 限流注解
*
* @author ZT
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimiter {
/**
* 限流的时间,默认为 1 秒
*/
int time() default 1;
/**
* 时间单位,默认为 SECONDS 秒
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 限流次数
*/
int count() default 100;
/**
* 提示信息,请求过快的提示
*
* @see GlobalErrorCodeConstants#TOO_MANY_REQUESTS
*/
String message() default ""; // 为空时,使用 TOO_MANY_REQUESTS 错误提示
/**
* 使用的 Key 解析器
*
* @see DefaultRateLimiterKeyResolver 全局级别
* @see UserRateLimiterKeyResolver 用户 ID 级别
* @see ClientIpRateLimiterKeyResolver 用户 IP 级别
* @see ServerNodeRateLimiterKeyResolver 服务器 Node 级别
* @see ExpressionIdempotentKeyResolver 自定义表达式,通过 {@link #keyArg()} 计算
*/
Class<? extends RateLimiterKeyResolver> keyResolver() default DefaultRateLimiterKeyResolver.class;
/**
* 使用的 Key 参数
*/
String keyArg() default "";
}

View File

@@ -0,0 +1,60 @@
package com.zt.plat.framework.ratelimiter.core.aop;
import cn.hutool.core.util.StrUtil;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants;
import com.zt.plat.framework.common.util.collection.CollectionUtils;
import com.zt.plat.framework.ratelimiter.core.annotation.RateLimiter;
import com.zt.plat.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
import com.zt.plat.framework.ratelimiter.core.redis.RateLimiterRedisDAO;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.util.Assert;
import java.util.List;
import java.util.Map;
/**
* 拦截声明了 {@link RateLimiter} 注解的方法,实现限流操作
*
* @author ZT
*/
@Aspect
@Slf4j
public class RateLimiterAspect {
/**
* RateLimiterKeyResolver 集合
*/
private final Map<Class<? extends RateLimiterKeyResolver>, RateLimiterKeyResolver> keyResolvers;
private final RateLimiterRedisDAO rateLimiterRedisDAO;
public RateLimiterAspect(List<RateLimiterKeyResolver> keyResolvers, RateLimiterRedisDAO rateLimiterRedisDAO) {
this.keyResolvers = CollectionUtils.convertMap(keyResolvers, RateLimiterKeyResolver::getClass);
this.rateLimiterRedisDAO = rateLimiterRedisDAO;
}
@Before("@annotation(rateLimiter)")
public void beforePointCut(JoinPoint joinPoint, RateLimiter rateLimiter) {
// 获得 IdempotentKeyResolver 对象
RateLimiterKeyResolver keyResolver = keyResolvers.get(rateLimiter.keyResolver());
Assert.notNull(keyResolver, "找不到对应的 RateLimiterKeyResolver");
// 解析 Key
String key = keyResolver.resolver(joinPoint, rateLimiter);
// 获取 1 次限流
boolean success = rateLimiterRedisDAO.tryAcquire(key,
rateLimiter.count(), rateLimiter.time(), rateLimiter.timeUnit());
if (!success) {
log.info("[beforePointCut][方法({}) 参数({}) 请求过于频繁]", joinPoint.getSignature().toString(), joinPoint.getArgs());
String message = StrUtil.blankToDefault(rateLimiter.message(),
GlobalErrorCodeConstants.TOO_MANY_REQUESTS.getMsg());
throw new ServiceException(GlobalErrorCodeConstants.TOO_MANY_REQUESTS.getCode(), message);
}
}
}

View File

@@ -0,0 +1,22 @@
package com.zt.plat.framework.ratelimiter.core.keyresolver;
import com.zt.plat.framework.ratelimiter.core.annotation.RateLimiter;
import org.aspectj.lang.JoinPoint;
/**
* 限流 Key 解析器接口
*
* @author ZT
*/
public interface RateLimiterKeyResolver {
/**
* 解析一个 Key
*
* @param rateLimiter 限流注解
* @param joinPoint AOP 切面
* @return Key
*/
String resolver(JoinPoint joinPoint, RateLimiter rateLimiter);
}

View File

@@ -0,0 +1,27 @@
package com.zt.plat.framework.ratelimiter.core.keyresolver.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import com.zt.plat.framework.common.util.servlet.ServletUtils;
import com.zt.plat.framework.ratelimiter.core.annotation.RateLimiter;
import com.zt.plat.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
import org.aspectj.lang.JoinPoint;
/**
* IP 级别的限流 Key 解析器,使用方法名 + 方法参数 + IP组装成一个 Key
*
* 为了避免 Key 过长,使用 MD5 进行“压缩”
*
* @author ZT
*/
public class ClientIpRateLimiterKeyResolver implements RateLimiterKeyResolver {
@Override
public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) {
String methodName = joinPoint.getSignature().toString();
String argsStr = StrUtil.join(",", joinPoint.getArgs());
String clientIp = ServletUtils.getClientIP();
return SecureUtil.md5(methodName + argsStr + clientIp);
}
}

View File

@@ -0,0 +1,25 @@
package com.zt.plat.framework.ratelimiter.core.keyresolver.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import com.zt.plat.framework.ratelimiter.core.annotation.RateLimiter;
import com.zt.plat.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
import org.aspectj.lang.JoinPoint;
/**
* 默认(全局级别)限流 Key 解析器,使用方法名 + 方法参数,组装成一个 Key
*
* 为了避免 Key 过长,使用 MD5 进行“压缩”
*
* @author ZT
*/
public class DefaultRateLimiterKeyResolver implements RateLimiterKeyResolver {
@Override
public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) {
String methodName = joinPoint.getSignature().toString();
String argsStr = StrUtil.join(",", joinPoint.getArgs());
return SecureUtil.md5(methodName + argsStr);
}
}

View File

@@ -0,0 +1,64 @@
package com.zt.plat.framework.ratelimiter.core.keyresolver.impl;
import cn.hutool.core.util.ArrayUtil;
import com.zt.plat.framework.ratelimiter.core.annotation.RateLimiter;
import com.zt.plat.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import java.lang.reflect.Method;
/**
* 基于 Spring EL 表达式的 {@link RateLimiterKeyResolver} 实现类
*
* @author ZT
*/
public class ExpressionRateLimiterKeyResolver implements RateLimiterKeyResolver {
private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
private final ExpressionParser expressionParser = new SpelExpressionParser();
@Override
public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) {
// 获得被拦截方法参数名列表
Method method = getMethod(joinPoint);
Object[] args = joinPoint.getArgs();
String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method);
// 准备 Spring EL 表达式解析的上下文
StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
if (ArrayUtil.isNotEmpty(parameterNames)) {
for (int i = 0; i < parameterNames.length; i++) {
evaluationContext.setVariable(parameterNames[i], args[i]);
}
}
// 解析参数
Expression expression = expressionParser.parseExpression(rateLimiter.keyArg());
return expression.getValue(evaluationContext, String.class);
}
private static Method getMethod(JoinPoint point) {
// 处理,声明在类上的情况
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
if (!method.getDeclaringClass().isInterface()) {
return method;
}
// 处理,声明在接口上的情况
try {
return point.getTarget().getClass().getDeclaredMethod(
point.getSignature().getName(), method.getParameterTypes());
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,27 @@
package com.zt.plat.framework.ratelimiter.core.keyresolver.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.system.SystemUtil;
import com.zt.plat.framework.ratelimiter.core.annotation.RateLimiter;
import com.zt.plat.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
import org.aspectj.lang.JoinPoint;
/**
* Server 节点级别的限流 Key 解析器,使用方法名 + 方法参数 + IP组装成一个 Key
*
* 为了避免 Key 过长,使用 MD5 进行“压缩”
*
* @author ZT
*/
public class ServerNodeRateLimiterKeyResolver implements RateLimiterKeyResolver {
@Override
public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) {
String methodName = joinPoint.getSignature().toString();
String argsStr = StrUtil.join(",", joinPoint.getArgs());
String serverNode = String.format("%s@%d", SystemUtil.getHostInfo().getAddress(), SystemUtil.getCurrentPID());
return SecureUtil.md5(methodName + argsStr + serverNode);
}
}

View File

@@ -0,0 +1,28 @@
package com.zt.plat.framework.ratelimiter.core.keyresolver.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import com.zt.plat.framework.ratelimiter.core.annotation.RateLimiter;
import com.zt.plat.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
import com.zt.plat.framework.web.core.util.WebFrameworkUtils;
import org.aspectj.lang.JoinPoint;
/**
* 用户级别的限流 Key 解析器,使用方法名 + 方法参数 + userId + userType组装成一个 Key
*
* 为了避免 Key 过长,使用 MD5 进行“压缩”
*
* @author ZT
*/
public class UserRateLimiterKeyResolver implements RateLimiterKeyResolver {
@Override
public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) {
String methodName = joinPoint.getSignature().toString();
String argsStr = StrUtil.join(",", joinPoint.getArgs());
Long userId = WebFrameworkUtils.getLoginUserId();
Integer userType = WebFrameworkUtils.getLoginUserType();
return SecureUtil.md5(methodName + argsStr + userId + userType);
}
}

View File

@@ -0,0 +1,62 @@
package com.zt.plat.framework.ratelimiter.core.redis;
import lombok.AllArgsConstructor;
import org.redisson.api.*;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* 限流 Redis DAO
*
* @author ZT
*/
@AllArgsConstructor
public class RateLimiterRedisDAO {
/**
* 限流操作
*
* KEY 格式rate_limiter:%s // 参数为 uuid
* VALUE 格式String
* 过期时间:不固定
*/
private static final String RATE_LIMITER = "rate_limiter:%s";
private final RedissonClient redissonClient;
public Boolean tryAcquire(String key, int count, int time, TimeUnit timeUnit) {
// 1. 获得 RRateLimiter并设置 rate 速率
RRateLimiter rateLimiter = getRRateLimiter(key, count, time, timeUnit);
// 2. 尝试获取 1 个
return rateLimiter.tryAcquire();
}
private static String formatKey(String key) {
return String.format(RATE_LIMITER, key);
}
private RRateLimiter getRRateLimiter(String key, long count, int time, TimeUnit timeUnit) {
String redisKey = formatKey(key);
RRateLimiter rateLimiter = redissonClient.getRateLimiter(redisKey);
long rateInterval = timeUnit.toSeconds(time);
// 1. 如果不存在,设置 rate 速率
RateLimiterConfig config = rateLimiter.getConfig();
if (config == null) {
rateLimiter.trySetRate(RateType.OVERALL, count, rateInterval, RateIntervalUnit.SECONDS);
rateLimiter.expire(rateInterval, TimeUnit.SECONDS); // 原因参见 https://t.zsxq.com/lcR0W
return rateLimiter;
}
// 2. 如果存在,并且配置相同,则直接返回
if (config.getRateType() == RateType.OVERALL
&& Objects.equals(config.getRate(), count)
&& Objects.equals(config.getRateInterval(), TimeUnit.SECONDS.toMillis(rateInterval))) {
return rateLimiter;
}
// 3. 如果存在,并且配置不同,则进行新建
rateLimiter.setRate(RateType.OVERALL, count, rateInterval, RateIntervalUnit.SECONDS);
rateLimiter.expire(rateInterval, TimeUnit.SECONDS); // 原因参见 https://t.zsxq.com/lcR0W
return rateLimiter;
}
}

View File

@@ -0,0 +1,4 @@
/**
* 限流组件,基于 Redisson {@link org.redisson.api.RRateLimiter} 限流实现
*/
package com.zt.plat.framework.ratelimiter;

View File

@@ -0,0 +1,28 @@
package com.zt.plat.framework.signature.config;
import com.zt.plat.framework.redis.config.CloudRedisAutoConfiguration;
import com.zt.plat.framework.signature.core.aop.ApiSignatureAspect;
import com.zt.plat.framework.signature.core.redis.ApiSignatureRedisDAO;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.core.StringRedisTemplate;
/**
* HTTP API 签名的自动配置类
*
* @author Zhougang
*/
@AutoConfiguration(after = CloudRedisAutoConfiguration.class)
public class CloudApiSignatureAutoConfiguration {
@Bean
public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) {
return new ApiSignatureAspect(signatureRedisDAO);
}
@Bean
public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) {
return new ApiSignatureRedisDAO(stringRedisTemplate);
}
}

View File

@@ -0,0 +1,59 @@
package com.zt.plat.framework.signature.core.annotation;
import com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* HTTP API 签名注解
*
* @author Zhougang
*/
@Inherited
@Documented
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiSignature {
/**
* 同一个请求多长时间内有效 默认 60 秒
*/
int timeout() default 60;
/**
* 时间单位,默认为 SECONDS 秒
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
// ========================== 签名参数 ==========================
/**
* 提示信息,签名失败的提示
*
* @see GlobalErrorCodeConstants#BAD_REQUEST
*/
String message() default "签名不正确"; // 为空时,使用 BAD_REQUEST 错误提示
/**
* 签名字段appId 应用ID
*/
String appId() default "appId";
/**
* 签名字段timestamp 时间戳
*/
String timestamp() default "timestamp";
/**
* 签名字段nonce 随机数10 位以上
*/
String nonce() default "nonce";
/**
* sign 客户端签名
*/
String sign() default "sign";
}

View File

@@ -0,0 +1,174 @@
package com.zt.plat.framework.signature.core.aop;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants;
import com.zt.plat.framework.common.util.servlet.ServletUtils;
import com.zt.plat.framework.signature.core.annotation.ApiSignature;
import com.zt.plat.framework.signature.core.redis.ApiSignatureRedisDAO;
import jakarta.servlet.http.HttpServletRequest;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import java.util.Map;
import java.util.Objects;
import java.util.SortedMap;
import java.util.TreeMap;
import static com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
/**
* 拦截声明了 {@link ApiSignature} 注解的方法,实现签名
*
* @author Zhougang
*/
@Aspect
@Slf4j
@AllArgsConstructor
public class ApiSignatureAspect {
private final ApiSignatureRedisDAO signatureRedisDAO;
@Before("@annotation(signature)")
public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) {
// 1. 验证通过,直接结束
if (verifySignature(signature, Objects.requireNonNull(ServletUtils.getRequest()))) {
return;
}
// 2. 验证不通过,抛出异常
log.error("[beforePointCut][方法{} 参数({}) 签名失败]", joinPoint.getSignature().toString(),
joinPoint.getArgs());
throw new ServiceException(BAD_REQUEST.getCode(),
StrUtil.blankToDefault(signature.message(), BAD_REQUEST.getMsg()));
}
public boolean verifySignature(ApiSignature signature, HttpServletRequest request) {
// 1.1 校验 Header
if (!verifyHeaders(signature, request)) {
return false;
}
// 1.2 校验 appId 是否能获取到对应的 appSecret
String appId = request.getHeader(signature.appId());
String appSecret = signatureRedisDAO.getAppSecret(appId);
Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId);
// 2. 校验签名【重要!】
String clientSignature = request.getHeader(signature.sign()); // 客户端签名
String serverSignatureString = buildSignatureString(signature, request, appSecret); // 服务端签名字符串
String serverSignature = DigestUtil.sha256Hex(serverSignatureString); // 服务端签名
if (ObjUtil.notEqual(clientSignature, serverSignature)) {
return false;
}
// 3. 将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2
String nonce = request.getHeader(signature.nonce());
if (BooleanUtil.isFalse(signatureRedisDAO.setNonce(appId, nonce, signature.timeout() * 2, signature.timeUnit()))) {
String timestamp = request.getHeader(signature.timestamp());
log.info("[verifySignature][appId({}) timestamp({}) nonce({}) sign({}) 存在重复请求]", appId, timestamp, nonce, clientSignature);
throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), "存在重复请求");
}
return true;
}
/**
* 校验请求头加签参数
* <p>
* 1. appId 是否为空
* 2. timestamp 是否为空,请求是否已经超时,默认 10 分钟
* 3. nonce 是否为空,随机数是否 10 位以上,是否在规定时间内已经访问过了
* 4. sign 是否为空
*
* @param signature signature
* @param request request
* @return 是否校验 Header 通过
*/
private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) {
// 1. 非空校验
String appId = request.getHeader(signature.appId());
if (StrUtil.isBlank(appId)) {
return false;
}
String timestamp = request.getHeader(signature.timestamp());
if (StrUtil.isBlank(timestamp)) {
return false;
}
String nonce = request.getHeader(signature.nonce());
if (StrUtil.length(nonce) < 10) {
return false;
}
String sign = request.getHeader(signature.sign());
if (StrUtil.isBlank(sign)) {
return false;
}
// 2. 检查 timestamp 是否超出允许的范围 (重点一:此处需要取绝对值)
long expireTime = signature.timeUnit().toMillis(signature.timeout());
long requestTimestamp = Long.parseLong(timestamp);
long timestampDisparity = Math.abs(System.currentTimeMillis() - requestTimestamp);
if (timestampDisparity > expireTime) {
return false;
}
// 3. 检查 nonce 是否存在,有且仅能使用一次
return signatureRedisDAO.getNonce(appId, nonce) == null;
}
/**
* 构建签名字符串
* <p>
* 格式为 = 请求参数 + 请求体 + 请求头 + 密钥
*
* @param signature signature
* @param request request
* @param appSecret appSecret
* @return 签名字符串
*/
private String buildSignatureString(ApiSignature signature, HttpServletRequest request, String appSecret) {
SortedMap<String, String> parameterMap = getRequestParameterMap(request); // 请求头
SortedMap<String, String> headerMap = getRequestHeaderMap(signature, request); // 请求参数
String requestBody = StrUtil.nullToDefault(ServletUtils.getBody(request), ""); // 请求体
return MapUtil.join(parameterMap, "&", "=")
+ requestBody
+ MapUtil.join(headerMap, "&", "=")
+ appSecret;
}
/**
* 获取请求头加签参数 Map
*
* @param request 请求
* @param signature 签名注解
* @return signature params
*/
private static SortedMap<String, String> getRequestHeaderMap(ApiSignature signature, HttpServletRequest request) {
SortedMap<String, String> sortedMap = new TreeMap<>();
sortedMap.put(signature.appId(), request.getHeader(signature.appId()));
sortedMap.put(signature.timestamp(), request.getHeader(signature.timestamp()));
sortedMap.put(signature.nonce(), request.getHeader(signature.nonce()));
return sortedMap;
}
/**
* 获取请求参数 Map
*
* @param request 请求
* @return queryParams
*/
private static SortedMap<String, String> getRequestParameterMap(HttpServletRequest request) {
SortedMap<String, String> sortedMap = new TreeMap<>();
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
sortedMap.put(entry.getKey(), entry.getValue()[0]);
}
return sortedMap;
}
}

View File

@@ -0,0 +1,57 @@
package com.zt.plat.framework.signature.core.redis;
import lombok.AllArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
/**
* HTTP API 签名 Redis DAO
*
* @author Zhougang
*/
@AllArgsConstructor
public class ApiSignatureRedisDAO {
private final StringRedisTemplate stringRedisTemplate;
/**
* 验签随机数
* <p>
* KEY 格式signature_nonce:%s // 参数为 随机数
* VALUE 格式String
* 过期时间:不固定
*/
private static final String SIGNATURE_NONCE = "api_signature_nonce:%s:%s";
/**
* 签名密钥
* <p>
* HASH 结构
* KEY 格式:%s // 参数为 appid
* VALUE 格式String
* 过期时间:永不过期(预加载到 Redis
*/
private static final String SIGNATURE_APPID = "api_signature_app";
// ========== 验签随机数 ==========
public String getNonce(String appId, String nonce) {
return stringRedisTemplate.opsForValue().get(formatNonceKey(appId, nonce));
}
public Boolean setNonce(String appId, String nonce, int time, TimeUnit timeUnit) {
return stringRedisTemplate.opsForValue().setIfAbsent(formatNonceKey(appId, nonce), "", time, timeUnit);
}
private static String formatNonceKey(String appId, String nonce) {
return String.format(SIGNATURE_NONCE, appId, nonce);
}
// ========== 签名密钥 ==========
public String getAppSecret(String appId) {
return (String) stringRedisTemplate.opsForHash().get(SIGNATURE_APPID, appId);
}
}

View File

@@ -0,0 +1,6 @@
/**
* HTTP API 签名,校验安全性
*
* @see <a href="https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=4_3>微信支付 —— 安全规范</a>
*/
package com.zt.plat.framework.signature;

View File

@@ -0,0 +1,4 @@
com.zt.plat.framework.idempotent.config.CloudIdempotentConfiguration
com.zt.plat.framework.lock4j.config.CloudLock4jConfiguration
com.zt.plat.framework.ratelimiter.config.CloudRateLimiterConfiguration
com.zt.plat.framework.signature.config.CloudApiSignatureAutoConfiguration

View File

@@ -0,0 +1,74 @@
package com.zt.plat.framework.signature.core;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.zt.plat.framework.signature.core.annotation.ApiSignature;
import com.zt.plat.framework.signature.core.aop.ApiSignatureAspect;
import com.zt.plat.framework.signature.core.redis.ApiSignatureRedisDAO;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* {@link ApiSignatureTest} 的单元测试
*/
@ExtendWith(MockitoExtension.class)
public class ApiSignatureTest {
@InjectMocks
private ApiSignatureAspect apiSignatureAspect;
@Mock
private ApiSignatureRedisDAO signatureRedisDAO;
@Test
public void testSignatureGet() throws IOException {
// 搞一个签名
Long timestamp = System.currentTimeMillis();
String nonce = IdUtil.randomUUID();
String appId = "xxxxxx";
String appSecret = "yyyyyy";
String signString = "k1=v1&v1=k1testappId=xxxxxx&nonce=" + nonce + "&timestamp=" + timestamp + "yyyyyy";
String sign = DigestUtil.sha256Hex(signString);
// 准备参数
ApiSignature apiSignature = mock(ApiSignature.class);
when(apiSignature.appId()).thenReturn("appId");
when(apiSignature.timestamp()).thenReturn("timestamp");
when(apiSignature.nonce()).thenReturn("nonce");
when(apiSignature.sign()).thenReturn("sign");
when(apiSignature.timeout()).thenReturn(60);
when(apiSignature.timeUnit()).thenReturn(TimeUnit.SECONDS);
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader(eq("appId"))).thenReturn(appId);
when(request.getHeader(eq("timestamp"))).thenReturn(String.valueOf(timestamp));
when(request.getHeader(eq("nonce"))).thenReturn(nonce);
when(request.getHeader(eq("sign"))).thenReturn(sign);
when(request.getParameterMap()).thenReturn(MapUtil.<String, String[]>builder()
.put("v1", new String[]{"k1"}).put("k1", new String[]{"v1"}).build());
when(request.getContentType()).thenReturn("application/json");
when(request.getReader()).thenReturn(new BufferedReader(new StringReader("test")));
// mock 方法
when(signatureRedisDAO.getAppSecret(eq(appId))).thenReturn(appSecret);
when(signatureRedisDAO.setNonce(eq(appId), eq(nonce), eq(120), eq(TimeUnit.SECONDS))).thenReturn(true);
// 调用
boolean result = apiSignatureAspect.verifySignature(apiSignature, request);
// 断言结果
assertTrue(result);
}
}