Ver código fonte

Merge branch '20260714-wyp-contentPlatformAccountAuth' of Server/growth-manager into master

wangyunpeng 14 horas atrás
pai
commit
3fd6e487b5

+ 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;
 
 /**
- * IP 限流注解
+ * 限流注解,支持按 IP 或按账号维度
  */
 @Target(ElementType.METHOD)
 @Retention(RetentionPolicy.RUNTIME)
 public @interface RateLimit {
-    
+
     /**
      * 限流时间窗口,单位秒
      */
     long timeWindow() default 60;
-    
+
     /**
      * 时间窗口内最大请求数
      */
     long maxRequests() default 10;
-    
+
     /**
      * 限流提示信息
      */
     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.common.enums.ExceptionEnum;
 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.RedisUtils;
 import org.aspectj.lang.ProceedingJoinPoint;
@@ -23,66 +25,77 @@ import java.lang.reflect.Method;
 import java.util.Objects;
 
 /**
- * IP 限流 AOP 切面
+ * 限流 AOP 切面,支持按账号或 IP 维度
  */
 @Aspect
 @Component
 public class RateLimitAop {
-    
+
     private static final Logger log = LoggerFactory.getLogger(RateLimitAop.class);
-    
+
     @Autowired
     private RedisUtils redisUtils;
-    
+
     @Pointcut("@annotation(com.tzld.piaoquan.api.annotation.RateLimit)")
     public void rateLimit() {
     }
-    
-    /**
-     * 环绕通知,执行限流逻辑
-     */
+
     @Around("rateLimit()")
     public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
         MethodSignature signature = (MethodSignature) joinPoint.getSignature();
         Method method = signature.getMethod();
-        
-        // 获取注解信息
+
         RateLimit rateLimit = method.getAnnotation(RateLimit.class);
         long timeWindow = rateLimit.timeWindow();
         long maxRequests = rateLimit.maxRequests();
         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();
         }
-        
-        // 构建 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);
         }
-        
-        // 增加请求计数
-        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();
     }
 }

+ 1 - 0
api-module/src/main/java/com/tzld/piaoquan/api/common/enums/ExceptionEnum.java

