Files
xingrin/backend/apps/scan/notifications/services.py
2025-12-19 19:41:12 +08:00

369 lines
13 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.
"""通知服务 - 支持数据库存储和 WebSocket 实时推送"""
import logging
import time
import requests
import urllib3
from .models import Notification, NotificationSettings
from .types import NotificationLevel, NotificationCategory
from .repositories import DjangoNotificationRepository, NotificationSettingsRepository
# 禁用自签名证书的 SSL 警告(远程 Worker 回调场景)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logger = logging.getLogger(__name__)
# ============================================================
# 外部推送渠道抽象
# ============================================================
# Discord Embed 颜色映射(使用字符串 key因为 model 字段存储的是字符串)
DISCORD_COLORS = {
'low': 0x3498db, # 蓝色
'medium': 0xf39c12, # 橙色
'high': 0xe74c3c, # 红色
'critical': 0x9b59b6, # 紫色
}
# 分类 emoji使用字符串 key
CATEGORY_EMOJI = {
'scan': '🔍',
'vulnerability': '⚠️',
'asset': '🌐',
'system': '⚙️',
}
def push_to_external_channels(notification: Notification) -> None:
"""
推送通知到外部渠道Discord、Slack 等)
根据用户设置决定推送到哪些渠道。
目前支持Discord
未来可扩展Slack、Telegram、Email 等
Args:
notification: 通知对象
"""
settings = NotificationSettings.get_instance()
# 检查分类是否启用
if not settings.is_category_enabled(notification.category):
logger.debug(f"分类 {notification.category} 未启用外部推送")
return
# Discord 渠道
if settings.discord_enabled and settings.discord_webhook_url:
try:
_send_discord(notification, settings.discord_webhook_url)
except Exception as e:
logger.warning(f"Discord 推送失败: {e}")
# 未来扩展Slack
# if settings.slack_enabled and settings.slack_webhook_url:
# _send_slack(notification, settings.slack_webhook_url)
# 未来扩展Telegram
# if settings.telegram_enabled and settings.telegram_bot_token:
# _send_telegram(notification, settings.telegram_chat_id)
def _send_discord(notification: Notification, webhook_url: str) -> bool:
"""发送到 Discord Webhook"""
try:
color = DISCORD_COLORS.get(notification.level, 0x95a5a6)
emoji = CATEGORY_EMOJI.get(notification.category, '📢')
embed = {
'title': f"{emoji} {notification.title}",
'description': notification.message,
'color': color,
'footer': {
'text': f"级别: {notification.get_level_display()} | 分类: {notification.get_category_display()}"
},
'timestamp': notification.created_at.isoformat(),
}
response = requests.post(
webhook_url,
json={'embeds': [embed]},
timeout=10
)
if response.status_code in (200, 204):
logger.info(f"Discord 通知发送成功 - {notification.title}")
return True
else:
logger.warning(f"Discord 发送失败 - 状态码: {response.status_code}")
return False
except requests.RequestException as e:
logger.error(f"Discord 网络错误: {e}")
return False
# ============================================================
# 设置服务
# ============================================================
class NotificationSettingsService:
"""通知设置服务"""
def __init__(self, repository: NotificationSettingsRepository | None = None):
self.repo = repository or NotificationSettingsRepository()
def get_settings(self) -> dict:
"""获取通知设置(前端格式)"""
settings = self.repo.get_settings()
return {
'discord': {
'enabled': settings.discord_enabled,
'webhookUrl': settings.discord_webhook_url,
},
'categories': settings.categories,
}
def update_settings(self, data: dict) -> dict:
"""更新通知设置
注意DRF CamelCaseJSONParser 会将前端的 webhookUrl 转换为 webhook_url
"""
discord_data = data.get('discord', {})
categories = data.get('categories', {})
# CamelCaseJSONParser 转换后的字段名是 webhook_url
webhook_url = discord_data.get('webhook_url', '')
settings = self.repo.update_settings(
discord_enabled=discord_data.get('enabled', False),
discord_webhook_url=webhook_url,
categories=categories,
)
return {
'discord': {
'enabled': settings.discord_enabled,
'webhookUrl': settings.discord_webhook_url,
},
'categories': settings.categories,
}
class NotificationService:
"""通知业务服务,封装常用查询与更新操作"""
def __init__(self, repository: DjangoNotificationRepository | None = None):
self.repo = repository or DjangoNotificationRepository()
def get_notifications(self, level: str | None = None, unread: bool | None = None):
return self.repo.get_filtered(level=level, unread=unread)
def get_unread_count(self) -> int:
return self.repo.get_unread_count()
def mark_all_as_read(self) -> int:
return self.repo.mark_all_as_read()
def create_notification(
title: str,
message: str,
level: NotificationLevel = NotificationLevel.LOW,
category: NotificationCategory = NotificationCategory.SYSTEM
) -> Notification:
"""
创建通知记录并实时推送
增强的重试机制:
- 最多重试 3 次
- 每次重试前强制关闭并重建数据库连接
- 重试间隔1秒 → 2秒 → 3秒
- 针对连接错误进行特殊处理
Args:
title: 通知标题
message: 通知消息
level: 通知级别
category: 通知分类
Returns:
Notification: 创建的通知对象
Raises:
Exception: 重试3次后仍然失败
"""
from django.db import connection
from psycopg2 import OperationalError, InterfaceError
repo = DjangoNotificationRepository()
max_retries = 3
last_exception = None
for attempt in range(1, max_retries + 1):
try:
# 强制关闭旧连接并重建(每次尝试都重建)
if attempt > 1:
logger.debug(f"重试创建通知 ({attempt}/{max_retries}) - {title}")
connection.close()
connection.ensure_connection()
# 测试连接是否真的可用
connection.cursor().execute("SELECT 1")
# 1. 写入数据库(通过仓储层统一访问 ORM
notification = repo.create(
title=title,
message=message,
level=level,
category=category,
)
# 2. WebSocket 实时推送(推送失败不影响通知创建)
try:
_push_to_websocket(notification)
except Exception as push_error:
logger.warning(f"WebSocket 推送失败,但通知已创建 - {title}: {push_error}")
# 3. 外部渠道推送Discord/Slack 等,推送失败不影响通知创建)
try:
push_to_external_channels(notification)
except Exception as external_error:
logger.warning(f"外部渠道推送失败,但通知已创建 - {title}: {external_error}")
if attempt > 1:
logger.info(f"✓ 通知创建成功(重试 {attempt-1} 次后) - {title}")
else:
logger.debug(f"通知已创建并推送 - {title}")
return notification
except (OperationalError, InterfaceError) as e:
# 数据库连接错误,需要重试
last_exception = e
error_msg = str(e)
logger.warning(
f"数据库连接错误 ({attempt}/{max_retries}) - {title}: {error_msg[:100]}"
)
if attempt < max_retries:
# 指数退避1秒、2秒、3秒
sleep_time = attempt
logger.debug(f"等待 {sleep_time} 秒后重试...")
time.sleep(sleep_time)
else:
logger.error(
f"创建通知失败 - 数据库连接问题(已重试 {max_retries} 次) - {title}: {error_msg}"
)
except Exception as e:
# 其他错误,不重试直接抛出
last_exception = e
error_str = str(e).lower()
if 'connection' in error_str or 'closed' in error_str:
logger.error(f"创建通知失败 - 连接相关错误 - {title}: {e}")
else:
logger.error(f"创建通知失败 - {title}: {e}")
# 非连接错误,直接抛出不重试
raise
# 所有重试都失败了
error_msg = f"创建通知失败 - 已重试 {max_retries} 次仍然失败 - {title}"
logger.error(error_msg)
raise RuntimeError(error_msg) from last_exception
def _push_to_websocket(notification: Notification) -> None:
"""
推送通知到 WebSocket 客户端
- 在 Server 容器中:直接通过 Channel Layer 推送
- 在 Worker 容器中:通过 API 回调让 Server 推送(因为 Worker 无法访问 Redis
"""
import os
# 检测是否在 Worker 容器中(有 SERVER_URL 环境变量)
server_url = os.environ.get("SERVER_URL")
if server_url:
# Worker 容器:通过 API 回调
_push_via_api_callback(notification, server_url)
else:
# Server 容器:直接推送
_push_via_channel_layer(notification)
def _push_via_api_callback(notification: Notification, server_url: str) -> None:
"""
通过 HTTP 回调推送通知Worker → Server 跨容器通信)
注意:这不是同进程内的 service 调用 view而是 Worker 容器
通过 HTTP 请求 Server 容器的 /api/callbacks/notification/ 接口。
Worker 无法直接访问 Redis需要由 Server 代为推送 WebSocket。
"""
import requests
try:
callback_url = f"{server_url}/api/callbacks/notification/"
data = {
'id': notification.id,
'category': notification.category,
'title': notification.title,
'message': notification.message,
'level': notification.level,
'created_at': notification.created_at.isoformat()
}
# verify=False: 远程 Worker 回调 Server 时可能使用自签名证书
resp = requests.post(callback_url, json=data, timeout=5, verify=False)
resp.raise_for_status()
logger.debug(f"通知回调推送成功 - ID: {notification.id}")
except Exception as e:
logger.warning(f"通知回调推送失败 - ID: {notification.id}: {e}")
def _push_via_channel_layer(notification: Notification) -> None:
"""通过 Channel Layer 直接推送通知Server 容器使用)"""
try:
logger.debug(f"开始推送通知到 WebSocket - ID: {notification.id}")
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
# 获取 Channel Layer
channel_layer = get_channel_layer()
if channel_layer is None:
logger.warning("Channel Layer 未配置,跳过 WebSocket 推送")
return
# 构造通知数据
data = {
'type': 'notification.message', # 对应 Consumer 的 notification_message 方法
'id': notification.id,
'category': notification.category,
'title': notification.title,
'message': notification.message,
'level': notification.level,
'created_at': notification.created_at.isoformat()
}
# 发送到通知组(所有连接的客户端)
async_to_sync(channel_layer.group_send)(
'notifications', # 组名
data
)
logger.debug(f"通知推送成功 - ID: {notification.id}")
except ImportError as e:
logger.warning(f"Channels 模块未安装,跳过 WebSocket 推送: {e}")
except Exception as e:
logger.warning(f"WebSocket 推送失败 - ID: {notification.id}: {e}", exc_info=True)