feat: C2 Redis 接入 + 接口限流(RedisService + @RateLimit,验证码/登录防刷)

This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-21 06:24:55 +08:00
1 parent d1aaf2e5c0
commit fa4977a71d
6 files changed
+145

No files matched your search

@@ -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:
+14
View File
@@ -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 "";
}
@@ -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;
}
}