gendesign/tradein-mvp/backend/app/api/v1/search.py
lekss361 7814e1223a feat(tradein): Phase 3.2 — /api/v1/search endpoint + Redis cache
Multi-Source Integration Phase 3.2. /api/v1/search with cross-source filters
+ POST body params + Redis 5min hot cache.

New files:
  app/schemas/search.py            — SearchParams (~20 filters, Pydantic v2)
  app/schemas/search_response.py   — SearchResponse + SearchResultItem
  app/services/search_query.py     — build_search_query (psycopg v3, CAST(:x AS type))
  app/services/cache.py            — SearchCache async wrapper (singleton lru_cache)
  app/api/v1/search.py             — POST /search endpoint
  tests/test_search_api.py         — unit + integration tests

Edited:
  app/main.py                      — include search.router prefix=/api/v1
  app/core/config.py               — settings.redis_url default
  pyproject.toml                   — add redis>=5.0.0

Queries listings_search_mv (matview from PR #469). Cache failures swallowed
(degrade to slow-but-correct). Sort whitelisted dict (no SQL injection).

Refs Master Plan sec 9.1 + 7.2.
2026-05-23 17:38:00 +03: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