mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 11:46:16 +08:00
增加企业微信
This commit is contained in:
23
backend/apps/scan/migrations/0003_add_wecom_fields.py
Normal file
23
backend/apps/scan/migrations/0003_add_wecom_fields.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
@@ -187,25 +202,59 @@ export default function NotificationSettingsPage() {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Feishu/DingTalk/WeCom - Coming soon */}
|
||||
<Card className="opacity-60">
|
||||
{/* 企业微信 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<IconBrandSlack className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#07C160]/10">
|
||||
<IconBrandSlack className="h-5 w-5 text-[#07C160]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">{t("enterprise.title")}</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">{t("emailChannel.comingSoon")}</Badge>
|
||||
</div>
|
||||
<CardDescription>{t("enterprise.description")}</CardDescription>
|
||||
<CardTitle className="text-base">{t("wecom.title")}</CardTitle>
|
||||
<CardDescription>{t("wecom.description")}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Switch disabled />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wecom.enabled"
|
||||
render={({ field }) => (
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={isLoading || updateMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{wecomEnabled && (
|
||||
<CardContent className="pt-0">
|
||||
<Separator className="mb-4" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wecom.webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("wecom.webhookLabel")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("wecom.webhookPlaceholder")}
|
||||
{...field}
|
||||
disabled={isLoading || updateMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("wecom.webhookHelp")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -1486,6 +1486,15 @@
|
||||
"requiredError": "Webhook URL is required when Discord is enabled",
|
||||
"urlInvalid": "Please enter a valid Discord Webhook URL"
|
||||
},
|
||||
"wecom": {
|
||||
"title": "WeCom",
|
||||
"description": "Push notifications to WeCom group bot",
|
||||
"webhookLabel": "Webhook URL",
|
||||
"webhookPlaceholder": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
|
||||
"webhookHelp": "Add a bot in WeCom group and copy the Webhook URL",
|
||||
"requiredError": "Webhook URL is required when WeCom is enabled",
|
||||
"urlInvalid": "Please enter a valid WeCom Webhook URL"
|
||||
},
|
||||
"emailChannel": {
|
||||
"title": "Email",
|
||||
"description": "Receive notifications via email",
|
||||
|
||||
@@ -1486,6 +1486,15 @@
|
||||
"requiredError": "启用 Discord 时必须填写 Webhook URL",
|
||||
"urlInvalid": "请输入有效的 Discord Webhook URL"
|
||||
},
|
||||
"wecom": {
|
||||
"title": "企业微信",
|
||||
"description": "将通知推送到企业微信群机器人",
|
||||
"webhookLabel": "Webhook URL",
|
||||
"webhookPlaceholder": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
|
||||
"webhookHelp": "在企业微信群中添加机器人,复制 Webhook 地址",
|
||||
"requiredError": "启用企业微信时必须填写 Webhook URL",
|
||||
"urlInvalid": "请输入有效的企业微信 Webhook URL"
|
||||
},
|
||||
"emailChannel": {
|
||||
"title": "邮件",
|
||||
"description": "通过邮件接收通知",
|
||||
|
||||
@@ -3,6 +3,11 @@ export interface DiscordSettings {
|
||||
webhookUrl: string
|
||||
}
|
||||
|
||||
export interface WeComSettings {
|
||||
enabled: boolean
|
||||
webhookUrl: string
|
||||
}
|
||||
|
||||
/** Notification category - corresponds to backend NotificationCategory */
|
||||
export type NotificationCategory = 'scan' | 'vulnerability' | 'asset' | 'system'
|
||||
|
||||
@@ -16,6 +21,7 @@ export interface NotificationCategories {
|
||||
|
||||
export interface NotificationSettings {
|
||||
discord: DiscordSettings
|
||||
wecom: WeComSettings
|
||||
categories: NotificationCategories
|
||||
}
|
||||
|
||||
@@ -26,5 +32,6 @@ export type UpdateNotificationSettingsRequest = NotificationSettings
|
||||
export interface UpdateNotificationSettingsResponse {
|
||||
message: string
|
||||
discord: DiscordSettings
|
||||
wecom: WeComSettings
|
||||
categories: NotificationCategories
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user