@@ -25,6 +25,7 @@ public enum ExceptionEnum {
     ACCOUNT_BANNED(1007, "账号被封禁"),
     USER_NOT_EXIST(1008, "user not exist"),
     SmsCodeExpired(1010, "短信验证码无效"),
+    DOMAIN_NOT_ALLOWED(1009, "无权访问"),
     ACCOUNT_NOT_EXISTS_WRONG(1011, "用户不存在"),
     ACCOUNT_PRICE_ERROR(1012, "定价格式错误"),
     ACCOUNT_PRICE_NULL_ERROR(1013, "定价不能为空"),

+ 29 - 0
api-module/src/main/java/com/tzld/piaoquan/api/config/JwtInterceptor.java

@@ -4,6 +4,7 @@ import com.alibaba.fastjson.JSON;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.tzld.piaoquan.api.annotation.JwtIgnore;
 import com.tzld.piaoquan.api.common.enums.ExceptionEnum;
+import com.tzld.piaoquan.api.common.enums.contentplatform.ContentPlatformAccountTypeEnum;
 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;
@@ -40,6 +41,11 @@ public class JwtInterceptor implements HandlerInterceptor {
     private Set<String> excludePaths;
     private Set<String> excludeUris;
 
+    /**
+     * 内部管理平台调用时携带的鉴权 Key,用于绕过 JWT 校验
+     */
+    private String internalKey;
+
     @Autowired
     private RedisUtils redisUtils;
 
@@ -77,6 +83,13 @@ public class JwtInterceptor implements HandlerInterceptor {
             //放行excludePaths包含的目录路径和excludeUris包含的URI
             return true;
         }
+        // 管理平台内部调用:通过 X-Internal-Key 鉴权,绕过 JWT 校验
+        if (StringUtils.isNotBlank(internalKey) && uri.startsWith("/contentPlatform/account/")) {
+            String requestInternalKey = request.getHeader("X-Internal-Key");
+            if (internalKey.equals(requestInternalKey)) {
+                return Boolean.TRUE;
+            }
+        }
         final String authHeader = request.getHeader("token");
         if (StringUtils.isBlank(authHeader)) {
             throw new CommonException(ExceptionEnum.Not_LOGIN);
@@ -84,6 +97,14 @@ public class JwtInterceptor implements HandlerInterceptor {
 
         validToken(authHeader);
 
+        // account 管理接口仅允许内部账号访问
+        if (uri.startsWith("/contentPlatform/account/")) {
+            ContentPlatformAccount loginUser = LoginUserContext.getUser();
+            if (loginUser == null || loginUser.getType() != ContentPlatformAccountTypeEnum.INTERNAL.getVal()) {
+                throw new CommonException(ExceptionEnum.Not_LOGIN);
+            }
+        }
+
         return Boolean.TRUE;
     }
 
@@ -140,6 +161,14 @@ public class JwtInterceptor implements HandlerInterceptor {
         this.excludeUris = excludeUris;
     }
 
+    public String getInternalKey() {
+        return internalKey;
+    }
+
+    public void setInternalKey(String internalKey) {
+        this.internalKey = internalKey;
+    }
+
     private void renewalToken(String token) {
         executor.execute(() -> {
             try {

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

@@ -1,6 +1,7 @@
 package com.tzld.piaoquan.api.controller.contentplatform;
 
 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.AccountListParam;
 import com.tzld.piaoquan.api.model.param.contentplatform.AccountLoginParam;
@@ -25,6 +26,7 @@ public class ContentPlatformAccountController {
     @ApiOperation(value = "手机号登录")
     @PostMapping("/login")
     @JwtIgnore
+    @RateLimit(timeWindow = 1800, maxRequests = 5, message = "请求过于频繁,请稍后再试")
     public CommonResponse<AccountLoginVO> login(@RequestBody AccountLoginParam param) {
         return CommonResponse.success(accountService.login(param));
     }
@@ -40,14 +42,12 @@ public class ContentPlatformAccountController {
 
     @ApiOperation(value = "分页获取账户")
     @PostMapping(value = "/list")
-    @JwtIgnore
     public CommonResponse<Page<AccountVO>> pageAccount(@RequestBody AccountListParam param) {
         return CommonResponse.success(accountService.pageAccount(param));
     }
 
     @ApiOperation(value = "账户封禁")
     @PostMapping(value = "/forbidden")
-    @JwtIgnore
     public CommonResponse<Void> accountForbidden(@RequestBody AccountForbiddenParam param) {
         accountService.accountForbidden(param);
         return CommonResponse.success();
@@ -55,7 +55,6 @@ public class ContentPlatformAccountController {
 
     @ApiOperation(value = "账户创建/更新")
     @PostMapping(value = "/save")
-    @JwtIgnore
     public CommonResponse<Void> saveAccount(@RequestBody AccountSaveParam param) {
         accountService.saveAccount(param);
         return CommonResponse.success();

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

+ 2 - 3
api-module/src/main/resources/application.properties

@@ -64,9 +64,8 @@ author.interceptor.excludePaths[3]=/account/
 author.interceptor.excludePaths[4]=/cgi/
 author.interceptor.excludePaths[5]=/wecom/
 author.interceptor.excludePaths[6]=/3rdParty/
-author.interceptor.excludePaths[7]=/contentPlatform/account/
-author.interceptor.excludePaths[8]=/webjars/
-author.interceptor.excludePaths[9]=/swagger-ui.html/
+author.interceptor.excludePaths[7]=/webjars/
+author.interceptor.excludePaths[8]=/swagger-ui.html/
 author.interceptor.excludeUris[0]=/login
 author.interceptor.excludeUris[1]=/api-docs
 author.interceptor.excludeUris[2]=/swagger-ui.html

+ 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;
     }

+ 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.data.redis.core.RedisCallback;
 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.stereotype.Service;
 import org.springframework.util.CollectionUtils;
 
+import java.util.Collections;
 import java.util.Date;
 import java.util.List;
 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) {
-        // 只在第一次进行设置过期时间
-        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) {