提交 7496710a 作者: 方治民

feat: 新增 RateLimiter 接口限流注解防刷实现

上级 87c23970
...@@ -11,6 +11,7 @@ import com.yiring.app.vo.user.UserExtensionVo; ...@@ -11,6 +11,7 @@ import com.yiring.app.vo.user.UserExtensionVo;
import com.yiring.auth.domain.user.User; import com.yiring.auth.domain.user.User;
import com.yiring.auth.util.Auths; import com.yiring.auth.util.Auths;
import com.yiring.common.annotation.DownloadResponse; import com.yiring.common.annotation.DownloadResponse;
import com.yiring.common.annotation.RateLimiter;
import com.yiring.common.core.I18n; import com.yiring.common.core.I18n;
import com.yiring.common.core.Result; import com.yiring.common.core.Result;
import com.yiring.common.core.Status; import com.yiring.common.core.Status;
...@@ -61,6 +62,7 @@ public class ExampleController { ...@@ -61,6 +62,7 @@ public class ExampleController {
final UserExtensionRepository userExtensionRepository; final UserExtensionRepository userExtensionRepository;
final FileManageService fileManageService; final FileManageService fileManageService;
@RateLimiter(count = 1)
@Operation(summary = "Hello World") @Operation(summary = "Hello World")
@GetMapping @GetMapping
public Result<String> hello() { public Result<String> hello() {
......
...@@ -12,6 +12,7 @@ import com.yiring.auth.param.auth.RegisterParam; ...@@ -12,6 +12,7 @@ import com.yiring.auth.param.auth.RegisterParam;
import com.yiring.auth.param.auth.SafeParam; import com.yiring.auth.param.auth.SafeParam;
import com.yiring.auth.util.Auths; import com.yiring.auth.util.Auths;
import com.yiring.auth.vo.auth.LoginVo; import com.yiring.auth.vo.auth.LoginVo;
import com.yiring.common.annotation.RateLimiter;
import com.yiring.common.core.Result; import com.yiring.common.core.Result;
import com.yiring.common.exception.BusinessException; import com.yiring.common.exception.BusinessException;
import com.yiring.common.util.Commons; import com.yiring.common.util.Commons;
...@@ -54,6 +55,7 @@ public class AuthController { ...@@ -54,6 +55,7 @@ public class AuthController {
final Auths auths; final Auths auths;
final UserRepository userRepository; final UserRepository userRepository;
@RateLimiter(time = 1, count = 1)
@Operation(summary = "注册") @Operation(summary = "注册")
@PostMapping(value = "register") @PostMapping(value = "register")
public Result<String> register(@ParameterObject @Validated RegisterParam param) { public Result<String> register(@ParameterObject @Validated RegisterParam param) {
...@@ -91,6 +93,7 @@ public class AuthController { ...@@ -91,6 +93,7 @@ public class AuthController {
return Result.ok(); return Result.ok();
} }
@RateLimiter(count = 3)
@Operation(summary = "登录") @Operation(summary = "登录")
@PostMapping("login") @PostMapping("login")
public Result<LoginVo> login(@ParameterObject @Validated LoginParam param, HttpServletRequest request) { public Result<LoginVo> login(@ParameterObject @Validated LoginParam param, HttpServletRequest request) {
...@@ -135,6 +138,7 @@ public class AuthController { ...@@ -135,6 +138,7 @@ public class AuthController {
return Result.ok(StpUtil.isLogin()); return Result.ok(StpUtil.isLogin());
} }
@RateLimiter(count = 3)
@Operation(summary = "登出") @Operation(summary = "登出")
@GetMapping("logout") @GetMapping("logout")
public Result<String> logout() { public Result<String> logout() {
...@@ -149,6 +153,7 @@ public class AuthController { ...@@ -149,6 +153,7 @@ public class AuthController {
* @param param 用户密码 * @param param 用户密码
* @link { <a href="https://sa-token.dev33.cn/doc.html#/up/safe-auth">...</a> } * @link { <a href="https://sa-token.dev33.cn/doc.html#/up/safe-auth">...</a> }
*/ */
@RateLimiter(count = 3)
@SaCheckLogin @SaCheckLogin
@Operation(summary = "安全验证") @Operation(summary = "安全验证")
@GetMapping("safe") @GetMapping("safe")
......
...@@ -6,6 +6,7 @@ Status.FORBIDDEN=Forbidden ...@@ -6,6 +6,7 @@ Status.FORBIDDEN=Forbidden
Status.NOT_FOUND=Not Found Status.NOT_FOUND=Not Found
Status.METHOD_NOT_ALLOWED=Method Not Allowed Status.METHOD_NOT_ALLOWED=Method Not Allowed
Status.EXPECTATION_FAILED=Expectation Failed Status.EXPECTATION_FAILED=Expectation Failed
Status.TOO_MANY_REQUESTS=Too Many Requests
Status.INTERNAL_SERVER_ERROR=Internal Server Error Status.INTERNAL_SERVER_ERROR=Internal Server Error
Status.UNKNOWN_ERROR=Unknown Error Status.UNKNOWN_ERROR=Unknown Error
Status.NOT_IMPLEMENTED=Not Implemented Status.NOT_IMPLEMENTED=Not Implemented
......
...@@ -6,6 +6,7 @@ Status.FORBIDDEN=\u7981\u6B62\u8BBF\u95EE ...@@ -6,6 +6,7 @@ Status.FORBIDDEN=\u7981\u6B62\u8BBF\u95EE
Status.NOT_FOUND=\u627E\u4E0D\u5230\u8D44\u6E90 Status.NOT_FOUND=\u627E\u4E0D\u5230\u8D44\u6E90
Status.METHOD_NOT_ALLOWED=\u4E0D\u652F\u6301\u7684\u8BF7\u6C42\u7C7B\u578B Status.METHOD_NOT_ALLOWED=\u4E0D\u652F\u6301\u7684\u8BF7\u6C42\u7C7B\u578B
Status.EXPECTATION_FAILED=\u65E0\u6548\u53C2\u6570 Status.EXPECTATION_FAILED=\u65E0\u6548\u53C2\u6570
Status.TOO_MANY_REQUESTS=\u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41
Status.INTERNAL_SERVER_ERROR=\u670D\u52A1\u5668\u9519\u8BEF Status.INTERNAL_SERVER_ERROR=\u670D\u52A1\u5668\u9519\u8BEF
Status.UNKNOWN_ERROR=\u672A\u77E5\u9519\u8BEF Status.UNKNOWN_ERROR=\u672A\u77E5\u9519\u8BEF
Status.NOT_IMPLEMENTED=API \u672A\u5B9E\u73B0 Status.NOT_IMPLEMENTED=API \u672A\u5B9E\u73B0
......
dependencies { dependencies {
implementation project(":basic-common:util") implementation project(":basic-common:util")
implementation project(":basic-common:i18n") implementation project(":basic-common:i18n")
implementation project(":basic-common:redis")
implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-validation'
// swagger(knife4j) // swagger(knife4j)
......
/* (C) 2023 YiRing, Inc. */
package com.yiring.common.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 流控注解
*
* @author Jim
* @version 0.1
* 2023/12/19 15:20
*/
@SuppressWarnings({ "unused" })
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimiter {
String RATE_LIMIT_KEY = "rate_limit:";
/**
* 限流key
*/
String key() default RATE_LIMIT_KEY;
/**
* 限流时间,单位秒
*/
int time() default 5;
/**
* 限流次数
*/
int count() default 10;
}
/* (C) 2023 YiRing, Inc. */
package com.yiring.common.aspect;
import com.yiring.common.annotation.RateLimiter;
import com.yiring.common.core.Redis;
import com.yiring.common.core.Status;
import com.yiring.common.util.Commons;
import jakarta.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
/**
* 流控切面
*
* @author Jim
* @version 0.1
* 2023/12/19 15:17
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimiterAspect {
final Redis redis;
/**
* 带有注解的方法之前执行
*/
@Before("@annotation(rateLimiter)")
public void doBefore(JoinPoint point, RateLimiter rateLimiter) {
int time = rateLimiter.time();
int count = rateLimiter.count();
// 将接口方法和用户IP构建Redis的key
String key = getRateLimiterKey(rateLimiter.key(), point);
// 使用 ZSet 的 score 设置成用户访问接口的时间戳
ZSetOperations<String, Object> zSetOperations = redis.getTemplate().opsForZSet();
// 当前时间戳
long currentTime = System.currentTimeMillis();
zSetOperations.add(key, currentTime, currentTime);
// 设置过期时间防止 key 不消失
redis.getTemplate().expire(key, time, TimeUnit.SECONDS);
// 移除 time 秒之前的访问记录,动态时间段
zSetOperations.removeRangeByScore(key, 0, currentTime - time * 1000L);
// 获得当前时间窗口内的访问记录数
Long currentCount = zSetOperations.zCard(key);
// 限流判断
if (Objects.nonNull(currentCount) && currentCount > count) {
log.warn("[Request RateLimit] 接口限流, Key: {}, count: {}, currentCount: {}", key, count, currentCount);
throw Status.TOO_MANY_REQUESTS.exception();
}
}
/**
* 组装 redis 的 key
*/
private String getRateLimiterKey(String prefixKey, JoinPoint point) {
StringBuilder sb = new StringBuilder(prefixKey);
HttpServletRequest request = getRequest();
sb.append(Commons.getClientIpAddress(request));
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
Class<?> targetClass = method.getDeclaringClass();
return sb.append("_").append(targetClass.getName()).append("_").append(method.getName()).toString();
}
/**
* 获取 HttpServletRequest
*/
private HttpServletRequest getRequest() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
assert requestAttributes != null;
return (HttpServletRequest) requestAttributes.resolveReference(RequestAttributes.REFERENCE_REQUEST);
}
}
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
package com.yiring.common.core; package com.yiring.common.core;
import com.yiring.common.exception.FailStatusException; import com.yiring.common.exception.FailStatusException;
import lombok.Getter;
import org.jetbrains.annotations.PropertyKey; import org.jetbrains.annotations.PropertyKey;
import org.springframework.lang.Nullable; import org.springframework.lang.Nullable;
...@@ -55,6 +56,11 @@ public enum Status { ...@@ -55,6 +56,11 @@ public enum Status {
EXPECTATION_FAILED(417, "Status.EXPECTATION_FAILED"), EXPECTATION_FAILED(417, "Status.EXPECTATION_FAILED"),
/** /**
* 请求过于频繁
*/
TOO_MANY_REQUESTS(429, "Status.TOO_MANY_REQUESTS"),
/**
* 服务器错误 * 服务器错误
*/ */
INTERNAL_SERVER_ERROR(500, "Status.INTERNAL_SERVER_ERROR"), INTERNAL_SERVER_ERROR(500, "Status.INTERNAL_SERVER_ERROR"),
...@@ -81,6 +87,11 @@ public enum Status { ...@@ -81,6 +87,11 @@ public enum Status {
private final int value; private final int value;
/**
* -- GETTER --
* Return the reason phrase of this status code.
*/
@Getter
private final String reasonPhrase; private final String reasonPhrase;
Status(int value, @PropertyKey(resourceBundle = "i18n.status") String reasonPhrase) { Status(int value, @PropertyKey(resourceBundle = "i18n.status") String reasonPhrase) {
...@@ -128,13 +139,6 @@ public enum Status { ...@@ -128,13 +139,6 @@ public enum Status {
} }
/** /**
* Return the reason phrase of this status code.
*/
public String getReasonPhrase() {
return this.reasonPhrase;
}
/**
* Return a string representation of this status code. * Return a string representation of this status code.
*/ */
@Override @Override
......
...@@ -36,7 +36,7 @@ public class I18nConfig { ...@@ -36,7 +36,7 @@ public class I18nConfig {
@Bean @Bean
public MessageSource messageSource() { public MessageSource messageSource() {
SmReloadableResourceBundleMessageSource messageSource = new SmReloadableResourceBundleMessageSource(); SmReloadableResourceBundleMessageSource messageSource = new SmReloadableResourceBundleMessageSource();
messageSource.setBasenames("classpath*:i18n/messages", "classpath:i18n/status", "classpath:i18n/validation"); messageSource.setBasenames("classpath*:i18n/messages", "classpath*:i18n/status", "classpath:i18n/validation");
messageSource.setDefaultEncoding("UTF-8"); messageSource.setDefaultEncoding("UTF-8");
messageSource.setAlwaysUseMessageFormat(true); messageSource.setAlwaysUseMessageFormat(true);
messageSource.setDefaultLocale(DEFAULT_LOCALE); messageSource.setDefaultLocale(DEFAULT_LOCALE);
......
...@@ -6,6 +6,7 @@ Status.FORBIDDEN=Forbidden ...@@ -6,6 +6,7 @@ Status.FORBIDDEN=Forbidden
Status.NOT_FOUND=Not Found Status.NOT_FOUND=Not Found
Status.METHOD_NOT_ALLOWED=Method Not Allowed Status.METHOD_NOT_ALLOWED=Method Not Allowed
Status.EXPECTATION_FAILED=Expectation Failed Status.EXPECTATION_FAILED=Expectation Failed
Status.TOO_MANY_REQUESTS=Too Many Requests
Status.INTERNAL_SERVER_ERROR=Internal Server Error Status.INTERNAL_SERVER_ERROR=Internal Server Error
Status.UNKNOWN_ERROR=Unknown Error Status.UNKNOWN_ERROR=Unknown Error
Status.NOT_IMPLEMENTED=Not Implemented Status.NOT_IMPLEMENTED=Not Implemented
......
...@@ -6,6 +6,7 @@ Status.FORBIDDEN=\u7981\u6B62\u8BBF\u95EE ...@@ -6,6 +6,7 @@ Status.FORBIDDEN=\u7981\u6B62\u8BBF\u95EE
Status.NOT_FOUND=\u627E\u4E0D\u5230\u8D44\u6E90 Status.NOT_FOUND=\u627E\u4E0D\u5230\u8D44\u6E90
Status.METHOD_NOT_ALLOWED=\u4E0D\u652F\u6301\u7684\u8BF7\u6C42\u7C7B\u578B Status.METHOD_NOT_ALLOWED=\u4E0D\u652F\u6301\u7684\u8BF7\u6C42\u7C7B\u578B
Status.EXPECTATION_FAILED=\u65E0\u6548\u53C2\u6570 Status.EXPECTATION_FAILED=\u65E0\u6548\u53C2\u6570
Status.TOO_MANY_REQUESTS=\u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41
Status.INTERNAL_SERVER_ERROR=\u670D\u52A1\u5668\u9519\u8BEF Status.INTERNAL_SERVER_ERROR=\u670D\u52A1\u5668\u9519\u8BEF
Status.UNKNOWN_ERROR=\u672A\u77E5\u9519\u8BEF Status.UNKNOWN_ERROR=\u672A\u77E5\u9519\u8BEF
Status.NOT_IMPLEMENTED=API \u672A\u5B9E\u73B0 Status.NOT_IMPLEMENTED=API \u672A\u5B9E\u73B0
......
...@@ -481,4 +481,8 @@ public final class Redis { ...@@ -481,4 +481,8 @@ public final class Redis {
.info(); .info();
return Convert.toStr(info); return Convert.toStr(info);
} }
public RedisTemplate<String, Object> getTemplate() {
return redisTemplate;
}
} }
...@@ -48,7 +48,7 @@ public class Commons { ...@@ -48,7 +48,7 @@ public class Commons {
public String getClientIpAddress(HttpServletRequest request) { public String getClientIpAddress(HttpServletRequest request) {
for (String header : HEADERS_TO_TRY) { for (String header : HEADERS_TO_TRY) {
String ip = request.getHeader(header); String ip = request.getHeader(header);
if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) { if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
return ip; return ip;
} }
} }
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论