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
+ * 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