diff --git a/backend/apps/scan/migrations/0003_add_wecom_fields.py b/backend/apps/scan/migrations/0003_add_wecom_fields.py new file mode 100644 index 00000000..5839a715 --- /dev/null +++ b/backend/apps/scan/migrations/0003_add_wecom_fields.py @@ -0,0 +1,23 @@ +# Generated manually for WeCom notification support + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scan', '0002_add_cached_screenshots_count'), + ] + + operations = [ + migrations.AddField( + model_name='notificationsettings', + name='wecom_enabled', + field=models.BooleanField(default=False, help_text='是否启用企业微信通知'), + ), + migrations.AddField( + model_name='notificationsettings', + name='wecom_webhook_url', + field=models.URLField(blank=True, default='', help_text='企业微信机器人 Webhook URL'), + ), + ] diff --git a/backend/apps/scan/notifications/models.py b/backend/apps/scan/notifications/models.py index 72791df5..43bae232 100644 --- a/backend/apps/scan/notifications/models.py +++ b/backend/apps/scan/notifications/models.py @@ -1,8 +1,14 @@ """通知系统数据模型""" -from django.db import models +import logging +from datetime import timedelta -from .types import NotificationLevel, NotificationCategory +from django.db import models +from django.utils import timezone + +from .types import NotificationCategory, NotificationLevel + +logger = logging.getLogger(__name__) class NotificationSettings(models.Model): @@ -10,31 +16,34 @@ class NotificationSettings(models.Model): 通知设置(单例模型) 存储 Discord webhook 配置和各分类的通知开关 """ - + # Discord 配置 discord_enabled = models.BooleanField(default=False, help_text='是否启用 Discord 通知') discord_webhook_url = models.URLField(blank=True, default='', help_text='Discord Webhook URL') - + + # 企业微信配置 + wecom_enabled = models.BooleanField(default=False, help_text='是否启用企业微信通知') + wecom_webhook_url = models.URLField(blank=True, default='', help_text='企业微信机器人 Webhook URL') + # 分类开关(使用 JSONField 存储) categories = models.JSONField( default=dict, help_text='各分类通知开关,如 {"scan": true, "vulnerability": true, "asset": true, "system": false}' ) - + # 时间信息 created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - + class Meta: db_table = 'notification_settings' verbose_name = '通知设置' verbose_name_plural = '通知设置' - + def save(self, *args, **kwargs): - # 单例模式:强制只有一条记录 - self.pk = 1 + self.pk = 1 # 单例模式 super().save(*args, **kwargs) - + @classmethod def get_instance(cls) -> 'NotificationSettings': """获取或创建单例实例""" @@ -52,7 +61,7 @@ class NotificationSettings(models.Model): } ) return obj - + def is_category_enabled(self, category: str) -> bool: """检查指定分类是否启用通知""" return self.categories.get(category, False) @@ -60,10 +69,9 @@ class NotificationSettings(models.Model): class Notification(models.Model): """通知模型""" - + id = models.AutoField(primary_key=True) - - # 通知分类 + category = models.CharField( max_length=20, choices=NotificationCategory.choices, @@ -71,8 +79,7 @@ class Notification(models.Model): db_index=True, help_text='通知分类' ) - - # 通知级别 + level = models.CharField( max_length=20, choices=NotificationLevel.choices, @@ -80,16 +87,15 @@ class Notification(models.Model): db_index=True, help_text='通知级别' ) - + title = models.CharField(max_length=200, help_text='通知标题') message = models.CharField(max_length=2000, help_text='通知内容') - - # 时间信息 + created_at = models.DateTimeField(auto_now_add=True, help_text='创建时间') - + is_read = models.BooleanField(default=False, help_text='是否已读') read_at = models.DateTimeField(null=True, blank=True, help_text='阅读时间') - + class Meta: db_table = 'notification' verbose_name = '通知' @@ -101,44 +107,26 @@ class Notification(models.Model): models.Index(fields=['level', '-created_at']), models.Index(fields=['is_read', '-created_at']), ] - + def __str__(self): return f"{self.get_level_display()} - {self.title}" - + @classmethod - def cleanup_old_notifications(cls): - """ - 清理超过15天的旧通知(硬编码) - - Returns: - int: 删除的通知数量 - """ - from datetime import timedelta - from django.utils import timezone - - # 硬编码:只保留最近15天的通知 + def cleanup_old_notifications(cls) -> int: + """清理超过15天的旧通知""" cutoff_date = timezone.now() - timedelta(days=15) - delete_result = cls.objects.filter(created_at__lt=cutoff_date).delete() - - return delete_result[0] if delete_result[0] else 0 - + deleted_count, _ = cls.objects.filter(created_at__lt=cutoff_date).delete() + return deleted_count or 0 + def save(self, *args, **kwargs): - """ - 重写save方法,在创建新通知时自动清理旧通知 - """ + """重写save方法,在创建新通知时自动清理旧通知""" is_new = self.pk is None super().save(*args, **kwargs) - - # 只在创建新通知时执行清理(自动清理超过15天的通知) + if is_new: try: deleted_count = self.__class__.cleanup_old_notifications() if deleted_count > 0: - import logging - logger = logging.getLogger(__name__) - logger.info(f"自动清理了 {deleted_count} 条超过15天的旧通知") - except Exception as e: - # 清理失败不应影响通知创建 - import logging - logger = logging.getLogger(__name__) - logger.warning(f"通知自动清理失败: {e}") + logger.info("自动清理了 %d 条超过15天的旧通知", deleted_count) + except Exception: + logger.warning("通知自动清理失败", exc_info=True) diff --git a/backend/apps/scan/notifications/repositories.py b/backend/apps/scan/notifications/repositories.py index cd18dfb5..9a51443b 100644 --- a/backend/apps/scan/notifications/repositories.py +++ b/backend/apps/scan/notifications/repositories.py @@ -1,52 +1,70 @@ +"""通知系统仓储层模块""" + import logging -from typing import TypedDict +from dataclasses import dataclass +from typing import Optional + +from django.db.models import QuerySet from django.utils import timezone from apps.common.decorators import auto_ensure_db_connection -from .models import Notification, NotificationSettings +from .models import Notification, NotificationSettings logger = logging.getLogger(__name__) -class NotificationSettingsData(TypedDict): - """通知设置数据结构""" +@dataclass +class NotificationSettingsData: + """通知设置更新数据""" + discord_enabled: bool discord_webhook_url: str categories: dict[str, bool] + wecom_enabled: bool = False + wecom_webhook_url: str = '' @auto_ensure_db_connection class NotificationSettingsRepository: """通知设置仓储层""" - + def get_settings(self) -> NotificationSettings: """获取通知设置单例""" return NotificationSettings.get_instance() - - def update_settings( - self, - discord_enabled: bool, - discord_webhook_url: str, - categories: dict[str, bool] - ) -> NotificationSettings: + + def update_settings(self, data: NotificationSettingsData) -> NotificationSettings: """更新通知设置""" settings = NotificationSettings.get_instance() - settings.discord_enabled = discord_enabled - settings.discord_webhook_url = discord_webhook_url - settings.categories = categories + settings.discord_enabled = data.discord_enabled + settings.discord_webhook_url = data.discord_webhook_url + settings.wecom_enabled = data.wecom_enabled + settings.wecom_webhook_url = data.wecom_webhook_url + settings.categories = data.categories settings.save() return settings - + def is_category_enabled(self, category: str) -> bool: """检查指定分类是否启用""" - settings = self.get_settings() - return settings.is_category_enabled(category) + return self.get_settings().is_category_enabled(category) @auto_ensure_db_connection class DjangoNotificationRepository: - def get_filtered(self, level: str | None = None, unread: bool | None = None): + """通知数据仓储层""" + + def get_filtered( + self, + level: Optional[str] = None, + unread: Optional[bool] = None + ) -> QuerySet[Notification]: + """ + 获取过滤后的通知列表 + + Args: + level: 通知级别过滤 + unread: 已读状态过滤 (True=未读, False=已读, None=全部) + """ queryset = Notification.objects.all() if level: @@ -60,16 +78,24 @@ class DjangoNotificationRepository: return queryset.order_by("-created_at") def get_unread_count(self) -> int: + """获取未读通知数量""" return Notification.objects.filter(is_read=False).count() def mark_all_as_read(self) -> int: - updated = Notification.objects.filter(is_read=False).update( + """标记所有通知为已读,返回更新数量""" + return Notification.objects.filter(is_read=False).update( is_read=True, read_at=timezone.now(), ) - return updated - def create(self, title: str, message: str, level: str, category: str = 'system') -> Notification: + def create( + self, + title: str, + message: str, + level: str, + category: str = 'system' + ) -> Notification: + """创建新通知""" return Notification.objects.create( category=category, level=level, diff --git a/backend/apps/scan/notifications/services.py b/backend/apps/scan/notifications/services.py index 96b290fb..649648f3 100644 --- a/backend/apps/scan/notifications/services.py +++ b/backend/apps/scan/notifications/services.py @@ -60,13 +60,12 @@ def push_to_external_channels(notification: Notification) -> None: 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) + # 企业微信渠道 + if settings.wecom_enabled and settings.wecom_webhook_url: + try: + _send_wecom(notification, settings.wecom_webhook_url) + except Exception as e: + logger.warning(f"企业微信推送失败: {e}") def _send_discord(notification: Notification, webhook_url: str) -> bool: @@ -103,6 +102,41 @@ def _send_discord(notification: Notification, webhook_url: str) -> bool: return False +def _send_wecom(notification: Notification, webhook_url: str) -> bool: + """发送到企业微信机器人 Webhook""" + try: + emoji = CATEGORY_EMOJI.get(notification.category, '📢') + + # 企业微信 Markdown 格式 + content = f"""**{emoji} {notification.title}** +> 级别:{notification.get_level_display()} +> 分类:{notification.get_category_display()} + +{notification.message}""" + + payload = { + 'msgtype': 'markdown', + 'markdown': {'content': content} + } + + response = requests.post(webhook_url, json=payload, timeout=10) + + if response.status_code == 200: + result = response.json() + if result.get('errcode') == 0: + logger.info(f"企业微信通知发送成功 - {notification.title}") + return True + logger.warning(f"企业微信发送失败 - errcode: {result.get('errcode')}, errmsg: {result.get('errmsg')}") + return False + + logger.warning(f"企业微信发送失败 - 状态码: {response.status_code}") + return False + + except requests.RequestException as e: + logger.error(f"企业微信网络错误: {e}") + return False + + # ============================================================ # 设置服务 # ============================================================ @@ -121,31 +155,43 @@ class NotificationSettingsService: 'enabled': settings.discord_enabled, 'webhookUrl': settings.discord_webhook_url, }, + 'wecom': { + 'enabled': settings.wecom_enabled, + 'webhookUrl': settings.wecom_webhook_url, + }, 'categories': settings.categories, } def update_settings(self, data: dict) -> dict: """更新通知设置 - + 注意:DRF CamelCaseJSONParser 会将前端的 webhookUrl 转换为 webhook_url """ discord_data = data.get('discord', {}) + wecom_data = data.get('wecom', {}) categories = data.get('categories', {}) - + # CamelCaseJSONParser 转换后的字段名是 webhook_url - webhook_url = discord_data.get('webhook_url', '') - + discord_webhook_url = discord_data.get('webhook_url', '') + wecom_webhook_url = wecom_data.get('webhook_url', '') + settings = self.repo.update_settings( discord_enabled=discord_data.get('enabled', False), - discord_webhook_url=webhook_url, + discord_webhook_url=discord_webhook_url, + wecom_enabled=wecom_data.get('enabled', False), + wecom_webhook_url=wecom_webhook_url, categories=categories, ) - + return { 'discord': { 'enabled': settings.discord_enabled, 'webhookUrl': settings.discord_webhook_url, }, + 'wecom': { + 'enabled': settings.wecom_enabled, + 'webhookUrl': settings.wecom_webhook_url, + }, 'categories': settings.categories, } diff --git a/frontend/app/[locale]/settings/notifications/page.tsx b/frontend/app/[locale]/settings/notifications/page.tsx index 979aaf46..e829f1cc 100644 --- a/frontend/app/[locale]/settings/notifications/page.tsx +++ b/frontend/app/[locale]/settings/notifications/page.tsx @@ -29,6 +29,10 @@ export default function NotificationSettingsPage() { enabled: z.boolean(), webhookUrl: z.string().url(t("discord.urlInvalid")).or(z.literal('')), }), + wecom: z.object({ + enabled: z.boolean(), + webhookUrl: z.string().url(t("wecom.urlInvalid")).or(z.literal('')), + }), categories: z.object({ scan: z.boolean(), vulnerability: z.boolean(), @@ -46,6 +50,15 @@ export default function NotificationSettingsPage() { }) } } + if (val.wecom.enabled) { + if (!val.wecom.webhookUrl || val.wecom.webhookUrl.trim() === '') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("wecom.requiredError"), + path: ['wecom', 'webhookUrl'], + }) + } + } }) const NOTIFICATION_CATEGORIES = [ @@ -79,6 +92,7 @@ export default function NotificationSettingsPage() { resolver: zodResolver(schema), values: data ?? { discord: { enabled: false, webhookUrl: '' }, + wecom: { enabled: false, webhookUrl: '' }, categories: { scan: true, vulnerability: true, @@ -93,6 +107,7 @@ export default function NotificationSettingsPage() { } const discordEnabled = form.watch('discord.enabled') + const wecomEnabled = form.watch('wecom.enabled') return (