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 05036b8..c514761 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 @@ -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 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 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 ae8eefe..9fa1bbe 100644 --- a/fruitshorts-app-api/src/main/resources/application.yml +++ b/fruitshorts-app-api/src/main/resources/application.yml @@ -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: diff --git a/fruitshorts-framework/pom.xml b/fruitshorts-framework/pom.xml index a22de97..bafe214 100644 --- a/fruitshorts-framework/pom.xml +++ b/fruitshorts-framework/pom.xml @@ -66,5 +66,19 @@ org.springdoc springdoc-openapi-starter-webmvc-ui + + + + org.springframework.boot + spring-boot-starter-actuator + + + 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; + } +}