""" 防抖服务 实现基于 commit hash + 分支的去重策略 """ import asyncio import hashlib import json from typing import Optional, Dict, Any from datetime import datetime, timedelta import structlog from redis import asyncio as aioredis from app.config import get_settings logger = structlog.get_logger() class DeduplicationService: """防抖服务""" def __init__(self, redis_client: aioredis.Redis): self.redis = redis_client self.settings = get_settings() self.cache_prefix = "webhook:dedup:" async def is_duplicate(self, dedup_key: str) -> bool: """ 检查是否为重复事件 Args: dedup_key: 防抖键值 (commit_hash:branch) Returns: bool: True 表示重复,False 表示新事件 """ if not self.settings.deduplication.enabled: return False try: cache_key = f"{self.cache_prefix}{dedup_key}" # 检查是否在缓存中 exists = await self.redis.exists(cache_key) if exists: logger.info("Duplicate event detected", dedup_key=dedup_key) return True # 记录新事件 await self._record_event(cache_key, dedup_key) logger.info("New event recorded", dedup_key=dedup_key) return False except Exception as e: logger.error("Error checking duplication", dedup_key=dedup_key, error=str(e)) # 出错时允许通过,避免阻塞 return False async def _record_event(self, cache_key: str, dedup_key: str): """记录事件到缓存""" try: # 设置缓存,TTL 为防抖窗口时间 ttl = self.settings.deduplication.cache_ttl await self.redis.setex(cache_key, ttl, json.dumps({ "dedup_key": dedup_key, "timestamp": datetime.utcnow().isoformat(), "ttl": ttl })) # 同时记录到时间窗口缓存 window_key = f"{self.cache_prefix}window:{dedup_key}" window_ttl = self.settings.deduplication.window_seconds await self.redis.setex(window_key, window_ttl, "1") except Exception as e: logger.error("Error recording event", cache_key=cache_key, error=str(e)) async def get_event_info(self, dedup_key: str) -> Optional[Dict[str, Any]]: """获取事件信息""" try: cache_key = f"{self.cache_prefix}{dedup_key}" data = await self.redis.get(cache_key) if data: return json.loads(data) return None except Exception as e: logger.error("Error getting event info", dedup_key=dedup_key, error=str(e)) return None async def clear_event(self, dedup_key: str) -> bool: """清除事件记录""" try: cache_key = f"{self.cache_prefix}{dedup_key}" window_key = f"{self.cache_prefix}window:{dedup_key}" # 删除两个缓存键 await self.redis.delete(cache_key, window_key) logger.info("Event cleared", dedup_key=dedup_key) return True except Exception as e: logger.error("Error clearing event", dedup_key=dedup_key, error=str(e)) return False async def get_stats(self) -> Dict[str, Any]: """获取防抖统计信息""" try: # 获取所有防抖键 pattern = f"{self.cache_prefix}*" keys = await self.redis.keys(pattern) # 统计不同类型的键 total_keys = len(keys) window_keys = len([k for k in keys if b"window:" in k]) event_keys = total_keys - window_keys # 获取配置信息 config = { "enabled": self.settings.deduplication.enabled, "window_seconds": self.settings.deduplication.window_seconds, "cache_ttl": self.settings.deduplication.cache_ttl, "strategy": self.settings.deduplication.strategy } return { "total_keys": total_keys, "window_keys": window_keys, "event_keys": event_keys, "config": config, "timestamp": datetime.utcnow().isoformat() } except Exception as e: logger.error("Error getting deduplication stats", error=str(e)) return {"error": str(e)} async def cleanup_expired_events(self) -> int: """清理过期事件""" try: pattern = f"{self.cache_prefix}*" keys = await self.redis.keys(pattern) cleaned_count = 0 for key in keys: # 检查 TTL ttl = await self.redis.ttl(key) if ttl <= 0: await self.redis.delete(key) cleaned_count += 1 if cleaned_count > 0: logger.info("Cleaned up expired events", count=cleaned_count) return cleaned_count except Exception as e: logger.error("Error cleaning up expired events", error=str(e)) return 0 def generate_dedup_key(self, commit_hash: str, branch: str) -> str: """ 生成防抖键值 Args: commit_hash: 提交哈希 branch: 分支名 Returns: str: 防抖键值 """ if self.settings.deduplication.strategy == "commit_branch": return f"{commit_hash}:{branch}" elif self.settings.deduplication.strategy == "commit_only": return commit_hash elif self.settings.deduplication.strategy == "branch_only": return branch else: # 默认使用 commit_hash:branch return f"{commit_hash}:{branch}" async def is_in_window(self, dedup_key: str) -> bool: """ 检查是否在防抖时间窗口内 Args: dedup_key: 防抖键值 Returns: bool: True 表示在窗口内 """ try: window_key = f"{self.cache_prefix}window:{dedup_key}" exists = await self.redis.exists(window_key) return bool(exists) except Exception as e: logger.error("Error checking window", dedup_key=dedup_key, error=str(e)) return False # 全局防抖服务实例 _dedup_service: Optional[DeduplicationService] = None def get_deduplication_service() -> DeduplicationService: """获取防抖服务实例""" global _dedup_service if _dedup_service is None: # 这里需要从依赖注入获取 Redis 客户端 # 在实际使用时,应该通过依赖注入传入 raise RuntimeError("DeduplicationService not initialized") return _dedup_service def set_deduplication_service(service: DeduplicationService): """设置防抖服务实例""" global _dedup_service _dedup_service = service