gendesign/tradein-mvp/backend/app/services/cache.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

"""Redis hot cache for search results (master plan sec 7.2)."""
from __future__ import annotations
import hashlib
import json
import logging
from functools import lru_cache
from typing import Any
import redis.asyncio as redis_async
from app.core.config import settings
logger = logging.getLogger(__name__)
class SearchCache:
"""Async Redis cache wrapper. Stable sha256 hash keys, TTL per kind."""
TTL_SEARCH: int = 300
TTL_HOUSE_DETAIL: int = 3600
TTL_VALUATION: int = 86400
def __init__(self, url: str) -> None:
self._client: redis_async.Redis = redis_async.from_url(
url, decode_responses=True, socket_timeout=2.0, socket_connect_timeout=2.0
)
def search_key(self, params: dict[str, Any]) -> str:
payload = json.dumps(params, sort_keys=True, default=str, ensure_ascii=False)
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
return f"tradein:search:{digest}"
async def get(self, key: str) -> dict[str, Any] | None:
try:
raw = await self._client.get(key)
except Exception as e:
logger.warning("redis GET failed key=%s: %s", key[:24], e)
return None
if raw is None:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
logger.warning("redis cache corrupted key=%s", key[:24])
return None
async def set(self, key: str, value: dict[str, Any], ttl: int) -> None:
try:
await self._client.set(
key, json.dumps(value, default=str, ensure_ascii=False), ex=ttl
)
except Exception as e:
logger.warning("redis SET failed key=%s: %s", key[:24], e)
async def close(self) -> None:
await self._client.aclose()
@lru_cache(maxsize=1)
def get_search_cache() -> SearchCache:
"""Singleton — один Redis pool на процесс."""
return SearchCache(settings.redis_url)