Просмотр исходного кода

合作平台 内容获取限流

wangyunpeng 1 день назад
Родитель
Сommit
9c01c38d83

+ 9 - 4
api-module/src/main/java/com/tzld/piaoquan/api/annotation/RateLimit.java

@@ -6,24 +6,29 @@ import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 import java.lang.annotation.Target;
 
 
 /**
 /**
- * IP 限流注解
+ * 限流注解,支持按 IP 或按账号维度
  */
  */
 @Target(ElementType.METHOD)
 @Target(ElementType.METHOD)
 @Retention(RetentionPolicy.RUNTIME)
 @Retention(RetentionPolicy.RUNTIME)
 public @interface RateLimit {
 public @interface RateLimit {
-    
+
     /**
     /**
      * 限流时间窗口,单位秒
      * 限流时间窗口,单位秒
      */
      */
     long timeWindow() default 60;
     long timeWindow() default 60;
-    
+
     /**
     /**
      * 时间窗口内最大请求数
      * 时间窗口内最大请求数
      */
      */
     long maxRequests() default 10;
     long maxRequests() default 10;
-    
+
     /**
     /**
      * 限流提示信息
      * 限流提示信息
      */
      */
     String message() default "请求过于频繁,请稍后再试";
     String message() default "请求过于频繁,请稍后再试";
+
+    /**
+     * 是否按账号限流(需要登录态),true 时以 accountId 为限流键,false 时以 IP 为限流键
+     */
+    boolean limitByAccount() default false;
 }
 }

+ 51 - 38
api-module/src/main/java/com/tzld/piaoquan/api/aop/RateLimitAop.java

@@ -4,6 +4,8 @@ import com.google.common.base.Strings;
 import com.tzld.piaoquan.api.annotation.RateLimit;
 import com.tzld.piaoquan.api.annotation.RateLimit;
 import com.tzld.piaoquan.api.common.enums.ExceptionEnum;
 import com.tzld.piaoquan.api.common.enums.ExceptionEnum;
 import com.tzld.piaoquan.api.common.exception.CommonException;
 import com.tzld.piaoquan.api.common.exception.CommonException;
+import com.tzld.piaoquan.api.model.config.LoginUserContext;
+import com.tzld.piaoquan.api.model.po.contentplatform.ContentPlatformAccount;
 import com.tzld.piaoquan.growth.common.utils.IpUtil;
 import com.tzld.piaoquan.growth.common.utils.IpUtil;
 import com.tzld.piaoquan.growth.common.utils.RedisUtils;
 import com.tzld.piaoquan.growth.common.utils.RedisUtils;
 import org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.ProceedingJoinPoint;
