freeleaps-ops/apps/gitea-webhook-ambassador-python/app/services/dedup_service.py
Nicolas f6c515157c feat: 添加 Python 版本的 Gitea Webhook Ambassador
- 新增完整的 Python 实现,替代 Go 版本
- 添加 Web 登录界面和仪表板
- 实现 JWT 认证和 API 密钥管理
- 添加数据库存储功能
- 保持与 Go 版本一致的目录结构和启动脚本
- 包含完整的文档和测试脚本
2025-07-20 21:17:10 +08:00

223 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
防抖服务
实现基于 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