Files
group_xinghuo_jinrong/app/service/cache_service.py
T
zhanghongyu_0626 ddcf53cfa8 feat(template): Implement template caching and SQL rendering for enhanced query handling
- 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.
2026-09-10 15:22:07 +08:00

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))