@@ -23,66 +25,77 @@ import java.lang.reflect.Method;
 import java.util.Objects;
 import java.util.Objects;
 
 
 /**
 /**
- * IP 限流 AOP 切面
+ * 限流 AOP 切面,支持按账号或 IP 维度
  */
  */
 @Aspect
 @Aspect
 @Component
 @Component
 public class RateLimitAop {
 public class RateLimitAop {
-    
+
     private static final Logger log = LoggerFactory.getLogger(RateLimitAop.class);
     private static final Logger log = LoggerFactory.getLogger(RateLimitAop.class);
-    
+
     @Autowired
     @Autowired
     private RedisUtils redisUtils;
     private RedisUtils redisUtils;
-    
+
     @Pointcut("@annotation(com.tzld.piaoquan.api.annotation.RateLimit)")
     @Pointcut("@annotation(com.tzld.piaoquan.api.annotation.RateLimit)")
     public void rateLimit() {
     public void rateLimit() {
     }
     }
-    
-    /**
-     * 环绕通知,执行限流逻辑
-     */
+
     @Around("rateLimit()")
     @Around("rateLimit()")
     public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
     public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
         MethodSignature signature = (MethodSignature) joinPoint.getSignature();
         MethodSignature signature = (MethodSignature) joinPoint.getSignature();
         Method method = signature.getMethod();
         Method method = signature.getMethod();
-        
-        // 获取注解信息
+
         RateLimit rateLimit = method.getAnnotation(RateLimit.class);
         RateLimit rateLimit = method.getAnnotation(RateLimit.class);
         long timeWindow = rateLimit.timeWindow();
         long timeWindow = rateLimit.timeWindow();
         long maxRequests = rateLimit.maxRequests();
         long maxRequests = rateLimit.maxRequests();
         String message = rateLimit.message();
         String message = rateLimit.message();
-        
-        // 获取请求对象
-        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
-        HttpServletRequest request = Objects.requireNonNull(attributes).getRequest();
-        
-        // 获取客户端 IP
-        String clientIp = IpUtil.getIpAddr(request);
-        
-        if (Strings.isNullOrEmpty(clientIp)) {
-            log.warn("Rate limit - client IP is empty");
+        boolean limitByAccount = rateLimit.limitByAccount();
+
+        String methodName = method.getName();
+        String limitKey;
+
+        if (limitByAccount) {
+            ContentPlatformAccount user = LoginUserContext.getUser();
+            if (user == null || user.getId() == null) {
+                log.warn("Rate limit - limitByAccount=true but no login user, fallback to IP");
+                ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+                HttpServletRequest request = Objects.requireNonNull(attributes).getRequest();
+                limitKey = IpUtil.getIpAddr(request);
+            } else {
+                limitKey = String.valueOf(user.getId());
+            }
+        } else {
+            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+            HttpServletRequest request = Objects.requireNonNull(attributes).getRequest();
+            limitKey = IpUtil.getIpAddr(request);
+        }
+
+        if (Strings.isNullOrEmpty(limitKey)) {
+            log.warn("Rate limit - limit key is empty");
             return joinPoint.proceed();
             return joinPoint.proceed();
         }
         }
-        
-        // 构建 Redis key: rate_limit:{method_name}:{ip}
-        String methodName = method.getName();
-        String redisKey = String.format("rate_limit:%s:%s", methodName, clientIp);
-        
-        // 获取当前请求次数
-        Long currentRequests = redisUtils.getLong(redisKey);
-        
-        if (currentRequests >= maxRequests) {
-            log.warn("Rate limit exceeded - IP: {}, Method: {}, Requests: {}, Max: {}", 
-                    clientIp, methodName, currentRequests, maxRequests);
+
+        String redisKey = String.format("rate_limit:%s:%s", methodName, limitKey);
+
+        // 原子递增并返回递增后的值,避免 get + set 的竞态问题
+        Long currentRequests = redisUtils.incrWithExpire(redisKey, timeWindow);
+
+        if (currentRequests == null) {
+            log.warn("Rate limit - incrWithExpire returned null");
+            return joinPoint.proceed();
+        }
+
+        if (currentRequests > maxRequests) {
+            // 回滚本次递增
+            redisUtils.setDecrementValue(redisKey, 1);
+            log.warn("Rate limit exceeded - Key: {}, Method: {}, Requests: {}, Max: {}",
+                    limitKey, methodName, currentRequests, maxRequests);
             throw new CommonException(ExceptionEnum.PARAM_ERROR.getCode(), message);
             throw new CommonException(ExceptionEnum.PARAM_ERROR.getCode(), message);
         }
         }
-        
-        // 增加请求计数
-        redisUtils.setIncrementValue(redisKey, 1, timeWindow);
-        
-        log.debug("Rate limit - IP: {}, Method: {}, Current Requests: {}, Max: {}", 
-                clientIp, methodName, currentRequests + 1, maxRequests);
-        
+
+        log.debug("Rate limit - Key: {}, Method: {}, Current Requests: {}, Max: {}",
+                limitKey, methodName, currentRequests, maxRequests);
+
         return joinPoint.proceed();
         return joinPoint.proceed();
     }
     }
 }
 }

+ 2 - 0
api-module/src/main/java/com/tzld/piaoquan/api/controller/contentplatform/ContentPlatformAccountController.java

@@ -1,6 +1,7 @@
 package com.tzld.piaoquan.api.controller.contentplatform;
 package com.tzld.piaoquan.api.controller.contentplatform;
 
 
 import com.tzld.piaoquan.api.annotation.JwtIgnore;
 import com.tzld.piaoquan.api.annotation.JwtIgnore;
