gendesign/backend/app/api/v1/landing.py
bot-backend c8e7cb1c79
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 2m8s
Deploy / build-worker (push) Successful in 3m16s
Deploy / deploy (push) Successful in 5m41s
perf(objective_lots): inline DISTINCT ON for 3 view-fullscan consumers + cache landing stats (#1953) (#2067)
2026-06-28 17:25:41 +00:00

202 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)