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.api.v1 import admin, brand, geocode, trade_in
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.ratelimit import RateLimitMiddleware
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|
@ -32,6 +33,8 @@ app.add_middleware(
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
# Rate-limit публичного API (per-IP sliding window) — защита от абуза.
|
||||||
|
app.add_middleware(RateLimitMiddleware)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
|
|
@ -60,3 +60,4 @@ class AggregatedEstimate(BaseModel):
|
||||||
target_lon: float | None = None
|
target_lon: float | None = None
|
||||||
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
|
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
|
||||||
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
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,
|
target_lon=geo.lon,
|
||||||
sources_used=sources_used,
|
sources_used=sources_used,
|
||||||
data_freshness_minutes=freshness_min,
|
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:
|
def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None:
|
||||||
"""Минут с последнего парсинга — для UI «обновлено N мин назад»."""
|
"""Минут с последнего парсинга — для UI «обновлено N мин назад»."""
|
||||||
if not lots:
|
if not lots:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue