""" AI 缓存读写服务。 负责 biz.ai_cache 表的 CRUD 和保留策略管理。 所有查询和写入操作强制 site_id 隔离。 """ from __future__ import annotations import json import logging from datetime import datetime from app.database import get_connection logger = logging.getLogger(__name__) class AICacheService: """AI 缓存读写服务。""" def get_latest( self, cache_type: str, site_id: int, target_id: str, ) -> dict | None: """查询最新缓存记录。 按 (cache_type, site_id, target_id) 查询 created_at 最新的一条。 无记录时返回 None。 """ conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT id, cache_type, site_id, target_id, result_json, score, triggered_by, created_at, expires_at FROM biz.ai_cache WHERE cache_type = %s AND site_id = %s AND target_id = %s ORDER BY created_at DESC LIMIT 1 """, (cache_type, site_id, target_id), ) columns = [desc[0] for desc in cur.description] row = cur.fetchone() if row is None: return None return _row_to_dict(columns, row) finally: conn.close() def get_history( self, cache_type: str, site_id: int, target_id: str, limit: int = 2, ) -> list[dict]: """查询历史缓存记录(按 created_at DESC),用于 Prompt reference。 无记录时返回空列表。 """ conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT id, cache_type, site_id, target_id, result_json, score, triggered_by, created_at, expires_at FROM biz.ai_cache WHERE cache_type = %s AND site_id = %s AND target_id = %s ORDER BY created_at DESC LIMIT %s """, (cache_type, site_id, target_id, limit), ) columns = [desc[0] for desc in cur.description] rows = cur.fetchall() return [_row_to_dict(columns, row) for row in rows] finally: conn.close() def write_cache( self, cache_type: str, site_id: int, target_id: str, result_json: dict, triggered_by: str | None = None, score: int | None = None, expires_at: datetime | None = None, ) -> int: """写入缓存记录,返回 id。写入后清理超限记录。""" conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ INSERT INTO biz.ai_cache (cache_type, site_id, target_id, result_json, triggered_by, score, expires_at) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( cache_type, site_id, target_id, json.dumps(result_json, ensure_ascii=False), triggered_by, score, expires_at, ), ) row = cur.fetchone() conn.commit() cache_id: int = row[0] except Exception: conn.rollback() raise finally: conn.close() # 写入成功后清理超限记录(失败仅记录警告,不影响写入结果) try: deleted = self._cleanup_excess(cache_type, site_id, target_id) if deleted > 0: logger.info( "清理超限缓存: cache_type=%s site_id=%s target_id=%s 删除=%d", cache_type, site_id, target_id, deleted, ) except Exception: logger.warning( "清理超限缓存失败: cache_type=%s site_id=%s target_id=%s", cache_type, site_id, target_id, exc_info=True, ) return cache_id def _cleanup_excess( self, cache_type: str, site_id: int, target_id: str, max_count: int = 500, ) -> int: """清理超限记录,保留最近 max_count 条,返回删除数量。""" conn = get_connection() try: with conn.cursor() as cur: # 删除超出保留上限的最旧记录 cur.execute( """ DELETE FROM biz.ai_cache WHERE id IN ( SELECT id FROM biz.ai_cache WHERE cache_type = %s AND site_id = %s AND target_id = %s ORDER BY created_at DESC OFFSET %s ) """, (cache_type, site_id, target_id, max_count), ) deleted = cur.rowcount conn.commit() return deleted except Exception: conn.rollback() raise finally: conn.close() def _row_to_dict(columns: list[str], row: tuple) -> dict: """将数据库行转换为 dict,处理特殊类型序列化。""" result = {} for col, val in zip(columns, row): if isinstance(val, datetime): result[col] = val.isoformat() else: result[col] = val return result