|
@@ -8,58 +8,107 @@ import com.pharmacopoeia.util.IpUtils;
|
|
|
import jakarta.servlet.ServletException;
|
|
import jakarta.servlet.ServletException;
|
|
|
import jakarta.servlet.http.HttpServletRequest;
|
|
import jakarta.servlet.http.HttpServletRequest;
|
|
|
import jakarta.servlet.http.HttpServletResponse;
|
|
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.stereotype.Component;
|
|
|
import org.springframework.web.filter.OncePerRequestFilter;
|
|
import org.springframework.web.filter.OncePerRequestFilter;
|
|
|
|
|
|
|
|
import java.io.IOException;
|
|
import java.io.IOException;
|
|
|
|
|
+import java.util.Iterator;
|
|
|
import java.util.Map;
|
|
import java.util.Map;
|
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
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
|
|
@Component
|
|
|
public class RateLimitFilter extends OncePerRequestFilter {
|
|
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 RateLimitProperties props;
|
|
|
- private final Map<String, AtomicInteger> minuteCounter = new ConcurrentHashMap<>();
|
|
|
|
|
|
|
+ private final Map<String, Window> buckets = new ConcurrentHashMap<>();
|
|
|
|
|
|
|
|
public RateLimitFilter(RateLimitProperties props) {
|
|
public RateLimitFilter(RateLimitProperties props) {
|
|
|
this.props = props;
|
|
this.props = props;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 每 IP 一个窗口:起始时间 + 计数。 */
|
|
|
|
|
+ private static final class Window {
|
|
|
|
|
+ volatile long start;
|
|
|
|
|
+ volatile int count;
|
|
|
|
|
+ Window(long start) { this.start = start; }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@Override
|
|
@Override
|
|
|
protected void doFilterInternal(@NotNull HttpServletRequest request,
|
|
protected void doFilterInternal(@NotNull HttpServletRequest request,
|
|
|
@NotNull HttpServletResponse response,
|
|
@NotNull HttpServletResponse response,
|
|
|
- @NotNull FilterChain filterChain) throws ServletException, IOException {
|
|
|
|
|
|
|
+ FilterChain filterChain) throws ServletException, IOException {
|
|
|
String ip = IpUtils.getClientIp(request);
|
|
String ip = IpUtils.getClientIp(request);
|
|
|
MDC.put("ip", ip);
|
|
MDC.put("ip", ip);
|
|
|
try {
|
|
try {
|
|
|
boolean isChatApi = request.getRequestURI().contains("/chat/")
|
|
boolean isChatApi = request.getRequestURI().contains("/chat/")
|
|
|
&& "POST".equalsIgnoreCase(request.getMethod());
|
|
&& "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);
|
|
filterChain.doFilter(request, response);
|
|
|
} finally {
|
|
} finally {
|
|
|
MDC.clear();
|
|
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 {
|
|
private static void writeRateLimitError(HttpServletResponse response) throws IOException {
|
|
|
response.setStatus(429);
|
|
response.setStatus(429);
|
|
|
response.setContentType("application/json;charset=UTF-8");
|
|
response.setContentType("application/json;charset=UTF-8");
|
|
|
response.getWriter().write(
|
|
response.getWriter().write(
|
|
|
- "{\"error\":\"" + "请求过于频繁,请稍后再试" + "\",\"detail\":\"" + "请求过于频繁,请稍后再试" + "\",\"code\":429}"
|
|
|
|
|
|
|
+ "{\"error\":\"请求过于频繁,请稍后再试\",\"detail\":\"请求过于频繁,请稍后再试\",\"code\":429}"
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|