+import com.tzld.piaoquan.api.annotation.RateLimit;
 import com.tzld.piaoquan.api.model.param.contentplatform.AccountForbiddenParam;
 import com.tzld.piaoquan.api.model.param.contentplatform.AccountForbiddenParam;
 import com.tzld.piaoquan.api.model.param.contentplatform.AccountListParam;
 import com.tzld.piaoquan.api.model.param.contentplatform.AccountListParam;
 import com.tzld.piaoquan.api.model.param.contentplatform.AccountLoginParam;
 import com.tzld.piaoquan.api.model.param.contentplatform.AccountLoginParam;
@@ -25,6 +26,7 @@ public class ContentPlatformAccountController {
     @ApiOperation(value = "手机号登录")
     @ApiOperation(value = "手机号登录")
     @PostMapping("/login")
     @PostMapping("/login")
     @JwtIgnore
     @JwtIgnore
+    @RateLimit(timeWindow = 1800, maxRequests = 5, message = "请求过于频繁,请稍后再试")
     public CommonResponse<AccountLoginVO> login(@RequestBody AccountLoginParam param) {
     public CommonResponse<AccountLoginVO> login(@RequestBody AccountLoginParam param) {
         return CommonResponse.success(accountService.login(param));
         return CommonResponse.success(accountService.login(param));
     }
     }

+ 7 - 0
api-module/src/main/java/com/tzld/piaoquan/api/controller/contentplatform/ContentPlatformPlanController.java

@@ -1,6 +1,9 @@
 package com.tzld.piaoquan.api.controller.contentplatform;
 package com.tzld.piaoquan.api.controller.contentplatform;
 
 
 import com.tzld.piaoquan.api.annotation.JwtIgnore;
 import com.tzld.piaoquan.api.annotation.JwtIgnore;
+import com.tzld.piaoquan.api.annotation.RateLimit;
+import com.tzld.piaoquan.api.common.enums.ExceptionEnum;
+import com.tzld.piaoquan.api.common.exception.CommonException;
 import com.tzld.piaoquan.api.job.contentplatform.ContentPlatformVideoJob;
 import com.tzld.piaoquan.api.job.contentplatform.ContentPlatformVideoJob;
 import com.tzld.piaoquan.api.job.contentplatform.ContentPlatformDemandVideoJob;
 import com.tzld.piaoquan.api.job.contentplatform.ContentPlatformDemandVideoJob;
 import com.tzld.piaoquan.api.model.param.IdParam;
 import com.tzld.piaoquan.api.model.param.IdParam;
@@ -61,24 +64,28 @@ public class ContentPlatformPlanController {
 
 
     @ApiOperation(value = "发布内容视频列表")
     @ApiOperation(value = "发布内容视频列表")
     @PostMapping("/videoContentList")
     @PostMapping("/videoContentList")
+    @RateLimit(timeWindow = 60, maxRequests = 10, limitByAccount = true, message = "请求过于频繁,请稍后再试")
     public CommonResponse<Page<VideoContentItemVO>> getVideoContentList(@RequestBody VideoContentListParam param) {
     public CommonResponse<Page<VideoContentItemVO>> getVideoContentList(@RequestBody VideoContentListParam param) {
         return CommonResponse.success(planService.getVideoContentList(param));
         return CommonResponse.success(planService.getVideoContentList(param));
     }
     }
 
 
     @ApiOperation(value = "向量匹配视频列表")
     @ApiOperation(value = "向量匹配视频列表")
     @PostMapping("/vector/videoContentList")
     @PostMapping("/vector/videoContentList")
+    @RateLimit(timeWindow = 60, maxRequests = 10, limitByAccount = true, message = "请求过于频繁,请稍后再试")
     public CommonResponse<Page<VideoContentItemVO>> getVectorVideoContentList(@RequestBody VideoContentListParam param) {
     public CommonResponse<Page<VideoContentItemVO>> getVectorVideoContentList(@RequestBody VideoContentListParam param) {
         return CommonResponse.success(planService.getVectorVideoContentList(param));
         return CommonResponse.success(planService.getVectorVideoContentList(param));
     }
     }
 
 
     @ApiOperation(value = "个人上传发布内容视频列表")
     @ApiOperation(value = "个人上传发布内容视频列表")
     @PostMapping("/upload/videoContentList")
     @PostMapping("/upload/videoContentList")
+    @RateLimit(timeWindow = 60, maxRequests = 10, limitByAccount = true, message = "请求过于频繁,请稍后再试")
     public CommonResponse<Page<VideoContentItemVO>> getUploadVideoContentList(@RequestBody VideoContentListParam param) {
     public CommonResponse<Page<VideoContentItemVO>> getUploadVideoContentList(@RequestBody VideoContentListParam param) {
         return CommonResponse.success(planService.getUploadVideoContentList(param));
         return CommonResponse.success(planService.getUploadVideoContentList(param));
     }
     }
 
 
     @ApiOperation(value = "需求匹配视频列表")
     @ApiOperation(value = "需求匹配视频列表")
     @PostMapping("/demand/videoContentList")
     @PostMapping("/demand/videoContentList")
+    @RateLimit(timeWindow = 60, maxRequests = 10, limitByAccount = true, message = "请求过于频繁,请稍后再试")
     public CommonResponse<Page<VideoContentItemVO>> getDemandVideoContentList(@RequestBody VideoContentListParam param) {
     public CommonResponse<Page<VideoContentItemVO>> getDemandVideoContentList(@RequestBody VideoContentListParam param) {
         return CommonResponse.success(planService.getDemandVideoContentList(param));
         return CommonResponse.success(planService.getDemandVideoContentList(param));
     }
     }

+ 4 - 5
common-module/src/main/java/com/tzld/piaoquan/growth/common/utils/IpUtil.java

@@ -58,11 +58,10 @@ public class IpUtil {
                 }
                 }
             }
             }
         }
         }
-        // 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
-        if (ipAddress != null && ipAddress.length() > 15) { // "***.***.***.***".length()
-            if (ipAddress.indexOf(",") > 0) {
-                ipAddress = ipAddress.substring(0, ipAddress.indexOf(","));
-            }
+        // 对于通过多个代理的情况,取最后一跳 IP(由受信反向代理/网关注入)
+        if (ipAddress != null && ipAddress.indexOf(",") > 0) {
+            String[] ips = ipAddress.split(",");
+            ipAddress = ips[ips.length - 1].trim();
         }
         }
         return ipAddress;
         return ipAddress;
     }
     }

+ 14 - 7
common-module/src/main/java/com/tzld/piaoquan/growth/common/utils/RedisUtils.java

@@ -6,10 +6,12 @@ import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.redis.core.RedisCallback;
 import org.springframework.data.redis.core.RedisCallback;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
 import org.springframework.data.redis.serializer.RedisSerializer;
 import org.springframework.data.redis.serializer.RedisSerializer;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.CollectionUtils;
 
 
+import java.util.Collections;
 import java.util.Date;
 import java.util.Date;
 import java.util.List;
 import java.util.List;
 import java.util.Objects;
 import java.util.Objects;
@@ -78,14 +80,19 @@ public class RedisUtils {
         }
         }
     }
     }
 
 
+    /**
+     * 原子递增并返回递增后的值,仅在 key 首次创建时设置过期时间(避免竞态导致永不过期)
+     */
+    public Long incrWithExpire(String key, long expireSeconds) {
+        String lua = "local v = redis.call('INCR', KEYS[1]) "
+                + "if v == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end "
+                + "return v";
+        DefaultRedisScript<Long> script = new DefaultRedisScript<>(lua, Long.class);
+        return redisTemplate.execute(script, Collections.singletonList(key), String.valueOf(expireSeconds));
+    }
+
     public void setIncrementValue(String key, Integer value, Long expireTime) {
     public void setIncrementValue(String key, Integer value, Long expireTime) {
-        // 只在第一次进行设置过期时间
-        if (!containsKey(key)) {
-            redisTemplate.opsForValue().increment(key, value);
-            redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
-        } else {
-            redisTemplate.opsForValue().increment(key, value);
-        }
+        incrWithExpire(key, expireTime);
     }
     }
 
 
     public void setDecrementValue(String key, double value) {
     public void setDecrementValue(String key, double value) {