From 365ab73e7d7368d169992f6b3522a6cc3c4f3544 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 12 Aug 2026 13:14:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(alerts):=20=E9=A3=9E=E4=B9=A6=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E6=94=AF=E6=8C=81=20Web=20=E7=95=8C=E9=9D=A2=E9=85=8D?= =?UTF-8?q?=E7=BD=AE(=E6=95=B0=E6=8D=AE=E5=BA=93=E5=AD=98=E5=82=A8+?= =?UTF-8?q?=E7=83=AD=E6=9B=B4=E6=96=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增 notification_config 模型,飞书配置存数据库(单行id=1) 2. feishu_service 改为从数据库读配置,支持热更新,Web保存即生效 3. alerts.py 新增接口: - GET /alerts/notify/feishu/config 读配置(secret只返回是否已设) - PUT /alerts/notify/feishu/config 保存配置+热更新 (status/test 同步改为走数据库配置) 4. 前端告警中心增加「飞书通知设置」弹窗: - 开关/Webhook/签名密钥/最低级别/类型白名单 - 保存配置、发送测试按钮 5. alert_service.create_alert 推送时按数据库配置判断 配置无需改文件/重启,前端界面直接保存即生效。 --- backend/app/api/v1/alerts.py | 60 ++++++++++++-- backend/app/main.py | 1 + backend/app/models/notification.py | 31 ++++++++ backend/app/services/alert_service.py | 14 +--- backend/app/services/feishu_service.py | 73 +++++++++++++++-- frontend/src/api/index.js | 13 +++ frontend/src/views/Alerts.vue | 106 +++++++++++++++++++++++++ 7 files changed, 274 insertions(+), 24 deletions(-) create mode 100644 backend/app/models/notification.py diff --git a/backend/app/api/v1/alerts.py b/backend/app/api/v1/alerts.py index 9a48cd6..99dd7b6 100644 --- a/backend/app/api/v1/alerts.py +++ b/backend/app/api/v1/alerts.py @@ -185,11 +185,57 @@ def detect_offline_devices(db: Session = Depends(get_db)): # ========== 飞书告警通知 ========== -@router.get("/notify/feishu/status", summary="获取飞书通知配置状态") -def get_feishu_status(): - """查看飞书通知是否已配置(不返回密钥等敏感信息)""" +@router.get("/notify/feishu/config", summary="获取飞书通知配置") +def get_feishu_config(db: Session = Depends(get_db)): + """读取飞书通知配置(Web 界面显示用,不返回 secret 原文只返回是否已设置)""" + from app.models.notification import NotificationConfig + row = NotificationConfig.get_singleton(db) + return { + "feishu_enabled": bool(row.feishu_enabled), + "feishu_webhook_url": row.feishu_webhook_url or "", + "feishu_secret_set": bool(row.feishu_secret), + "feishu_min_severity": row.feishu_min_severity or "warning", + "feishu_alert_types": row.feishu_alert_types or "", + } + + +@router.put("/notify/feishu/config", summary="保存飞书通知配置") +def update_feishu_config( + feishu_enabled: bool, + feishu_webhook_url: str = "", + feishu_secret: str = "", + feishu_min_severity: str = "warning", + feishu_alert_types: str = "", + db: Session = Depends(get_db), +): + """保存飞书通知配置到数据库并热更新(无需重启后端)""" + from app.models.notification import NotificationConfig + row = NotificationConfig.get_singleton(db) + row.feishu_enabled = bool(feishu_enabled) + row.feishu_webhook_url = (feishu_webhook_url or "").strip() + # 仅当传入新 secret 时才覆盖(避免 UI 回显时清空已有 secret) + if feishu_secret: + row.feishu_secret = feishu_secret.strip() + row.feishu_min_severity = feishu_min_severity or "warning" + row.feishu_alert_types = (feishu_alert_types or "").strip() + db.commit() + + # 热更新运行时的飞书服务 + from app.services.feishu_service import reload_feishu_service + svc = reload_feishu_service(db) + + return { + "message": "飞书通知配置已保存并生效", + "success": True, + "configured": svc.is_configured(), + } + + +@router.get("/notify/feishu/status", summary="获取飞书通知运行状态") +def get_feishu_status(db: Session = Depends(get_db)): + """查看飞书通知运行状态""" from app.services.feishu_service import get_feishu_service - svc = get_feishu_service() + svc = get_feishu_service(db) return { "enabled": svc.enabled, "configured": bool(svc.webhook_url), @@ -202,14 +248,14 @@ def get_feishu_status(): @router.post("/notify/feishu/test", summary="发送飞书测试告警") -def test_feishu_notification(): +def test_feishu_notification(db: Session = Depends(get_db)): """发送一条测试告警到飞书,验证 webhook 配置是否正确""" from app.services.feishu_service import get_feishu_service - svc = get_feishu_service() + svc = get_feishu_service(db) if not svc.is_configured(): raise HTTPException( status_code=400, - detail="飞书通知未启用或未配置 webhook。请在 backend/.env 设置 FEISHU_ENABLED=true 和 FEISHU_WEBHOOK_URL" + detail="飞书通知未启用或未配置 webhook。请在告警中心「飞书通知设置」中配置并开启" ) ok = svc.send_alert({ "alert_type": "new_device_detected", diff --git a/backend/app/main.py b/backend/app/main.py index ef399ab..184b0be 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from app.models.snmp import SNMPCredential, NetworkDevice, ARPEntry, MACAddressT from app.models.alert import Alert, AlertRule, WhitelistedMAC from app.models.auth import User, UserRole, UserStatus, Permission, RolePermission, RefreshToken from app.models.audit import AuditLog +from app.models.notification import NotificationConfig @asynccontextmanager diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..a81ed07 --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,31 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text +from sqlalchemy.sql import func +from app.core.database import Base + + +class NotificationConfig(Base): + """通知渠道配置(当前支持飞书机器人)。 + 使用单行记录(id=1)存储,便于 Web 界面读写和热更新。 + """ + __tablename__ = "notification_config" + + id = Column(Integer, primary_key=True) + # 飞书 + feishu_enabled = Column(Boolean, default=False) # 总开关 + feishu_webhook_url = Column(String(500), default="") # 机器人 webhook 地址 + feishu_secret = Column(String(255), default="") # 签名校验密钥(可选) + feishu_min_severity = Column(String(20), default="warning") # 最低告警级别 + feishu_alert_types = Column(Text, default="") # 逗号分隔的类型白名单,空=全部 + + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + @classmethod + def get_singleton(cls, db): + """获取或创建单行配置""" + row = db.query(cls).filter(cls.id == 1).first() + if row is None: + row = cls(id=1) + db.add(row) + db.commit() + db.refresh(row) + return row diff --git a/backend/app/services/alert_service.py b/backend/app/services/alert_service.py index ab4ca5d..d9ad162 100644 --- a/backend/app/services/alert_service.py +++ b/backend/app/services/alert_service.py @@ -13,15 +13,9 @@ from app.models.network import Network, IPAddress from app.models.snmp import NetworkDevice, ARPEntry # 飞书告警通知(延迟导入避免循环依赖) -_feishu_service = None - - -def _get_feishu_service(): - global _feishu_service - if _feishu_service is None: - from app.services.feishu_service import get_feishu_service - _feishu_service = get_feishu_service() - return _feishu_service +def _get_feishu_service(db=None): + from app.services.feishu_service import get_feishu_service + return get_feishu_service(db) logger = logging.getLogger(__name__) @@ -85,7 +79,7 @@ class AlertService: # 飞书告警通知(仅新创建的告警推送;existing 去重返回的不会走到这里,避免刷屏) try: - _get_feishu_service().send_alert({ + _get_feishu_service(db).send_alert({ "alert_type": alert_type.value if hasattr(alert_type, "value") else str(alert_type), "severity": severity.value if hasattr(severity, "value") else str(severity), "title": title, diff --git a/backend/app/services/feishu_service.py b/backend/app/services/feishu_service.py index 78eeee9..f20f92c 100644 --- a/backend/app/services/feishu_service.py +++ b/backend/app/services/feishu_service.py @@ -200,19 +200,59 @@ class FeishuService: _service: Optional[FeishuService] = None -def get_feishu_service() -> FeishuService: - """获取(缓存的)飞书服务实例""" +def _build_from_values(webhook_url="", secret="", enabled=False, + min_severity="warning", alert_types_str="") -> FeishuService: + """用一组值构造 FeishuService(api 层用)""" + if _service is not None: + # 热更新:复用同一实例,更新内部配置 + _service.webhook_url = webhook_url + _service.secret = secret + _service.enabled = bool(enabled) + _service.min_severity = min_severity or "warning" + _service.alert_types = [t.strip() for t in (alert_types_str or "").split(",") if t.strip()] + return _service + return FeishuService( + webhook_url=webhook_url, + secret=secret, + enabled=bool(enabled), + min_severity=min_severity or "warning", + alert_types=[t.strip() for t in (alert_types_str or "").split(",") if t.strip()], + ) + + +def get_feishu_service(db=None) -> FeishuService: + """获取(缓存的)飞书服务实例。 + 优先从数据库 notification_config 读取配置(Web 界面可热更新); + 若无数据库配置则回退到 .env / settings 默认值。 + """ global _service + + # 优先从数据库加载(Web 配置优先) + try: + if db is not None: + from app.models.notification import NotificationConfig + row = db.query(NotificationConfig).filter(NotificationConfig.id == 1).first() + if row is not None: + return _build_from_values( + webhook_url=row.feishu_webhook_url or "", + secret=row.feishu_secret or "", + enabled=row.feishu_enabled or False, + min_severity=row.feishu_min_severity or "warning", + alert_types_str=row.feishu_alert_types or "", + ) + except Exception as e: + logger.warning(f"从数据库读取飞书配置失败,回退 .env: {e}") + + # 回退到 settings(.env) if _service is None: try: from app.core.config import settings - alert_types = [t.strip() for t in (settings.FEISHU_ALERT_TYPES or "").split(",") if t.strip()] - _service = FeishuService( + _service = _build_from_values( webhook_url=settings.FEISHU_WEBHOOK_URL, secret=settings.FEISHU_SECRET, enabled=settings.FEISHU_ENABLED, min_severity=settings.FEISHU_MIN_SEVERITY or "warning", - alert_types=alert_types, + alert_types_str=settings.FEISHU_ALERT_TYPES or "", ) except Exception as e: logger.error(f"初始化飞书服务失败: {e}") @@ -220,6 +260,25 @@ def get_feishu_service() -> FeishuService: return _service -def send_alert_notification(alert_data: Dict[str, Any]) -> bool: +def reload_feishu_service(db) -> FeishuService: + """强制从数据库重新加载飞书配置(Web 界面保存配置后调用)""" + global _service + from app.models.notification import NotificationConfig + row = db.query(NotificationConfig).filter(NotificationConfig.id == 1).first() + if row is None: + row = NotificationConfig.get_singleton(db) + svc = _build_from_values( + webhook_url=row.feishu_webhook_url or "", + secret=row.feishu_secret or "", + enabled=row.feishu_enabled or False, + min_severity=row.feishu_min_severity or "warning", + alert_types_str=row.feishu_alert_types or "", + ) + global _service + _service = svc + return svc + + +def send_alert_notification(alert_data: Dict[str, Any], db=None) -> bool: """便捷函数:发送一条告警通知""" - return get_feishu_service().send_alert(alert_data) + return get_feishu_service(db).send_alert(alert_data) diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 6ecd2fc..add7273 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -131,6 +131,19 @@ export const alertApi = { }, removeFromWhitelist(id) { return api.delete(`/alerts/whitelist/macs/${id}`) + }, + // 飞书通知配置 + getFeishuConfig() { + return api.get('/alerts/notify/feishu/config') + }, + saveFeishuConfig(data) { + return api.put('/alerts/notify/feishu/config', {}, { params: data }) + }, + getFeishuStatus() { + return api.get('/alerts/notify/feishu/status') + }, + testFeishu() { + return api.post('/alerts/notify/feishu/test') } } diff --git a/frontend/src/views/Alerts.vue b/frontend/src/views/Alerts.vue index e869e48..be2a79d 100644 --- a/frontend/src/views/Alerts.vue +++ b/frontend/src/views/Alerts.vue @@ -13,6 +13,10 @@ 全部确认 + + + 飞书通知设置 + @@ -231,6 +235,47 @@ 确定 + + + + + + + + + + + + + +
机器人开启「签名校验」时必填;未开启留空即可
+
+ + + + + + + +
仅推送大于等于所选级别的告警
+
+ + +
逗号分隔告警类型,留空则推送全部类型
+
+
+
+ 配置保存后立即生效,无需重启服务。 +
+ +
@@ -463,6 +508,67 @@ const getAlertTypeText = (type) => { // 旧 formatDate 已由 @/utils/datetime 统一替代 +// ---- 飞书通知配置 ---- +const feishuDialogVisible = ref(false) +const savingFeishu = ref(false) +const testing = ref(false) +const feishuForm = reactive({ + feishu_enabled: false, + feishu_webhook_url: '', + feishu_secret: '', + feishu_secret_set: false, + feishu_min_severity: 'warning', + feishu_alert_types: '' +}) + +const openFeishuDialog = async () => { + feishuDialogVisible.value = true + try { + const cfg = await alertApi.getFeishuConfig() + feishuForm.feishu_enabled = !!cfg.feishu_enabled + feishuForm.feishu_webhook_url = cfg.feishu_webhook_url || '' + feishuForm.feishu_secret = '' + feishuForm.feishu_secret_set = !!cfg.feishu_secret_set + feishuForm.feishu_min_severity = cfg.feishu_min_severity || 'warning' + feishuForm.feishu_alert_types = cfg.feishu_alert_types || '' + } catch (error) { + ElMessage.error('加载飞书配置失败') + } +} + +const saveFeishuConfig = async () => { + savingFeishu.value = true + try { + const params = { + feishu_enabled: feishuForm.feishu_enabled, + feishu_webhook_url: feishuForm.feishu_webhook_url, + feishu_secret: feishuForm.feishu_secret, + feishu_min_severity: feishuForm.feishu_min_severity, + feishu_alert_types: feishuForm.feishu_alert_types + } + const res = await alertApi.saveFeishuConfig(params) + ElMessage.success(res.message || '飞书配置已保存') + feishuForm.feishu_secret = '' + feishuForm.feishu_secret_set = true + } catch (error) { + ElMessage.error('保存飞书配置失败') + } finally { + savingFeishu.value = false + } +} + +const testFeishu = async () => { + testing.value = true + try { + const res = await alertApi.testFeishu() + ElMessage.success(res.message || '测试通知已发送') + } catch (error) { + ElMessage.error(error?.response?.data?.detail || '发送测试失败,请检查配置') + } finally { + testing.value = false + } +} + onMounted(() => { loadStats() loadAlerts()