- Add BottomSheet component for Google Maps-style restaurant detail on mobile (3-snap drag: 40%/55%/92%, velocity-based close, backdrop overlay) - Mobile map mode now full-screen with bottom sheet overlay for details - Collapsible filter panel on mobile with active filter badge count - Standardized cuisine taxonomy (46 categories: 한식|국밥, 일식|스시 etc.) with LLM remap endpoint and admin UI button - Enhanced search: keyword search now includes foods_mentioned + video title - Search results include channels array for frontend filtering - Channel filter moved to frontend filteredRestaurants (not API-level) - LLM extraction prompt updated for pipe-delimited region + cuisine taxonomy - Vector rebuild endpoint with rich JSON chunks per restaurant - Geolocation-based auto region selection on page load - Desktop filters split into two clean rows Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
108 lines
2.9 KiB
Python
108 lines
2.9 KiB
Python
"""Redis cache layer — graceful fallback when Redis is unavailable."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
import redis
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_client: redis.Redis | None = None
|
|
_disabled = False
|
|
|
|
DEFAULT_TTL = 600 # 10 minutes
|
|
|
|
|
|
def _get_client() -> redis.Redis | None:
|
|
global _client, _disabled
|
|
if _disabled:
|
|
return None
|
|
if _client is None:
|
|
host = os.environ.get("REDIS_HOST", "192.168.0.147")
|
|
port = int(os.environ.get("REDIS_PORT", "6379"))
|
|
db = int(os.environ.get("REDIS_DB", "0"))
|
|
try:
|
|
_client = redis.Redis(
|
|
host=host, port=port, db=db,
|
|
socket_connect_timeout=2,
|
|
socket_timeout=2,
|
|
decode_responses=True,
|
|
)
|
|
_client.ping()
|
|
logger.info("Redis connected: %s:%s/%s", host, port, db)
|
|
except Exception as e:
|
|
logger.warning("Redis unavailable (%s), caching disabled", e)
|
|
_client = None
|
|
_disabled = True
|
|
return None
|
|
return _client
|
|
|
|
|
|
def make_key(*parts: Any) -> str:
|
|
"""Build a cache key like 'tasteby:restaurants:cuisine=한식:limit=100'."""
|
|
return "tasteby:" + ":".join(str(p) for p in parts if p is not None and p != "")
|
|
|
|
|
|
def get(key: str) -> Any | None:
|
|
"""Get cached value. Returns None on miss or error."""
|
|
try:
|
|
client = _get_client()
|
|
if not client:
|
|
return None
|
|
val = client.get(key)
|
|
if val is not None:
|
|
return json.loads(val)
|
|
except Exception as e:
|
|
logger.debug("Cache get error: %s", e)
|
|
return None
|
|
|
|
|
|
def set(key: str, value: Any, ttl: int = DEFAULT_TTL) -> None:
|
|
"""Cache a value as JSON with TTL."""
|
|
try:
|
|
client = _get_client()
|
|
if not client:
|
|
return
|
|
client.setex(key, ttl, json.dumps(value, ensure_ascii=False, default=str))
|
|
except Exception as e:
|
|
logger.debug("Cache set error: %s", e)
|
|
|
|
|
|
def flush() -> None:
|
|
"""Flush all tasteby cache keys."""
|
|
try:
|
|
client = _get_client()
|
|
if not client:
|
|
return
|
|
cursor = 0
|
|
while True:
|
|
cursor, keys = client.scan(cursor, match="tasteby:*", count=200)
|
|
if keys:
|
|
client.delete(*keys)
|
|
if cursor == 0:
|
|
break
|
|
logger.info("Cache flushed")
|
|
except Exception as e:
|
|
logger.debug("Cache flush error: %s", e)
|
|
|
|
|
|
def invalidate_prefix(prefix: str) -> None:
|
|
"""Delete all keys matching a prefix."""
|
|
try:
|
|
client = _get_client()
|
|
if not client:
|
|
return
|
|
cursor = 0
|
|
while True:
|
|
cursor, keys = client.scan(cursor, match=f"{prefix}*", count=200)
|
|
if keys:
|
|
client.delete(*keys)
|
|
if cursor == 0:
|
|
break
|
|
except Exception as e:
|
|
logger.debug("Cache invalidate error: %s", e)
|