feat: C2 Redis 接入 + 接口限流(RedisService + @RateLimit,验证码/登录防刷)
This commit is contained in:
1 parent
d1aaf2e5c0
commit
fa4977a71d
6 files changed
+145
No files matched your search
+3
@@ -4,6 +4,7 @@ package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.app.vo.GoogleLoginReqVO;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.redis.RateLimit;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.framework.web.WebUtils;
|
||||
import com.fruitshorts.user.service.AppAuthService;
|
||||
@@ -36,6 +37,7 @@ public class AppAuthController {
|
||||
|
||||
/** 发送验证码。频控在服务端,客户端不需要自己限流。 */
|
||||
@Operation(summary = "发送验证码(频控在服务端)")
|
||||
@RateLimit(limit = 20, windowSeconds = 60, key = "otp")
|
||||
@PostMapping("/otp")
|
||||
public Result<Void> sendOtp(@Valid @RequestBody OtpSendReqVO req, HttpServletRequest request) {
|
||||
otpService.send(req, WebUtils.getClientIp(request));
|
||||
@@ -50,6 +52,7 @@ public class AppAuthController {
|
||||
}
|
||||
|
||||
@Operation(summary = "登录(验证码或密码)")
|
||||
@RateLimit(limit = 10, windowSeconds = 60, key = "login")
|
||||
@PostMapping("/login")
|
||||
public Result<TokenRespVO> login(@Valid @RequestBody LoginReqVO req,
|
||||
HttpServletRequest request) {
|
||||
|
||||
@@ -18,6 +18,10 @@ spring:
|
||||
jackson:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
time-zone: Asia/Shanghai
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:127.0.0.1}
|
||||
port: ${REDIS_PORT:6379}
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
|
||||
@@ -66,5 +66,19 @@
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 监控:暴露 actuator 指标供 Prometheus 抓取 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,25 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.framework.redis;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 接口级固定窗口限流。基于 Redis INCR,key 按客户端 IP 维度隔离。
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface RateLimit {
|
||||
|
||||
/** 窗口内允许的最大请求次数。 */
|
||||
int limit() default 10;
|
||||
|
||||
/** 窗口时长(秒)。 */
|
||||
int windowSeconds() default 60;
|
||||
|
||||
/** 限流维度前缀,默认取方法名。 */
|
||||
String key() default "";
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.framework.redis;
|
||||
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Before;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 固定窗口限流切面:每次命中注解先 INCR,超阈值即拒绝。
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RateLimitAspect {
|
||||
|
||||
private final RedisService redisService;
|
||||
|
||||
@Before("@annotation(rateLimit)")
|
||||
public void check(RateLimit rateLimit) {
|
||||
long count = redisService.increment(
|
||||
buildKey(rateLimit), Duration.ofSeconds(rateLimit.windowSeconds()));
|
||||
if (count > rateLimit.limit()) {
|
||||
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "请求过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
|
||||
private String buildKey(RateLimit rateLimit) {
|
||||
String scope = rateLimit.key().isBlank() ? "default" : rateLimit.key();
|
||||
return "rate:" + scope + ":" + clientIp();
|
||||
}
|
||||
|
||||
private String clientIp() {
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attrs == null) {
|
||||
return "unknown";
|
||||
}
|
||||
HttpServletRequest request = attrs.getRequest();
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.framework.redis;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Redis 字符串操作封装,供缓存与限流复用。
|
||||
* 统一在这里兜底过期策略,业务侧不直接接触 RedisTemplate。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RedisService {
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
public void set(String key, String value, Duration ttl) {
|
||||
redisTemplate.opsForValue().set(key, value, ttl);
|
||||
}
|
||||
|
||||
public String get(String key) {
|
||||
return redisTemplate.opsForValue().get(key);
|
||||
}
|
||||
|
||||
public void delete(String key) {
|
||||
redisTemplate.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定窗口计数:自增并保证首次写入时设置过期时间。
|
||||
* 用于接口限流,返回当前窗口内的累计次数。
|
||||
*/
|
||||
public long increment(String key, Duration window) {
|
||||
Long count = redisTemplate.opsForValue().increment(key);
|
||||
long value = count == null ? 1 : count;
|
||||
if (value == 1) {
|
||||
redisTemplate.expire(key, window);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user