202 lines
8.6 KiB
Python
202 lines
8.6 KiB
Python
"""Landing page stats endpoint — 5 KPI для hero-секции."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import threading
|
||
import time
|
||
from datetime import date
|
||
from typing import Annotated, Any
|
||
|
||
from fastapi import APIRouter, Depends
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import get_db
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# In-process TTL-кэш результата _query_stats (перф-батч #2, #1953-followup)
|
||
# ---------------------------------------------------------------------------
|
||
# /landing/stats бьётся на КАЖДЫЙ load хоумпейджа и НЕ кэширован. KPI 3 (доля квартир
|
||
# с ценой) делает полный агрегат по physflat-дедупу objective_lots: price_per_m2_rub
|
||
# нет в covering-INCLUDE и фильтра нет → ~6.7 s heap-скан 603k физлотов на каждый
|
||
# запрос. Данные дневной гранулярности (snapshot_date) → 1 ч staleness приемлем.
|
||
#
|
||
# Кэшируем ВЕСЬ dict (все 5 KPI), не только KPI 3 — там ещё pg_class deals + 2
|
||
# MAX(snapshot_date). Только УСПЕШНЫЙ результат (None/fallback НЕ кэшируем — пустая
|
||
# dev-БД / сбой запроса не должны залипать на час). Потокобезопасно (threading.Lock,
|
||
# удерживается только на check/store); analyze идёт в threadpool под Uvicorn. Стиль —
|
||
# зеркало app.services.weather_cache. time.monotonic устойчив к скачкам системных часов.
|
||
_STATS_TTL_S: float = 3600.0 # 1 час — дневная гранулярность данных
|
||
|
||
# (result_dict, expires_at_monotonic_seconds). Только успешный результат.
|
||
_STATS_CACHE: dict[str, Any] | None = None
|
||
_STATS_EXPIRES_AT: float = 0.0
|
||
_STATS_LOCK = threading.Lock()
|
||
|
||
|
||
def _now() -> float:
|
||
"""Монотонный таймер (тесты подменяют этот helper, а не сам `time`)."""
|
||
return time.monotonic()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Response schema
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class LandingStatsOut(BaseModel):
|
||
zk_total: int
|
||
deals_total: int
|
||
price_coverage_pct: float
|
||
mapping_coverage_pct: float
|
||
last_data_update: str
|
||
paradox: str
|
||
stale: bool = False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fallback defaults (на случай пустой dev БД или ошибки запроса)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_FALLBACK_DATA: dict[str, Any] = {
|
||
"zk_total": 0,
|
||
"deals_total": 0,
|
||
"price_coverage_pct": 0.0,
|
||
"mapping_coverage_pct": 0.0,
|
||
"last_data_update": "",
|
||
"paradox": "",
|
||
"stale": True,
|
||
}
|
||
|
||
|
||
def _query_stats(db: Session) -> dict[str, Any] | None:
|
||
"""Выполняет агрегирующие запросы к БД. Возвращает None при любой ошибке."""
|
||
try:
|
||
# KPI 1: COUNT ЖК DOM.РФ (ЕКБ)
|
||
row_zk = db.execute(
|
||
text("SELECT COUNT(*) FROM domrf_kn_objects WHERE is_ekb = TRUE")
|
||
).scalar()
|
||
zk_total = int(row_zk or 0)
|
||
|
||
# KPI 2: COUNT ДДУ-сделок (через pg_class для партицированной таблицы)
|
||
row_deals = db.execute(
|
||
text(
|
||
"SELECT COALESCE(SUM(CAST(reltuples AS bigint)), 0)"
|
||
" FROM pg_class"
|
||
" WHERE relname LIKE 'rosreestr_deals_%'"
|
||
" AND relkind = 'r'"
|
||
)
|
||
).scalar()
|
||
deals_total = int(row_deals or 0)
|
||
|
||
# KPI 3: % objective_lots с ценой
|
||
# #1964: physflat-дедуп view — доля квартир с ценой по ФИЗИЧЕСКИМ лотам, а не
|
||
# по пере-листингам (сырой objective_lots раздут ~2.91× мульти lot_id/физлот).
|
||
row_price = db.execute(
|
||
text(
|
||
"SELECT"
|
||
" COUNT(*) FILTER (WHERE price_per_m2_rub IS NOT NULL) * 100.0"
|
||
" / NULLIF(COUNT(*), 0)"
|
||
" FROM v_objective_lots_latest"
|
||
)
|
||
).scalar()
|
||
price_coverage_pct = round(float(row_price or 0.0), 1)
|
||
|
||
# KPI 4: % mapping (objective_complex_mapping / domrf_kn_objects ekb)
|
||
row_mapping = db.execute(
|
||
text(
|
||
"SELECT"
|
||
" (SELECT COUNT(*) FROM objective_complex_mapping) * 100.0"
|
||
" / NULLIF("
|
||
" (SELECT COUNT(*) FROM domrf_kn_objects WHERE is_ekb = TRUE),"
|
||
" 0"
|
||
" )"
|
||
)
|
||
).scalar()
|
||
mapping_coverage_pct = round(float(row_mapping or 0.0), 1)
|
||
|
||
# KPI 5: дата последнего обновления (MAX snapshot_date из objective_lots + domrf)
|
||
row_date_obj = db.execute(text("SELECT MAX(snapshot_date) FROM objective_lots")).scalar()
|
||
row_date_domrf = db.execute(
|
||
text("SELECT MAX(snapshot_date) FROM domrf_kn_objects")
|
||
).scalar()
|
||
|
||
dates = [d for d in (row_date_obj, row_date_domrf) if d is not None]
|
||
last_data_update: str
|
||
if dates:
|
||
last_data_update = str(max(dates))
|
||
else:
|
||
last_data_update = str(date.today())
|
||
|
||
# Если все KPI нули — скорее всего пустая БД → stale
|
||
if zk_total == 0 and deals_total == 0:
|
||
logger.warning("landing/stats: all KPIs are zero — returning stale=True")
|
||
return None
|
||
|
||
return {
|
||
"zk_total": zk_total,
|
||
"deals_total": deals_total,
|
||
"price_coverage_pct": price_coverage_pct,
|
||
"mapping_coverage_pct": mapping_coverage_pct,
|
||
"last_data_update": last_data_update,
|
||
"paradox": (
|
||
f"из {zk_total} ЖК у {price_coverage_pct}% есть цены"
|
||
" — но в публичном доступе только 0.3%"
|
||
),
|
||
"stale": False,
|
||
}
|
||
except Exception:
|
||
logger.exception("landing/stats: DB query failed, returning stale=True")
|
||
return None
|
||
|
||
|
||
def _query_stats_cached(db: Session) -> dict[str, Any] | None:
|
||
"""`_query_stats` с in-process TTL-кэшем (TTL=1 ч). См. блок-комментарий вверху.
|
||
|
||
Кэширует ТОЛЬКО успешный dict; None (сбой/пустая БД) не кэшируется — следующий
|
||
запрос попробует снова. Lock удерживается лишь на check/store; сам DB-запрос идёт
|
||
ВНЕ lock'а (не сериализуем одновременные cold-start, last-write wins — запрос
|
||
идемпотентен, TTL длинный).
|
||
"""
|
||
global _STATS_CACHE, _STATS_EXPIRES_AT
|
||
now = _now()
|
||
with _STATS_LOCK:
|
||
if _STATS_CACHE is not None and _STATS_EXPIRES_AT > now:
|
||
return _STATS_CACHE
|
||
# MISS / истёк → DB-запрос ВНЕ lock'а.
|
||
result = _query_stats(db)
|
||
if result is None:
|
||
# Не кэшируем fallback — пусть следующий запрос повторит попытку.
|
||
return None
|
||
with _STATS_LOCK:
|
||
_STATS_CACHE = result
|
||
_STATS_EXPIRES_AT = _now() + _STATS_TTL_S
|
||
return result
|
||
|
||
|
||
@router.get("/landing/stats", response_model=LandingStatsOut)
|
||
def landing_stats(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> LandingStatsOut:
|
||
"""5 KPI для hero-секции лендинга.
|
||
|
||
Поля ответа:
|
||
- zk_total: количество ЖК DOM.РФ в ЕКБ
|
||
- deals_total: суммарное кол-во ДДУ-сделок в индексе Росреестра
|
||
- price_coverage_pct: % объектов objective_lots с ценой
|
||
- mapping_coverage_pct: % маппинга objective → domrf_kn_objects (ЕКБ)
|
||
- last_data_update: дата последнего обновления данных (YYYY-MM-DD)
|
||
- paradox: строка-парадокс портфеля для hero CTA
|
||
- stale: true если данные недоступны (DB ошибка или пустая БД)
|
||
"""
|
||
result = _query_stats_cached(db)
|
||
if result is None:
|
||
return LandingStatsOut(**_FALLBACK_DATA)
|
||
return LandingStatsOut(**result)
|