diff --git a/src/main/java/com/youlai/boot/common/annotation/RateLimit.java b/src/main/java/com/youlai/boot/common/annotation/RateLimit.java index f53893bc..7039965e 100644 --- a/src/main/java/com/youlai/boot/common/annotation/RateLimit.java +++ b/src/main/java/com/youlai/boot/common/annotation/RateLimit.java @@ -15,11 +15,29 @@ import java.util.concurrent.TimeUnit; @Documented public @interface RateLimit { + /** + * 窗口内允许的最大请求数。 + *

+ * {@code <=0} 时使用全局默认值 {@code rate-limit.default-limit}。 + */ int limit() default 0; + /** + * 滑动窗口大小。 + *

+ * 单位由 {@link #timeUnit()} 决定;{@code <=0} 时使用全局默认值。 + */ int window() default 0; + /** + * 窗口单位,默认秒。 + */ TimeUnit timeUnit() default TimeUnit.SECONDS; - String prefix() default "rate_limit:"; + /** + * 限流 Key 的分组标签,用于区分不同接口。 + *

+ * 默认 {@code api}。 + */ + String prefix() default "api"; } diff --git a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java index 035d6792..4c43b8e5 100644 --- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java @@ -11,12 +11,12 @@ public interface RedisConstants { /** * 限流相关键 */ - interface RateLimiter { - /** 接口级限流 Key(示例:login:rate_limit:token:/api/v1/auth/login) */ - String API = "{}rate_limit:{}:{}"; + interface RateLimit { + /** 接口级限流 Key(示例:rate_limit:api:{user}:{uri}) */ + String API = "rate_limit:{}:{}:{}"; - /** IP 全局限流 Key(示例:rate_limiter:ip:192.168.1.100) */ - String IP = "rate_limiter:ip:{}"; + /** IP 全局限流 Key(示例:rate_limit:ip:192.168.1.100) */ + String IP = "rate_limit:ip:{}"; } /** diff --git a/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java b/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java index 0692e0de..89c044ec 100644 --- a/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java +++ b/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.result.Result; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.framework.security.exception.TokenInvalidException; +import com.youlai.boot.framework.web.exception.RateLimitException; import jakarta.servlet.ServletException; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -157,6 +158,12 @@ public class GlobalExceptionHandler { return Result.failed(e.getResultCode()); } + @ExceptionHandler(RateLimitException.class) + @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) + public Result handleRateLimitException(RateLimitException e) { + return Result.failed(e.getResultCode(), e.getMessage()); + } + @ExceptionHandler(BusinessException.class) @ResponseStatus(HttpStatus.OK) public Result handleBizException(BusinessException e) { diff --git a/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java b/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java index 7c1940f1..1e2b1367 100644 --- a/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java +++ b/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java @@ -5,10 +5,10 @@ import cn.hutool.crypto.digest.DigestUtil; import com.youlai.boot.common.annotation.RateLimit; import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.SecurityConstants; -import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.util.IPUtils; import com.youlai.boot.framework.web.config.RateLimitProperties; +import com.youlai.boot.framework.web.exception.RateLimitException; import com.youlai.boot.framework.web.ratelimit.SlidingWindowScript; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -55,7 +55,7 @@ public class RateLimitAspect { String key = buildKey(request, rateLimit); int limit = rateLimit.limit() > 0 ? rateLimit.limit() : rateLimitProperties.getDefaultLimit(); - int window = rateLimit.window() > 0 ? rateLimit.window() : rateLimitProperties.getDefaultWindow(); + int window = rateLimit.window() > 0 ? rateLimit.window() : rateLimitProperties.getDefaultWindowSeconds(); long windowMs = rateLimit.timeUnit().toMillis(window); Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs); @@ -65,7 +65,7 @@ public class RateLimitAspect { if (current > limit) { log.warn("接口限流触发 key={} count={} limit={}", key, current, limit); - throw new BusinessException(ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED); + throw new RateLimitException(ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED); } return jp.proceed(); @@ -73,7 +73,7 @@ public class RateLimitAspect { private String buildKey(HttpServletRequest request, RateLimit rateLimit) { String user = resolveUser(request); - return StrUtil.format(RedisConstants.RateLimiter.API, + return StrUtil.format(RedisConstants.RateLimit.API, rateLimit.prefix(), user, request.getRequestURI()); } diff --git a/src/main/java/com/youlai/boot/framework/web/aspect/RepeatSubmitAspect.java b/src/main/java/com/youlai/boot/framework/web/aspect/RepeatSubmitAspect.java index 63e2456c..78eaf1ff 100644 --- a/src/main/java/com/youlai/boot/framework/web/aspect/RepeatSubmitAspect.java +++ b/src/main/java/com/youlai/boot/framework/web/aspect/RepeatSubmitAspect.java @@ -2,6 +2,7 @@ package com.youlai.boot.framework.web.aspect; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.json.JSONUtil; import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.result.ResultCode; @@ -42,7 +43,7 @@ public class RepeatSubmitAspect { @Around(value = "repeatSubmitPointCut(repeatSubmit)", argNames = "pjp,repeatSubmit") public Object handleRepeatSubmit(ProceedingJoinPoint pjp, RepeatSubmit repeatSubmit) throws Throwable { - String lockKey = buildLockKey(); + String lockKey = buildLockKey(pjp); int expire = repeatSubmit.expire(); RLock lock = redissonClient.getLock(lockKey); @@ -56,12 +57,17 @@ public class RepeatSubmitAspect { /** * 生成防重复提交锁的 key + *

+ * key 由「用户标识 + 接口(method:URI) + 请求体哈希」组成:只有同一用户在同一接口提交 + * 完全相同的请求体才视为重复提交;若仅按 method:URI 生成,同一接口的不同提交 + * (如新增菜单与新增目录)会在窗口内被误判为重复。 */ - private String buildLockKey() { + private String buildLockKey(ProceedingJoinPoint pjp) { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); String userIdentifier = getUserIdentifier(request); String requestIdentifier = StrUtil.join(":", request.getMethod(), request.getRequestURI()); - return StrUtil.format(RedisConstants.Lock.RESUBMIT, userIdentifier, requestIdentifier); + String bodyHash = DigestUtil.sha256Hex(JSONUtil.toJsonStr(pjp.getArgs())); + return StrUtil.format(RedisConstants.Lock.RESUBMIT, userIdentifier, requestIdentifier + ":" + bodyHash); } /** diff --git a/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java b/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java index a62102dc..288d4410 100644 --- a/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java +++ b/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java @@ -19,14 +19,14 @@ import org.springframework.stereotype.Component; public class RateLimitProperties { /** - * @RateLimit 注解未显式指定 limit 时的默认阈值 + * @RateLimit 注解未显式指定 limit 时的默认阈值(窗口内最大请求数) */ private int defaultLimit = 5; /** * @RateLimit 注解未显式指定 window 时的默认窗口大小(秒) */ - private int defaultWindow = 1; + private int defaultWindowSeconds = 60; /** * IP 全局限流配置 diff --git a/src/main/java/com/youlai/boot/framework/web/exception/RateLimitException.java b/src/main/java/com/youlai/boot/framework/web/exception/RateLimitException.java new file mode 100644 index 00000000..9374b6ba --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/web/exception/RateLimitException.java @@ -0,0 +1,26 @@ +package com.youlai.boot.framework.web.exception; + +import com.youlai.boot.common.result.ResultCode; +import lombok.Getter; + +/** + * 接口限流异常:请求超出限流阈值时抛出,由全局异常处理器统一映射为 HTTP 429。 + * + * @author Ray.Hao + * @since 4.4.0 + */ +@Getter +public class RateLimitException extends RuntimeException { + + private final ResultCode resultCode; + + public RateLimitException(ResultCode resultCode) { + super(resultCode.getMsg()); + this.resultCode = resultCode; + } + + public RateLimitException(ResultCode resultCode, String message) { + super(message); + this.resultCode = resultCode; + } +} diff --git a/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java b/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java index a05d8f99..bc1c66ea 100644 --- a/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java +++ b/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java @@ -30,7 +30,7 @@ import java.io.IOException; * *

限流维度

*
{@code
- *   Key: rate_limiter:ip:{clientIp}
+ *   Key: rate_limit:ip:{clientIp}
  *   默认: 1000 req / 60s(可通过 rate-limit.ip.* 配置)
  * }
* @@ -67,7 +67,7 @@ public class IpRateLimitFilter extends OncePerRequestFilter { } String ip = IPUtils.getIpAddr(request); - String key = StrUtil.format(RedisConstants.RateLimiter.IP, ip); + String key = StrUtil.format(RedisConstants.RateLimit.IP, ip); long windowMs = ipConfig.getWindowSeconds() * 1000L; // 执行滑动窗口计数(Lua 原子操作) diff --git a/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java b/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java index a05bc25e..fafe61ad 100644 --- a/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java +++ b/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java @@ -49,7 +49,7 @@ public final class SlidingWindowScript { * 执行滑动窗口计数 * * @param redisTemplate Redis 模板 - * @param key 限流 Key(如 {@code rate_limiter:ip:192.168.1.1}) + * @param key 限流 Key(如 {@code rate_limit:ip:192.168.1.1}) * @param windowMs 窗口大小(毫秒) * @return 当前窗口内请求数(包含本次) */ diff --git a/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java b/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java index 02f972ef..118ea7ed 100644 --- a/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java +++ b/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java @@ -57,6 +57,7 @@ public final class ResponseWriter { return switch (resultCode) { case ACCESS_UNAUTHORIZED, ACCESS_TOKEN_INVALID, REFRESH_TOKEN_INVALID -> HttpStatus.UNAUTHORIZED.value(); case ACCESS_PERMISSION_EXCEPTION -> HttpStatus.FORBIDDEN.value(); + case REQUEST_CONCURRENCY_LIMIT_EXCEEDED -> HttpStatus.TOO_MANY_REQUESTS.value(); default -> HttpStatus.BAD_REQUEST.value(); }; } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index ab421526..24c08d86 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -71,7 +71,7 @@ mybatis-plus: # 接口限流 rate-limit: default-limit: 60 - default-window: 60 + default-window-seconds: 60 ip: enabled: false # 开发环境关闭 IP 全局限流 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 260fb344..90829aa4 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -70,7 +70,7 @@ mybatis-plus: # 接口限流 rate-limit: default-limit: 10 - default-window: 1 + default-window-seconds: 60 ip: enabled: true limit: 1000 # IP 全局:1000 req / 60s