feat(stats): #337 봇 UA 필터 + IP 레이트리밋
- BotDetector 유틸 (Pattern.CASE_INSENSITIVE: bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse) - RateLimitService: Redis SET NX EX(60s) 패턴으로 같은 IP 윈도우 차단 - Bucket4j 대신 spring-data-redis 기존 의존성 재사용 (간결) - Redis 다운 시 fail-open (사용자 경험 우선) - StatsController.recordVisit: HttpServletRequest 받아 UA + X-Forwarded-For 우선 IP - 봇/리밋 초과 → 200 + counted:false (사용자 페이지 로드 지장 X) - 통과 → 200 + counted:true → statsService.recordVisit() - application.yml: app.rate-limit.visit-window-seconds (env VISIT_WINDOW_SECONDS) 기본 60 - dev 검증: 봇 UA → counted=false, Mozilla → true, 즉시 재호출 → false 설계서: docs/design/337-stats-bot-ratelimit/README.md Refs: #337 (Developer 단계)
This commit is contained in:
@@ -32,6 +32,8 @@ dependencies {
|
||||
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0'
|
||||
implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.16.0'
|
||||
|
||||
// #337 — IP 레이트리밋은 Redis SET NX EX 패턴으로 자체 구현 (기존 spring-data-redis 활용)
|
||||
|
||||
// Oracle JDBC + Security (Wallet support for Oracle ADB)
|
||||
implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01'
|
||||
implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01'
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.SiteVisitStats;
|
||||
import com.tasteby.service.RateLimitService;
|
||||
import com.tasteby.service.StatsService;
|
||||
import com.tasteby.util.BotDetector;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -10,20 +15,51 @@ import java.util.Map;
|
||||
@RequestMapping("/api/stats")
|
||||
public class StatsController {
|
||||
|
||||
private final StatsService statsService;
|
||||
private static final Logger log = LoggerFactory.getLogger(StatsController.class);
|
||||
|
||||
public StatsController(StatsService statsService) {
|
||||
private final StatsService statsService;
|
||||
private final RateLimitService rateLimitService;
|
||||
|
||||
public StatsController(StatsService statsService, RateLimitService rateLimitService) {
|
||||
this.statsService = statsService;
|
||||
this.rateLimitService = rateLimitService;
|
||||
}
|
||||
|
||||
@PostMapping("/visit")
|
||||
public Map<String, Object> recordVisit() {
|
||||
public Map<String, Object> recordVisit(HttpServletRequest req) {
|
||||
// #337 — 봇 UA + IP 레이트리밋. 모두 통과해야 카운트 진행.
|
||||
String ua = req.getHeader("User-Agent");
|
||||
if (BotDetector.isBot(ua)) {
|
||||
log.debug("visit skipped (bot): {}", ua);
|
||||
return Map.of("ok", true, "counted", false);
|
||||
}
|
||||
|
||||
String clientIp = resolveClientIp(req);
|
||||
if (!rateLimitService.tryConsume(clientIp)) {
|
||||
log.debug("visit skipped (rate-limit): {}", clientIp);
|
||||
return Map.of("ok", true, "counted", false);
|
||||
}
|
||||
|
||||
statsService.recordVisit();
|
||||
return Map.of("ok", true);
|
||||
return Map.of("ok", true, "counted", true);
|
||||
}
|
||||
|
||||
@GetMapping("/visits")
|
||||
public SiteVisitStats getVisits() {
|
||||
return statsService.getVisits();
|
||||
}
|
||||
|
||||
/**
|
||||
* #337 — X-Forwarded-For 우선 (Nginx Ingress 뒤). chain이면 첫 번째(원본).
|
||||
* 없으면 RemoteAddr 폴백.
|
||||
*/
|
||||
private static String resolveClientIp(HttpServletRequest req) {
|
||||
String fwd = req.getHeader("X-Forwarded-For");
|
||||
if (fwd != null && !fwd.isBlank()) {
|
||||
int comma = fwd.indexOf(',');
|
||||
return (comma > 0 ? fwd.substring(0, comma) : fwd).trim();
|
||||
}
|
||||
String addr = req.getRemoteAddr();
|
||||
return addr != null ? addr : "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* #337 — IP 기반 레이트리밋 (방문 카운트 어뷰즈 차단).
|
||||
*
|
||||
* 단순 Redis SETIFABSENT(SET NX EX) 패턴:
|
||||
* - 첫 호출 시 키 등록 + TTL → 허용
|
||||
* - TTL 동안 다음 호출은 키 존재로 차단
|
||||
*
|
||||
* Redis 다운 시 fail-open (true 반환) — 사용자 페이지 로드 우선.
|
||||
* 멀티 파드 + Redis 단일 인스턴스 환경에서 자연스럽게 동작.
|
||||
*/
|
||||
@Service
|
||||
public class RateLimitService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RateLimitService.class);
|
||||
private static final String PREFIX = "ratelimit:visit:";
|
||||
|
||||
private final StringRedisTemplate redis;
|
||||
|
||||
@Value("${app.rate-limit.visit-window-seconds:60}")
|
||||
private long visitWindowSeconds;
|
||||
|
||||
public RateLimitService(StringRedisTemplate redis) {
|
||||
this.redis = redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 IP의 visit 호출 허용 여부.
|
||||
* @return true = 허용 (첫 호출 또는 윈도우 만료), false = 차단 (윈도우 안 재호출)
|
||||
*/
|
||||
public boolean tryConsume(String ipKey) {
|
||||
try {
|
||||
String key = PREFIX + ipKey;
|
||||
Boolean ok = redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(visitWindowSeconds));
|
||||
return Boolean.TRUE.equals(ok);
|
||||
} catch (Exception e) {
|
||||
// fail-open: Redis 문제로 통계가 약간 부풀어도 사용자 영향 X
|
||||
log.debug("RateLimit error (fail-open): {}", e.getMessage());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
backend-java/src/main/java/com/tasteby/util/BotDetector.java
Normal file
25
backend-java/src/main/java/com/tasteby/util/BotDetector.java
Normal file
@@ -0,0 +1,25 @@
|
||||
package com.tasteby.util;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* #337 — User-Agent 기반 봇 패턴 매칭.
|
||||
*
|
||||
* Googlebot / bingbot / facebookexternalhit / 일반 crawler/spider 등을 일괄 차단.
|
||||
* 빈 UA는 봇으로 간주하지 않음(모바일 앱 등 정상 케이스 보호).
|
||||
*/
|
||||
public final class BotDetector {
|
||||
|
||||
private BotDetector() {}
|
||||
|
||||
// 일반적인 봇/크롤러 패턴. 케이스 무시.
|
||||
private static final Pattern BOT_PATTERN = Pattern.compile(
|
||||
"bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
|
||||
public static boolean isBot(String userAgent) {
|
||||
if (userAgent == null || userAgent.isBlank()) return false;
|
||||
return BOT_PATTERN.matcher(userAgent).find();
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,10 @@ app:
|
||||
# 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값.
|
||||
max-distance: ${SEARCH_MAX_DISTANCE:0.57}
|
||||
|
||||
rate-limit:
|
||||
# #337 — 같은 IP에서 visit 카운트 허용 간격(초). 기본 60.
|
||||
visit-window-seconds: ${VISIT_WINDOW_SECONDS:60}
|
||||
|
||||
build:
|
||||
# #338 — 배포 시 deploy.sh가 env로 주입. dev에서는 dev/unknown.
|
||||
version: ${APP_VERSION:dev}
|
||||
|
||||
Reference in New Issue
Block a user