From 63c96628e3b18c08a165ff1a76dd7871955dd917 Mon Sep 17 00:00:00 2001 From: TradeIn Deploy Date: Thu, 21 May 2026 20:12:49 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BD?= =?UTF-8?q?=D0=BE=D0=B7=20=D1=81=D1=80=D0=BE=D0=BA=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=D0=B6=D0=B8=20+=20rate-limit=20(#9,#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/core/ratelimit.py | 68 +++++++++++++++++++ tradein-mvp/backend/app/main.py | 3 + tradein-mvp/backend/app/schemas/trade_in.py | 1 + tradein-mvp/backend/app/services/estimator.py | 20 ++++++ 4 files changed, 92 insertions(+) create mode 100644 tradein-mvp/backend/app/core/ratelimit.py diff --git a/tradein-mvp/backend/app/core/ratelimit.py b/tradein-mvp/backend/app/core/ratelimit.py new file mode 100644 index 00000000..9ab432b4 --- /dev/null +++ b/tradein-mvp/backend/app/core/ratelimit.py @@ -0,0 +1,68 @@ +"""Простой in-memory rate-limiter для публичного API. + +Per-IP sliding window. Достаточно для одного backend-инстанса (MVP). +Защищает от абуза `/api/v1/*` — скрейп-эндпоинты уже за admin-токеном, +но estimate/geocode/suggest публичны. + +Лимиты намеренно щедрые — обычный пользователь их не достигнет, режутся боты. +""" + +from __future__ import annotations + +import time +from collections import defaultdict, deque + +from fastapi import Request +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +# Окно и лимит: не более RATE_LIMIT запросов за RATE_WINDOW секунд с одного IP. +RATE_WINDOW = 60.0 +RATE_LIMIT = 90 + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """Sliding-window rate limit на /api/v1/*. Health и статика — без лимита.""" + + def __init__(self, app) -> None: # type: ignore[no-untyped-def] + super().__init__(app) + self._hits: dict[str, deque[float]] = defaultdict(deque) + + async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] + path = request.url.path + # Лимитируем только API; health и прочее — пропускаем. + if not path.startswith("/api/"): + return await call_next(request) + + ip = _client_ip(request) + now = time.monotonic() + bucket = self._hits[ip] + + # Выкидываем устаревшие отметки за пределами окна. + cutoff = now - RATE_WINDOW + while bucket and bucket[0] < cutoff: + bucket.popleft() + + if len(bucket) >= RATE_LIMIT: + retry = int(RATE_WINDOW - (now - bucket[0])) + 1 + return JSONResponse( + status_code=429, + content={"detail": "Слишком много запросов. Попробуйте позже."}, + headers={"Retry-After": str(retry)}, + ) + + bucket.append(now) + # Лёгкая защита от утечки памяти — чистим пустые корзины изредка. + if len(self._hits) > 10000: + for k in [k for k, v in self._hits.items() if not v]: + del self._hits[k] + + return await call_next(request) + + +def _client_ip(request: Request) -> str: + """Реальный IP за реверс-прокси (Caddy/nginx ставят X-Forwarded-For).""" + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else "unknown" diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index c886cff8..da2d590b 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -13,6 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.v1 import admin, brand, geocode, trade_in from app.core.config import settings +from app.core.ratelimit import RateLimitMiddleware logging.basicConfig( level=logging.INFO, @@ -32,6 +33,8 @@ app.add_middleware( allow_methods=["*"], allow_headers=["*"], ) +# Rate-limit публичного API (per-IP sliding window) — защита от абуза. +app.add_middleware(RateLimitMiddleware) @app.get("/health") diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 4aeebd88..088bad27 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -60,3 +60,4 @@ class AggregatedEstimate(BaseModel): target_lon: float | None = None sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr'] data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг + est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index ed7ba17e..e5aaecb1 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -266,9 +266,29 @@ async def estimate_quality( target_lon=geo.lon, sources_used=sources_used, data_freshness_minutes=freshness_min, + est_days_on_market=_estimate_days_on_market(listings_clean, deals), ) +def _estimate_days_on_market( + listings: list[dict[str, Any]], deals: list[dict[str, Any]] +) -> int | None: + """Прогноз срока продажи — медиана days_on_market по аналогам/сделкам. + + Возвращает None если ни у одного аналога нет данных о сроке экспозиции + (наши парсеры не всегда его отдают — честно показываем «нет данных»). + """ + values = [ + int(lot["days_on_market"]) + for lot in (*listings, *deals) + if lot.get("days_on_market") and int(lot["days_on_market"]) > 0 + ] + if len(values) < 3: + return None + values.sort() + return values[len(values) // 2] + + def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None: """Минут с последнего парсинга — для UI «обновлено N мин назад».""" if not lots: -- 2.45.3