gendesign/tradein-mvp/backend/app/api/v1/search.py
lekss361 9643979d2c
All checks were successful
Deploy Trade-In / deploy (push) Successful in 26s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 56s
feat(tradein): Phase 3.2 — /api/v1/search endpoint + Redis cache (#479)
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.
2026-05-23 14:44:22 +00:00

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