- Introduced `TemplateService` for managing SQL templates, allowing for parameterized queries based on user input. - Added functionality to automatically reload templates upon asset creation in `analyst.py`. - Enhanced `CacheService` to support table generation bumping, ensuring cache invalidation on data changes. - Updated `RiskRepository` and `GatewayRepository` to trigger cache invalidation for relevant operations. - Expanded `analyst_schemas.py` to include new fields for template tracking in response metadata. - Created seed SQL script for populating initial templates and added unit tests for template rendering logic. This update significantly improves the efficiency of query handling by leveraging SQL templates, reducing reliance on LLM for common queries.
189 lines
5.7 KiB
Python
189 lines
5.7 KiB
Python
"""查询缓存(D-06):结果缓存 + 模板缓存,Redis 可选(未连上则用内存降级)。
|
|
|
|
键构造包含权限指纹与表世代(写侧 bump 主动失效),TTL 按表分层。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any, Iterable
|
|
|
|
from app.config.settings import settings
|
|
|
|
# 表 → 缓存 TTL(秒)
|
|
TABLES_TTL = {
|
|
"core_trade": 5 * 60,
|
|
"core_cash_flow": 5 * 60,
|
|
"core_holding": 5 * 60,
|
|
"risk_alert": 60 * 60,
|
|
"core_customer": 24 * 60 * 60,
|
|
"core_product": 24 * 60 * 60,
|
|
"core_product_nav": 24 * 60 * 60,
|
|
"customer_profile_l3": 60 * 60,
|
|
}
|
|
DEFAULT_TTL = 60 * 60
|
|
|
|
GEN_PREFIX = "cache:analyst:gen:"
|
|
|
|
|
|
class CacheBackend:
|
|
def get(self, key: str) -> Any | None:
|
|
raise NotImplementedError
|
|
|
|
def set(self, key: str, value: Any, ttl: int) -> None:
|
|
raise NotImplementedError
|
|
|
|
def delete(self, key: str) -> None:
|
|
raise NotImplementedError
|
|
|
|
def get_counter(self, key: str) -> int | None:
|
|
raise NotImplementedError
|
|
|
|
def incr_counter(self, key: str) -> int:
|
|
raise NotImplementedError
|
|
|
|
|
|
class InMemoryBackend(CacheBackend):
|
|
"""内存缓存(Redis 不可用时的降级)。"""
|
|
|
|
def __init__(self) -> None:
|
|
self._store: dict[str, tuple[float, Any]] = {}
|
|
self._counters: dict[str, int] = {}
|
|
|
|
def get(self, key: str) -> Any | None:
|
|
item = self._store.get(key)
|
|
if not item:
|
|
return None
|
|
exp, value = item
|
|
if time.time() > exp:
|
|
self._store.pop(key, None)
|
|
return None
|
|
return value
|
|
|
|
def set(self, key: str, value: Any, ttl: int) -> None:
|
|
self._store[key] = (time.time() + ttl, value)
|
|
|
|
def delete(self, key: str) -> None:
|
|
self._store.pop(key, None)
|
|
|
|
def get_counter(self, key: str) -> int | None:
|
|
return self._counters.get(key)
|
|
|
|
def incr_counter(self, key: str) -> int:
|
|
n = self._counters.get(key, 0) + 1
|
|
self._counters[key] = n
|
|
return n
|
|
|
|
|
|
class RedisBackend(CacheBackend):
|
|
def __init__(self, url: str) -> None:
|
|
import redis # 延迟导入,避免无 redis 环境报错
|
|
|
|
self._r = redis.Redis.from_url(url, socket_connect_timeout=2, socket_timeout=2, decode_responses=False)
|
|
|
|
def get(self, key: str) -> Any | None:
|
|
try:
|
|
raw = self._r.get(key)
|
|
except Exception:
|
|
return None
|
|
if raw is None:
|
|
return None
|
|
try:
|
|
return json.loads(raw)
|
|
except Exception:
|
|
return None
|
|
|
|
def set(self, key: str, value: Any, ttl: int) -> None:
|
|
try:
|
|
self._r.set(key, json.dumps(value, ensure_ascii=False, default=str), ex=ttl)
|
|
except Exception:
|
|
pass
|
|
|
|
def delete(self, key: str) -> None:
|
|
try:
|
|
self._r.delete(key)
|
|
except Exception:
|
|
pass
|
|
|
|
def get_counter(self, key: str) -> int | None:
|
|
try:
|
|
raw = self._r.get(key)
|
|
except Exception:
|
|
return None
|
|
if raw is None:
|
|
return None
|
|
try:
|
|
return int(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
def incr_counter(self, key: str) -> int:
|
|
try:
|
|
return int(self._r.incr(key))
|
|
except Exception:
|
|
return 1
|
|
|
|
|
|
@dataclass
|
|
class CacheService:
|
|
backend: CacheBackend
|
|
|
|
@classmethod
|
|
def auto(cls) -> "CacheService":
|
|
"""优先 Redis,连不上则降级内存。"""
|
|
try:
|
|
import redis
|
|
|
|
r = redis.Redis.from_url(settings.redis_url, socket_connect_timeout=1, socket_timeout=1)
|
|
r.ping()
|
|
return cls(RedisBackend(settings.redis_url))
|
|
except Exception:
|
|
return cls(InMemoryBackend())
|
|
|
|
def sql_hash(self, sql: str) -> str:
|
|
return hashlib.sha256(sql.strip().encode("utf-8")).hexdigest()[:16]
|
|
|
|
def permission_fingerprint(self, subject_id: str, domain: str, scope: list[str] | None = None) -> str:
|
|
raw = f"{subject_id}|{domain}|{','.join(sorted(scope or []))}"
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
|
|
|
|
def _gen_key(self, table: str) -> str:
|
|
return f"{GEN_PREFIX}{table}"
|
|
|
|
def table_generation(self, table: str) -> int:
|
|
val = self.backend.get_counter(self._gen_key(table))
|
|
return val if val is not None else 0
|
|
|
|
def _generation_suffix(self, tables: list[str]) -> str:
|
|
parts = [f"{t}={self.table_generation(t)}" for t in sorted(set(tables))]
|
|
return ":".join(parts)
|
|
|
|
def result_key(self, perm_fp: str, sql_hash: str, tables: list[str]) -> str:
|
|
return f"cache:analyst:result:{perm_fp}:{sql_hash}:{self._generation_suffix(tables)}"
|
|
|
|
def ttl_for(self, tables: list[str]) -> int:
|
|
for t in tables:
|
|
if t in TABLES_TTL:
|
|
return TABLES_TTL[t]
|
|
return DEFAULT_TTL
|
|
|
|
def get_result(self, perm_fp: str, sql: str, tables: list[str]) -> Any | None:
|
|
return self.backend.get(self.result_key(perm_fp, self.sql_hash(sql), tables))
|
|
|
|
def set_result(self, perm_fp: str, sql: str, value: Any, tables: list[str]) -> None:
|
|
self.backend.set(
|
|
self.result_key(perm_fp, self.sql_hash(sql), tables),
|
|
value,
|
|
self.ttl_for(tables),
|
|
)
|
|
|
|
def invalidate_tables(self, tables: Iterable[str]) -> None:
|
|
"""写侧主动失效:bump 表世代,使依赖该表的结果缓存全部 miss。"""
|
|
for table in set(tables):
|
|
self.backend.incr_counter(self._gen_key(table))
|
|
|
|
def invalidate_by_sql(self, perm_fp: str, sql: str, tables: list[str]) -> None:
|
|
self.backend.delete(self.result_key(perm_fp, self.sql_hash(sql), tables))
|