diff --git a/db/points_db.py b/db/points_db.py index e027d72..94270a5 100644 --- a/db/points_db.py +++ b/db/points_db.py @@ -2,6 +2,7 @@ """ 积分系统数据库操作类 """ +import json from loguru import logger from datetime import datetime, timedelta from enum import Enum @@ -98,6 +99,35 @@ class PointsDBOperator(BaseDBOperator): self.LOG.error(f"创建积分系统数据库表失败: {e}") raise + def _build_group_plugin_consumption_cache_key(self, group_id: str, lookback_hours: int) -> str: + """ + 构造群聊插件积分消耗统计的 Redis 缓存键。 + + 这里按“天维度”缓存,原因有两点: + 1. 用户明确希望不要每次都实时计算; + 2. 通胀策略的目标是压缩群积分长期沉淀,对秒级实时性要求不高, + 因此按天缓存可以显著减少数据库聚合压力,同时保持策略方向稳定。 + + Key 里额外带上 `lookback_hours`,是为了避免后续不同窗口参数互相污染缓存。 + """ + stat_day = datetime.now().strftime("%Y%m%d") + normalized_group_id = str(group_id or "private") + normalized_hours = max(1, int(lookback_hours or 72)) + return f"bot:points:inflation:group_stats:{normalized_group_id}:{stat_day}:{normalized_hours}" + + @staticmethod + def _seconds_until_next_day() -> int: + """ + 计算距离次日零点还有多少秒。 + + 缓存采用“自然日切换”: + - 当天第一次命中后,后续同一天直接复用; + - 到了第二天自动失效,重新基于新一天的数据重算。 + """ + now = datetime.now() + tomorrow = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + return max(60, int((tomorrow - now).total_seconds())) + def get_user_points(self, user_id: str, group_id: str) -> Dict: """ 获取用户积分信息 @@ -694,6 +724,58 @@ class PointsDBOperator(BaseDBOperator): self.LOG.error(f"获取群聊插件积分消耗统计失败: {e}") return stats + def get_group_plugin_consumption_stats_cached(self, group_id: str, lookback_hours: int = 72) -> Dict[str, Any]: + """ + 获取带 Redis 日缓存的群聊插件积分消耗统计。 + + 设计目标: + 1. 避免每次插件扣费前都扫描积分表和流水表; + 2. 使用“群 + 日期 + 回看窗口”的 Redis 键,把同一天的同群统计复用起来; + 3. Redis 不可用时自动降级为实时查询,保证功能可用性优先。 + + Args: + group_id: 群ID + lookback_hours: 统计最近多少小时的插件消耗 + + Returns: + 群积分通胀计算所需的统计字典 + """ + normalized_hours = max(1, int(lookback_hours or 72)) + if not group_id: + return self.get_group_plugin_consumption_stats(group_id, normalized_hours) + + cache_key = self._build_group_plugin_consumption_cache_key(group_id, normalized_hours) + + try: + # 先读 Redis 日缓存。 + # 同一天同一群通常会多次触发同类功能,这一步能把热点 SQL 压掉。 + with self.db_manager.get_redis_connection() as redis_client: + cached_payload = redis_client.get(cache_key) + if cached_payload: + if isinstance(cached_payload, bytes): + cached_payload = cached_payload.decode("utf-8") + cached_stats = json.loads(cached_payload) + if isinstance(cached_stats, dict): + return cached_stats + except Exception as e: + # 缓存失败不影响主流程,直接回退实时统计。 + self.LOG.warning(f"读取群积分通胀缓存失败,回退实时查询: {e}") + + stats = self.get_group_plugin_consumption_stats(group_id, normalized_hours) + + try: + with self.db_manager.get_redis_connection() as redis_client: + # 额外多保留 5 分钟缓冲,避免 00:00 刚过时多个请求同时击穿。 + redis_client.set( + cache_key, + json.dumps(stats, ensure_ascii=False), + ex=self._seconds_until_next_day() + 300, + ) + except Exception as e: + self.LOG.warning(f"写入群积分通胀缓存失败: {e}") + + return stats + def imprison_user(self, user_id: str, group_id: str, hours: int = 24, reason: str = None) -> bool: """关押用户 Args: diff --git a/utils/decorator/points_decorator.py b/utils/decorator/points_decorator.py index 6d0907a..0b8afe3 100644 --- a/utils/decorator/points_decorator.py +++ b/utils/decorator/points_decorator.py @@ -76,7 +76,11 @@ def _resolve_points_cost_profile(plugin_instance: Any, message: Dict[str, Any], try: db_manager = DBConnectionManager.get_instance() points_db = PointsDBOperator(db_manager) - stats = points_db.get_group_plugin_consumption_stats(roomid, lookback_hours) + # 通胀画像优先走 Redis 日缓存: + # 1. 同一个群在一天内会重复触发多次插件扣费; + # 2. 若每次都实时聚合,会对积分表和流水表造成额外压力; + # 3. 因此这里统一走“按天缓存”的统计入口,把数据库开销压到每天首次访问。 + stats = points_db.get_group_plugin_consumption_stats_cached(roomid, lookback_hours) profile["lookback_hours"] = lookback_hours profile["spend_ratio"] = float(stats.get("plugin_spend_ratio") or 0.0)