浏览代码

限流调整

liuchengsen 3 周之前
父节点
当前提交
cd09720ebe

+ 2 - 0
backend-java/src/main/java/com/pharmacopoeia/config/AsyncConfig.java

@@ -3,6 +3,7 @@ package com.pharmacopoeia.config;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
 import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
 
 import java.util.concurrent.Executor;
@@ -10,6 +11,7 @@ import java.util.concurrent.ThreadPoolExecutor;
 
 @Configuration
 @EnableAsync
+@EnableScheduling
 public class AsyncConfig {
 
     @Bean("analyticsExecutor")

+ 64 - 15
backend-java/src/main/java/com/pharmacopoeia/security/RateLimitFilter.java

@@ -8,58 +8,107 @@ import com.pharmacopoeia.util.IpUtils;
 import jakarta.servlet.ServletException;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 import org.springframework.web.filter.OncePerRequestFilter;
 
 import java.io.IOException;
+import java.util.Iterator;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.atomic.AtomicInteger;
 
 /**
- * 用户侧限流:仅限 chat POST 接口,每 IP 每分钟限制次数。
- * 超限返回 429,不会进入 Controller 层,因此不会写缓存和 DB。
+ * 用户侧限流:仅 chat POST 接口,按 IP 固定窗口(60s)计数。
+ * 窗口内累加;超过 perMinute 继续拦;窗口时间过去后计数归零、自动恢复。
+ * 超限返回 429,不进入 Controller 层,不写缓存和 DB。
  */
 @Component
 public class RateLimitFilter extends OncePerRequestFilter {
 
+    private static final Logger log = LoggerFactory.getLogger(RateLimitFilter.class);
+    private static final long WINDOW_MS = 60_000L;
+
     private final RateLimitProperties props;
-    private final Map<String, AtomicInteger> minuteCounter = new ConcurrentHashMap<>();
+    private final Map<String, Window> buckets = new ConcurrentHashMap<>();
 
     public RateLimitFilter(RateLimitProperties props) {
         this.props = props;
     }
 
+    /** 每 IP 一个窗口:起始时间 + 计数。 */
+    private static final class Window {
+        volatile long start;
+        volatile int count;
+        Window(long start) { this.start = start; }
+    }
+
     @Override
     protected void doFilterInternal(@NotNull HttpServletRequest request,
                                     @NotNull HttpServletResponse response,
-                                    @NotNull FilterChain filterChain) throws ServletException, IOException {
+                                    FilterChain filterChain) throws ServletException, IOException {
         String ip = IpUtils.getClientIp(request);
         MDC.put("ip", ip);
         try {
             boolean isChatApi = request.getRequestURI().contains("/chat/")
                     && "POST".equalsIgnoreCase(request.getMethod());
-            if (isChatApi) {
-                var ipCount = minuteCounter.computeIfAbsent(ip, k -> new AtomicInteger(0));
-                int count = ipCount.incrementAndGet();
-                if (count > props.getPerMinute()) {
-                    ipCount.decrementAndGet(); // 超限不计
-                    writeRateLimitError(response);
-                    return;
-                }
+            if (isChatApi && !allow(ip)) {
+                writeRateLimitError(response);
+                return;
             }
-
             filterChain.doFilter(request, response);
         } finally {
             MDC.clear();
         }
     }
 
+    private boolean allow(String ip) {
+        int perMinute = props.getPerMinute();
+        long now = System.currentTimeMillis();
+        Window w = buckets.computeIfAbsent(ip, k -> new Window(now));
+        synchronized (w) {
+            if (now - w.start >= WINDOW_MS) {
+                // 新窗口:归零计数、刷新起始时间 → 自动恢复
+                w.start = now;
+                w.count = 1;
+            } else {
+                w.count++;
+            }
+            if (w.count > perMinute) {
+                w.count--; // 超限不再累加,避免计数无限上涨;窗口结束前继续拦
+                return false;
+            }
+            return true;
+        }
+    }
+
+    /** 每 2 分钟清理一次无活动 IP 的桶,防 map 无限增长 */
+    @Scheduled(fixedRate = 120_000)
+    public void cleanupStaleBuckets() {
+        long now = System.currentTimeMillis();
+        int removed = 0;
+        Iterator<Map.Entry<String, Window>> it = buckets.entrySet().iterator();
+        while (it.hasNext()) {
+            Map.Entry<String, Window> e = it.next();
+            Window w = e.getValue();
+            synchronized (w) {
+                if (now - w.start >= WINDOW_MS * 2) {
+                    it.remove();
+                    removed++;
+                }
+            }
+        }
+        if (removed > 0) {
+            log.debug("[rateLimit] 清理 {} 个过期 IP 窗口", removed);
+        }
+    }
+
     private static void writeRateLimitError(HttpServletResponse response) throws IOException {
         response.setStatus(429);
         response.setContentType("application/json;charset=UTF-8");
         response.getWriter().write(
-                "{\"error\":\"" + "请求过于频繁,请稍后再试" + "\",\"detail\":\"" + "请求过于频繁,请稍后再试" + "\",\"code\":429}"
+                "{\"error\":\"请求过于频繁,请稍后再试\",\"detail\":\"请求过于频繁,请稍后再试\",\"code\":429}"
         );
     }
 }

+ 3 - 3
backend-java/src/main/resources/application.yml

@@ -126,9 +126,9 @@ dashscope:
 # 限流
 # ============================================
 rate-limit:
-  per-minute: 60      # 每 IP 每分钟最多 60 次 chat 请求
-  per-hour: 999999    # 极大=不限
-  per-day: 999999     # 极大=不限
+  per-minute: 60      # 每 IP 每分钟最多 120 次 chat 请求(固定窗口,窗口结束自动恢复)
+  per-hour: 999999    # 极大=不限(预留,当前未启用)
+  per-day: 999999     # 极大=不限(预留,当前未启用)
 
 logging:
   level: