From 952057ac51e0f610e26ff60de37cf7694518b3af Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Sat, 17 Oct 2026 02:49:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20C2=20Redis=20=E6=8E=A5=E5=85=A5=20+=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E9=99=90=E6=B5=81=EF=BC=88RedisService=20+?= =?UTF-8?q?=20@RateLimit=EF=BC=8C=E9=AA=8C=E8=AF=81=E7=A0=81/=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=98=B2=E5=88=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controller/AppAuthController.java | 3 ++ .../src/main/resources/application.yml | 4 ++ fruitshorts-framework/pom.xml | 4 ++ .../framework/redis/RateLimit.java | 25 +++++++++ .../framework/redis/RateLimitAspect.java | 54 +++++++++++++++++++ .../framework/redis/RedisService.java | 45 ++++++++++++++++ 6 files changed, 135 insertions(+) create mode 100644 fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RateLimit.java create mode 100644 fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RateLimitAspect.java create mode 100644 fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RedisService.java diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java index 3d1c58d..14e4c53 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java @@ -3,6 +3,7 @@ package com.fruitshorts.app.controller; 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; @@ -35,6 +36,7 @@ public class AppAuthController { /** 发送验证码。频控在服务端,客户端不需要自己限流。 */ @Operation(summary = "发送验证码(注册/登录/找回密码,服务端频控)") + @RateLimit(limit = 20, windowSeconds = 60, key = "otp") @PostMapping("/otp") public Result sendOtp(@Valid @RequestBody OtpSendReqVO req, HttpServletRequest request) { otpService.send(req, WebUtils.getClientIp(request)); @@ -49,6 +51,7 @@ public class AppAuthController { } @Operation(summary = "登录(验证码或密码),返回令牌") + @RateLimit(limit = 10, windowSeconds = 60, key = "login") @PostMapping("/login") public Result login(@Valid @RequestBody LoginReqVO req, HttpServletRequest request) { diff --git a/fruitshorts-app-api/src/main/resources/application.yml b/fruitshorts-app-api/src/main/resources/application.yml index 54c49ed..db79c78 100644 --- a/fruitshorts-app-api/src/main/resources/application.yml +++ b/fruitshorts-app-api/src/main/resources/application.yml @@ -16,6 +16,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: diff --git a/fruitshorts-framework/pom.xml b/fruitshorts-framework/pom.xml index 1509589..bafe214 100644 --- a/fruitshorts-framework/pom.xml +++ b/fruitshorts-framework/pom.xml @@ -76,5 +76,9 @@ io.micrometer micrometer-registry-prometheus + + org.springframework.boot + spring-boot-starter-data-redis + diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RateLimit.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RateLimit.java new file mode 100644 index 0000000..c052cf7 --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RateLimit.java @@ -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 ""; +} diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RateLimitAspect.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RateLimitAspect.java new file mode 100644 index 0000000..5554e1b --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RateLimitAspect.java @@ -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(); + } +} diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RedisService.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RedisService.java new file mode 100644 index 0000000..62a3cdb --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/redis/RedisService.java @@ -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; + } +}