diff --git a/src/main/java/com/youlai/boot/auth/controller/AuthController.java b/src/main/java/com/youlai/boot/auth/controller/AuthController.java index 305938e5..f3884f0e 100644 --- a/src/main/java/com/youlai/boot/auth/controller/AuthController.java +++ b/src/main/java/com/youlai/boot/auth/controller/AuthController.java @@ -48,7 +48,7 @@ public class AuthController { @Operation(summary = "账号密码登录") @PostMapping("/login") @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) - @RateLimit(limit = 5, window = 60) + @RateLimit public Result login(@RequestBody @Valid LoginForm request) { AuthenticationToken authenticationToken = authService.login(request.getUsername(), request.getPassword()); return Result.success(authenticationToken); diff --git a/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java b/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java index 33653466..760295fe 100644 --- a/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java +++ b/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java @@ -50,7 +50,7 @@ public class WxMaAuthController { @Operation(summary = "静默登录", description = "通过微信 code 登录,已绑定用户直接返回 token,未绑定用户返回 openid 需绑定手机号") @PostMapping("/silent-login") @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) - @RateLimit(limit = 10, window = 60) + @RateLimit public Result silentLogin( @Parameter(description = "微信登录凭证(wx.login 获取)", required = true, example = "0xxx") @RequestParam String code @@ -69,7 +69,7 @@ public class WxMaAuthController { @Operation(summary = "手机号快捷登录", description = "同时使用微信 code 和手机号授权 code 登录,适用于企业认证小程序") @PostMapping("/phone-login") @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) - @RateLimit(limit = 5, window = 60) + @RateLimit public Result phoneLogin(@Valid @RequestBody WxMaPhoneLoginForm req) { AuthenticationToken result = wxMaAuthService.phoneLogin(req.getLoginCode(), req.getPhoneCode()); return Result.success(result); @@ -85,7 +85,7 @@ public class WxMaAuthController { @Operation(summary = "绑定手机号", description = "为静默登录用户绑定手机号,绑定成功后自动登录") @PostMapping("/bind-mobile") @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) - @RateLimit(limit = 3, window = 60) + @RateLimit public Result bindMobile(@Valid @RequestBody WxMaBindMobileForm req) { AuthenticationToken result = wxMaAuthService.bindMobile(req.getOpenid(), req.getMobile(), req.getSmsCode()); return Result.success(result); 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 93e24ccd..035d6792 100644 --- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java @@ -12,7 +12,11 @@ public interface RedisConstants { * 限流相关键 */ interface RateLimiter { - String API = "{}rate_limit:{}:{}"; // 接口限流(示例:login:rate_limit:token:/api/v1/auth/login) + /** 接口级限流 Key(示例:login:rate_limit:token:/api/v1/auth/login) */ + String API = "{}rate_limit:{}:{}"; + + /** IP 全局限流 Key(示例:rate_limiter:ip:192.168.1.100) */ + String IP = "rate_limiter:ip:{}"; } /** 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 bcbc377c..7c1940f1 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 @@ -9,7 +9,9 @@ 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.ratelimit.SlidingWindowScript; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; @@ -21,11 +23,18 @@ import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; -import java.util.concurrent.TimeUnit; - /** - * 接口限流切面 - *

对 @RateLimit 标注的方法做 Redis 计数,超阈值则拒绝

+ * 接口级限流切面 + *

+ * 对标注了 {@link RateLimit} 的 Controller 方法做 Redis 滑动窗口计数。 + * 超阈值抛出 {@link BusinessException}(A0502),全局异常处理器统一返回 JSON。 + *

+ * + *

升级历史

+ *
    + *
  • 4.3.1 — 初始版本:固定窗口计数器
  • + *
  • 4.4.0 — 升级为滑动窗口 + X-RateLimit-* 渐进式响应头
  • + *
* * @author Ray.Hao * @since 4.3.1 @@ -41,25 +50,31 @@ public class RateLimitAspect { @Around("@annotation(rateLimit)") public Object handle(ProceedingJoinPoint jp, RateLimit rateLimit) throws Throwable { - HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); - String key = buildKey(request, rateLimit); + HttpServletRequest request = + ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); + String key = buildKey(request, rateLimit); int limit = rateLimit.limit() > 0 ? rateLimit.limit() : rateLimitProperties.getDefaultLimit(); - Long count = redisTemplate.opsForValue().increment(key); - if (count != null && count == 1) { - int window = rateLimit.window() > 0 ? rateLimit.window() : rateLimitProperties.getDefaultWindow(); - redisTemplate.expire(key, window, rateLimit.timeUnit()); - } - if (count != null && count > limit) { - log.warn("限流触发 key={} count={} limit={}", key, count, limit); + int window = rateLimit.window() > 0 ? rateLimit.window() : rateLimitProperties.getDefaultWindow(); + long windowMs = rateLimit.timeUnit().toMillis(window); + + Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs); + + int current = count != null ? count.intValue() : 0; + setRateLimitHeaders(request, limit, current, windowMs); + + if (current > limit) { + log.warn("接口限流触发 key={} count={} limit={}", key, current, limit); throw new BusinessException(ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED); } + return jp.proceed(); } private String buildKey(HttpServletRequest request, RateLimit rateLimit) { String user = resolveUser(request); - return StrUtil.format(RedisConstants.RateLimiter.API, rateLimit.prefix(), user, request.getRequestURI()); + return StrUtil.format(RedisConstants.RateLimiter.API, + rateLimit.prefix(), user, request.getRequestURI()); } private String resolveUser(HttpServletRequest request) { @@ -69,4 +84,23 @@ public class RateLimitAspect { } return IPUtils.getIpAddr(request); } + + private void setRateLimitHeaders(HttpServletRequest request, + int limit, + int current, + long windowMs) { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs != null) { + HttpServletResponse response = attrs.getResponse(); + if (response != null) { + int remaining = Math.max(0, limit - current); + long resetAt = (System.currentTimeMillis() + windowMs) / 1000; + response.setHeader("X-RateLimit-Limit", String.valueOf(limit)); + response.setHeader("X-RateLimit-Remaining", String.valueOf(remaining)); + response.setHeader("X-RateLimit-Reset", String.valueOf(resetAt)); + } + } + } + } 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 6ea46c6d..a62102dc 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 @@ -6,13 +6,52 @@ import org.springframework.stereotype.Component; /** * 限流配置 + *

+ * 对应 application.yml 中的 {@code rate-limit} 配置节点。 + *

+ * + * @author Ray.Hao + * @since 4.3.1 */ @Data @Component @ConfigurationProperties(prefix = "rate-limit") public class RateLimitProperties { + /** + * @RateLimit 注解未显式指定 limit 时的默认阈值 + */ private int defaultLimit = 5; + /** + * @RateLimit 注解未显式指定 window 时的默认窗口大小(秒) + */ private int defaultWindow = 1; + + /** + * IP 全局限流配置 + */ + private Ip ip = new Ip(); + + @Data + public static class Ip { + + /** + * 是否启用 IP 全局限流。 + * 建议生产环境开启,开发/测试环境酌情关闭。 + */ + private boolean enabled = true; + + /** + * 窗口内最大请求数(默认 1000) + */ + private int limit = 1000; + + /** + * 窗口大小(秒,默认 60) + */ + private int windowSeconds = 60; + + } + } 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 new file mode 100644 index 00000000..a05d8f99 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java @@ -0,0 +1,106 @@ +package com.youlai.boot.framework.web.filter; + +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.common.constant.RedisConstants; +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.ratelimit.SlidingWindowScript; +import com.youlai.boot.framework.web.util.ResponseWriter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * IP 全局限流过滤器 + *

+ * 在所有请求进入 Controller 之前,按 IP 维度做滑动窗口限流。 + * 与 {@code @RateLimit} 注解级限流叠加:先过 IP 全局,再过接口级。 + *

+ * + *

限流维度

+ *
{@code
+ *   Key: rate_limiter:ip:{clientIp}
+ *   默认: 1000 req / 60s(可通过 rate-limit.ip.* 配置)
+ * }
+ * + *

响应头

+ * + * + * + * + * + *
X-RateLimit-Limit窗口内最大允许请求数
X-RateLimit-Remaining窗口内剩余可用请求数
X-RateLimit-Reset窗口重置时间(Unix 秒)
Retry-After超限时建议重试等待秒数(仅 429)
+ * + * @author Ray.Hao + * @since 4.4.0 + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 5) +@RequiredArgsConstructor +@Slf4j +public class IpRateLimitFilter extends OncePerRequestFilter { + + private final RedisTemplate redisTemplate; + private final RateLimitProperties rateLimitProperties; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // 未启用时直接放行 + RateLimitProperties.Ip ipConfig = rateLimitProperties.getIp(); + if (!ipConfig.isEnabled()) { + filterChain.doFilter(request, response); + return; + } + + String ip = IPUtils.getIpAddr(request); + String key = StrUtil.format(RedisConstants.RateLimiter.IP, ip); + long windowMs = ipConfig.getWindowSeconds() * 1000L; + + // 执行滑动窗口计数(Lua 原子操作) + Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs); + + int limit = ipConfig.getLimit(); + int current = count != null ? count.intValue() : 0; + + setRateLimitHeaders(response, limit, current, windowMs); + + if (current > limit) { + log.warn("IP 限流触发 ip={} count={} limit={}", ip, current, limit); + response.setHeader("Retry-After", String.valueOf(ipConfig.getWindowSeconds())); + ResponseWriter.writeError(response, ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED); + return; + } + + filterChain.doFilter(request, response); + } + + /** + * 设置渐进式限流响应头(X-RateLimit-Limit / Remaining / Reset)。 + * 超限时额外返回 Retry-After。 + */ + private void setRateLimitHeaders(HttpServletResponse response, + int limit, + int current, + long windowMs) { + int remaining = Math.max(0, limit - current); + long resetAt = (System.currentTimeMillis() + windowMs) / 1000; + response.setHeader("X-RateLimit-Limit", String.valueOf(limit)); + response.setHeader("X-RateLimit-Remaining", String.valueOf(remaining)); + response.setHeader("X-RateLimit-Reset", String.valueOf(resetAt)); + } + +} 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 new file mode 100644 index 00000000..a05bc25e --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java @@ -0,0 +1,69 @@ +package com.youlai.boot.framework.web.ratelimit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; + +import java.util.Collections; +import java.util.UUID; + +/** + * 滑动窗口限流 Lua 脚本(共享工具) + *

+ * 基于 Redis Sorted Set 实现原子化的滑动窗口计数: + *

    + *
  1. ZREMRANGEBYSCORE 清除窗口外旧请求
  2. + *
  3. ZADD 添加当前请求
  4. + *
  5. ZCARD 统计窗口内请求数
  6. + *
  7. PEXPIRE 设置 Key 过期(窗口 + 1s 冗余)
  8. + *
+ * 一次网络往返完成全部操作,避免多次 Redis 调用之间的竞态条件。 + *

+ * + * @author Ray.Hao + * @since 4.4.0 + */ +public final class SlidingWindowScript { + + private SlidingWindowScript() { + throw new UnsupportedOperationException("工具类不允许实例化"); + } + + private static final DefaultRedisScript SCRIPT; + + static { + SCRIPT = new DefaultRedisScript<>(); + SCRIPT.setScriptText( + "local key = KEYS[1] " + + "local now = tonumber(ARGV[1]) " + + "local window = tonumber(ARGV[2]) " + + "local member = ARGV[3] " + + "redis.call('ZREMRANGEBYSCORE', key, 0, now - window) " + + "redis.call('ZADD', key, now, member) " + + "redis.call('PEXPIRE', key, window + 1000) " + + "return redis.call('ZCARD', key)" + ); + SCRIPT.setResultType(Long.class); + } + + /** + * 执行滑动窗口计数 + * + * @param redisTemplate Redis 模板 + * @param key 限流 Key(如 {@code rate_limiter:ip:192.168.1.1}) + * @param windowMs 窗口大小(毫秒) + * @return 当前窗口内请求数(包含本次) + */ + public static Long execute(RedisTemplate redisTemplate, + String key, + long windowMs) { + // 传 Long 而非 String,避免 Jackson 序列化器加双引号导致 Lua tonumber() 返回 nil + return redisTemplate.execute( + SCRIPT, + Collections.singletonList(key), + System.currentTimeMillis(), + windowMs, + UUID.randomUUID().toString() + ); + } + +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index f3758e1b..ab421526 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -70,8 +70,10 @@ mybatis-plus: # 接口限流 rate-limit: - default-limit: 5 - default-window: 1 + default-limit: 60 + default-window: 60 + ip: + enabled: false # 开发环境关闭 IP 全局限流 # 安全配置 security: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index f445a740..260fb344 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -71,6 +71,10 @@ mybatis-plus: rate-limit: default-limit: 10 default-window: 1 + ip: + enabled: true + limit: 1000 # IP 全局:1000 req / 60s + window-seconds: 60 # 安全配置 security: