All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
84 lines
2.6 KiB
Python
84 lines
2.6 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()
|
||
|
||
|
||
def _map_row(row: object) -> dict[str, object]:
|
||
"""Маппит строку matview в поля SearchResultItem.
|
||
|
||
SQL отдаёт колонку `cadastral_number`, а в схеме поле зовётся
|
||
`kadastr_num` — без явного маппинга значение молча теряется (#1514).
|
||
"""
|
||
data = dict(row)
|
||
if "cadastral_number" in data:
|
||
data["kadastr_num"] = data.pop("cadastral_number")
|
||
return data
|
||
|
||
|
||
@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,
|
||
)
|
||
resp = SearchResponse.model_validate(cached)
|
||
resp.cache_hit = True
|
||
return resp
|
||
|
||
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(_map_row(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
|