- 新增完整的 Python 实现,替代 Go 版本 - 添加 Web 登录界面和仪表板 - 实现 JWT 认证和 API 密钥管理 - 添加数据库存储功能 - 保持与 Go 版本一致的目录结构和启动脚本 - 包含完整的文档和测试脚本
223 lines
7.3 KiB
Python
223 lines
7.3 KiB
Python
"""
|
||
防抖服务
|
||
实现基于 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 |