POST /api/v1/search reading listings_search_mv with ~20 cross-source filters, parameterized SQL (CAST(:x AS type), psycopg v3), whitelisted ORDER BY (Pydantic Literal), Redis 5min hot cache with graceful degradation (singleton pool via lru_cache). Verified vs data/sql/050_search_optimization.sql: matview column refs (total_area, lng, house_rating, sources[], has_avito/cian/yandex), SQL injection safety, cache failure swallow, router prefix. Deep-code-reviewer: APPROVE.
64 lines
2 KiB
Python
64 lines
2 KiB
Python
"""Search endpoint — POST /api/v1/search (Phase 3.2, master plan sec 9.1)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.db import get_db
|
|
from app.schemas.search import SearchParams
|
|
from app.schemas.search_response import SearchResponse, SearchResultItem
|
|
from app.services.cache import get_search_cache
|
|
from app.services.search_query import build_count_query, build_search_query
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.post("/search", response_model=SearchResponse)
|
|
async def search(
|
|
params: SearchParams,
|
|
db: Annotated[Session, Depends(get_db)],
|
|
) -> SearchResponse:
|
|
"""Поиск listings по фильтрам (cross-source matview + Redis 5min cache)."""
|
|
started = time.monotonic()
|
|
cache = get_search_cache()
|
|
cache_key = cache.search_key(params.model_dump(mode="json"))
|
|
|
|
cached = await cache.get(cache_key)
|
|
if cached is not None:
|
|
logger.info(
|
|
"search cache HIT key=%s page=%d size=%d",
|
|
cache_key[:24], params.page, params.page_size,
|
|
)
|
|
return SearchResponse.model_validate(cached)
|
|
|
|
sql, args = build_search_query(params)
|
|
rows = db.execute(text(sql), args).mappings().all()
|
|
|
|
count_sql, count_args = build_count_query(params)
|
|
total = int(db.execute(text(count_sql), count_args).scalar() or 0)
|
|
|
|
items = [SearchResultItem.model_validate(dict(r)) for r in rows]
|
|
elapsed_ms = (time.monotonic() - started) * 1000.0
|
|
response = SearchResponse(
|
|
items=items,
|
|
total=total,
|
|
page=params.page,
|
|
page_size=params.page_size,
|
|
elapsed_ms=round(elapsed_ms, 1),
|
|
cache_hit=False,
|
|
)
|
|
|
|
await cache.set(cache_key, response.model_dump(mode="json"), ttl=cache.TTL_SEARCH)
|
|
logger.info(
|
|
"search MISS page=%d size=%d total=%d items=%d elapsed=%.1fms",
|
|
params.page, params.page_size, total, len(items), elapsed_ms,
|
|
)
|
|
return response
|