refactor(rate-limit): 限流架构升级为双层滑动窗口,新增IP全局防护与响应头
限流算法从 INCR+EXPIRE 固定窗口升级为 ZSET+Lua 滑动窗口, 新增 IpRateLimitFilter 提供 IP 全局防护(默认 1000次/60s)。 新增 X-RateLimit-Limit/Remaining/Reset 响应头与 Retry-After, 新增 rate-limit.ip.* 配置项控制 IP 全局开关与阈值, @RateLimit 空注解统一走全局配置,消除硬编码。
This commit is contained in:
@@ -48,7 +48,7 @@ public class AuthController {
|
|||||||
@Operation(summary = "账号密码登录")
|
@Operation(summary = "账号密码登录")
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
||||||
@RateLimit(limit = 5, window = 60)
|
@RateLimit
|
||||||
public Result<AuthenticationToken> login(@RequestBody @Valid LoginForm request) {
|
public Result<AuthenticationToken> login(@RequestBody @Valid LoginForm request) {
|
||||||
AuthenticationToken authenticationToken = authService.login(request.getUsername(), request.getPassword());
|
AuthenticationToken authenticationToken = authService.login(request.getUsername(), request.getPassword());
|
||||||
return Result.success(authenticationToken);
|
return Result.success(authenticationToken);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public class WxMaAuthController {
|
|||||||
@Operation(summary = "静默登录", description = "通过微信 code 登录,已绑定用户直接返回 token,未绑定用户返回 openid 需绑定手机号")
|
@Operation(summary = "静默登录", description = "通过微信 code 登录,已绑定用户直接返回 token,未绑定用户返回 openid 需绑定手机号")
|
||||||
@PostMapping("/silent-login")
|
@PostMapping("/silent-login")
|
||||||
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
||||||
@RateLimit(limit = 10, window = 60)
|
@RateLimit
|
||||||
public Result<WxMaLoginVO> silentLogin(
|
public Result<WxMaLoginVO> silentLogin(
|
||||||
@Parameter(description = "微信登录凭证(wx.login 获取)", required = true, example = "0xxx")
|
@Parameter(description = "微信登录凭证(wx.login 获取)", required = true, example = "0xxx")
|
||||||
@RequestParam String code
|
@RequestParam String code
|
||||||
@@ -69,7 +69,7 @@ public class WxMaAuthController {
|
|||||||
@Operation(summary = "手机号快捷登录", description = "同时使用微信 code 和手机号授权 code 登录,适用于企业认证小程序")
|
@Operation(summary = "手机号快捷登录", description = "同时使用微信 code 和手机号授权 code 登录,适用于企业认证小程序")
|
||||||
@PostMapping("/phone-login")
|
@PostMapping("/phone-login")
|
||||||
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
||||||
@RateLimit(limit = 5, window = 60)
|
@RateLimit
|
||||||
public Result<AuthenticationToken> phoneLogin(@Valid @RequestBody WxMaPhoneLoginForm req) {
|
public Result<AuthenticationToken> phoneLogin(@Valid @RequestBody WxMaPhoneLoginForm req) {
|
||||||
AuthenticationToken result = wxMaAuthService.phoneLogin(req.getLoginCode(), req.getPhoneCode());
|
AuthenticationToken result = wxMaAuthService.phoneLogin(req.getLoginCode(), req.getPhoneCode());
|
||||||
return Result.success(result);
|
return Result.success(result);
|
||||||
@@ -85,7 +85,7 @@ public class WxMaAuthController {
|
|||||||
@Operation(summary = "绑定手机号", description = "为静默登录用户绑定手机号,绑定成功后自动登录")
|
@Operation(summary = "绑定手机号", description = "为静默登录用户绑定手机号,绑定成功后自动登录")
|
||||||
@PostMapping("/bind-mobile")
|
@PostMapping("/bind-mobile")
|
||||||
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
||||||
@RateLimit(limit = 3, window = 60)
|
@RateLimit
|
||||||
public Result<AuthenticationToken> bindMobile(@Valid @RequestBody WxMaBindMobileForm req) {
|
public Result<AuthenticationToken> bindMobile(@Valid @RequestBody WxMaBindMobileForm req) {
|
||||||
AuthenticationToken result = wxMaAuthService.bindMobile(req.getOpenid(), req.getMobile(), req.getSmsCode());
|
AuthenticationToken result = wxMaAuthService.bindMobile(req.getOpenid(), req.getMobile(), req.getSmsCode());
|
||||||
return Result.success(result);
|
return Result.success(result);
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ public interface RedisConstants {
|
|||||||
* 限流相关键
|
* 限流相关键
|
||||||
*/
|
*/
|
||||||
interface RateLimiter {
|
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:{}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import com.youlai.boot.common.exception.BusinessException;
|
|||||||
import com.youlai.boot.common.result.ResultCode;
|
import com.youlai.boot.common.result.ResultCode;
|
||||||
import com.youlai.boot.common.util.IPUtils;
|
import com.youlai.boot.common.util.IPUtils;
|
||||||
import com.youlai.boot.framework.web.config.RateLimitProperties;
|
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.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.aspectj.lang.ProceedingJoinPoint;
|
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.RequestContextHolder;
|
||||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 接口限流切面
|
* 接口级限流切面
|
||||||
* <p>对 @RateLimit 标注的方法做 Redis 计数,超阈值则拒绝</p>
|
* <p>
|
||||||
|
* 对标注了 {@link RateLimit} 的 Controller 方法做 Redis 滑动窗口计数。
|
||||||
|
* 超阈值抛出 {@link BusinessException}(A0502),全局异常处理器统一返回 JSON。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h3>升级历史</h3>
|
||||||
|
* <ul>
|
||||||
|
* <li>4.3.1 — 初始版本:固定窗口计数器</li>
|
||||||
|
* <li>4.4.0 — 升级为滑动窗口 + X-RateLimit-* 渐进式响应头</li>
|
||||||
|
* </ul>
|
||||||
*
|
*
|
||||||
* @author Ray.Hao
|
* @author Ray.Hao
|
||||||
* @since 4.3.1
|
* @since 4.3.1
|
||||||
@@ -41,25 +50,31 @@ public class RateLimitAspect {
|
|||||||
|
|
||||||
@Around("@annotation(rateLimit)")
|
@Around("@annotation(rateLimit)")
|
||||||
public Object handle(ProceedingJoinPoint jp, RateLimit rateLimit) throws Throwable {
|
public Object handle(ProceedingJoinPoint jp, RateLimit rateLimit) throws Throwable {
|
||||||
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
|
HttpServletRequest request =
|
||||||
String key = buildKey(request, rateLimit);
|
((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
|
||||||
|
|
||||||
|
String key = buildKey(request, rateLimit);
|
||||||
int limit = rateLimit.limit() > 0 ? rateLimit.limit() : rateLimitProperties.getDefaultLimit();
|
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();
|
int window = rateLimit.window() > 0 ? rateLimit.window() : rateLimitProperties.getDefaultWindow();
|
||||||
redisTemplate.expire(key, window, rateLimit.timeUnit());
|
long windowMs = rateLimit.timeUnit().toMillis(window);
|
||||||
}
|
|
||||||
if (count != null && count > limit) {
|
Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs);
|
||||||
log.warn("限流触发 key={} count={} limit={}", key, count, limit);
|
|
||||||
|
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);
|
throw new BusinessException(ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
|
||||||
}
|
}
|
||||||
|
|
||||||
return jp.proceed();
|
return jp.proceed();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
|
private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
|
||||||
String user = resolveUser(request);
|
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) {
|
private String resolveUser(HttpServletRequest request) {
|
||||||
@@ -69,4 +84,23 @@ public class RateLimitAspect {
|
|||||||
}
|
}
|
||||||
return IPUtils.getIpAddr(request);
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,52 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 限流配置
|
* 限流配置
|
||||||
|
* <p>
|
||||||
|
* 对应 application.yml 中的 {@code rate-limit} 配置节点。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @author Ray.Hao
|
||||||
|
* @since 4.3.1
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Component
|
@Component
|
||||||
@ConfigurationProperties(prefix = "rate-limit")
|
@ConfigurationProperties(prefix = "rate-limit")
|
||||||
public class RateLimitProperties {
|
public class RateLimitProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @RateLimit 注解未显式指定 limit 时的默认阈值
|
||||||
|
*/
|
||||||
private int defaultLimit = 5;
|
private int defaultLimit = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @RateLimit 注解未显式指定 window 时的默认窗口大小(秒)
|
||||||
|
*/
|
||||||
private int defaultWindow = 1;
|
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;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 全局限流过滤器
|
||||||
|
* <p>
|
||||||
|
* 在所有请求进入 Controller 之前,按 IP 维度做滑动窗口限流。
|
||||||
|
* 与 {@code @RateLimit} 注解级限流叠加:先过 IP 全局,再过接口级。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h3>限流维度</h3>
|
||||||
|
* <pre>{@code
|
||||||
|
* Key: rate_limiter:ip:{clientIp}
|
||||||
|
* 默认: 1000 req / 60s(可通过 rate-limit.ip.* 配置)
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <h3>响应头</h3>
|
||||||
|
* <table>
|
||||||
|
* <tr><td>X-RateLimit-Limit</td><td>窗口内最大允许请求数</td></tr>
|
||||||
|
* <tr><td>X-RateLimit-Remaining</td><td>窗口内剩余可用请求数</td></tr>
|
||||||
|
* <tr><td>X-RateLimit-Reset</td><td>窗口重置时间(Unix 秒)</td></tr>
|
||||||
|
* <tr><td>Retry-After</td><td>超限时建议重试等待秒数(仅 429)</td></tr>
|
||||||
|
* </table>
|
||||||
|
*
|
||||||
|
* @author Ray.Hao
|
||||||
|
* @since 4.4.0
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE + 5)
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class IpRateLimitFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final RedisTemplate<String, Object> 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 脚本(共享工具)
|
||||||
|
* <p>
|
||||||
|
* 基于 Redis Sorted Set 实现原子化的滑动窗口计数:
|
||||||
|
* <ol>
|
||||||
|
* <li>ZREMRANGEBYSCORE 清除窗口外旧请求</li>
|
||||||
|
* <li>ZADD 添加当前请求</li>
|
||||||
|
* <li>ZCARD 统计窗口内请求数</li>
|
||||||
|
* <li>PEXPIRE 设置 Key 过期(窗口 + 1s 冗余)</li>
|
||||||
|
* </ol>
|
||||||
|
* 一次网络往返完成全部操作,避免多次 Redis 调用之间的竞态条件。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @author Ray.Hao
|
||||||
|
* @since 4.4.0
|
||||||
|
*/
|
||||||
|
public final class SlidingWindowScript {
|
||||||
|
|
||||||
|
private SlidingWindowScript() {
|
||||||
|
throw new UnsupportedOperationException("工具类不允许实例化");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final DefaultRedisScript<Long> 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<String, Object> redisTemplate,
|
||||||
|
String key,
|
||||||
|
long windowMs) {
|
||||||
|
// 传 Long 而非 String,避免 Jackson 序列化器加双引号导致 Lua tonumber() 返回 nil
|
||||||
|
return redisTemplate.execute(
|
||||||
|
SCRIPT,
|
||||||
|
Collections.singletonList(key),
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
windowMs,
|
||||||
|
UUID.randomUUID().toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -70,8 +70,10 @@ mybatis-plus:
|
|||||||
|
|
||||||
# 接口限流
|
# 接口限流
|
||||||
rate-limit:
|
rate-limit:
|
||||||
default-limit: 5
|
default-limit: 60
|
||||||
default-window: 1
|
default-window: 60
|
||||||
|
ip:
|
||||||
|
enabled: false # 开发环境关闭 IP 全局限流
|
||||||
|
|
||||||
# 安全配置
|
# 安全配置
|
||||||
security:
|
security:
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ mybatis-plus:
|
|||||||
rate-limit:
|
rate-limit:
|
||||||
default-limit: 10
|
default-limit: 10
|
||||||
default-window: 1
|
default-window: 1
|
||||||
|
ip:
|
||||||
|
enabled: true
|
||||||
|
limit: 1000 # IP 全局:1000 req / 60s
|
||||||
|
window-seconds: 60
|
||||||
|
|
||||||
# 安全配置
|
# 安全配置
|
||||||
security:
|
security:
|
||||||
|
|||||||
Reference in New Issue
Block a user