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()