feat(tradein): прогноз срока продажи + rate-limit (#9,#16) #387
4 changed files with 92 additions and 0 deletions
68
tradein-mvp/backend/app/core/ratelimit.py
Normal file
68
tradein-mvp/backend/app/core/ratelimit.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 # прогноз срока продажи (медиана по аналогам)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue