feat(alerts): 飞书通知支持 Web 界面配置(数据库存储+热更新)

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 推送时按数据库配置判断

配置无需改文件/重启,前端界面直接保存即生效。
This commit is contained in:
Your Name
2026-08-12 13:14:38 +08:00
parent 717fa31456
commit 365ab73e7d
7 changed files with 274 additions and 24 deletions
+53 -7
View File
@@ -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",
+1
View File
@@ -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
+31
View File
@@ -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
+3 -9
View File
@@ -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:
def _get_feishu_service(db=None):
from app.services.feishu_service import get_feishu_service
_feishu_service = get_feishu_service()
return _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,
+66 -7
View File
@@ -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:
"""用一组值构造 FeishuServiceapi 层用)"""
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)
+13
View File
@@ -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')
}
}
+106
View File
@@ -13,6 +13,10 @@
<el-icon><Check /></el-icon>
全部确认
</el-button>
<el-button @click="openFeishuDialog">
<el-icon><ChatDotRound /></el-icon>
飞书通知设置
</el-button>
</el-button-group>
</div>
</template>
@@ -231,6 +235,47 @@
<el-button type="primary" @click="addToWhitelist" :loading="submitting">确定</el-button>
</template>
</el-dialog>
<!-- 飞书通知设置弹窗 -->
<el-dialog v-model="feishuDialogVisible" title="飞书通知设置" width="560px">
<el-alert type="info" :closable="false" style="margin-bottom: 16px"
title="在飞书群添加「自定义机器人」,将 Webhook 地址填入下方即可。开启后,检测到新告警会自动推送到飞书群。"
show-icon />
<el-form label-width="110px" label-position="left">
<el-form-item label="启用通知">
<el-switch v-model="feishuForm.feishu_enabled" active-text="开启" inactive-text="关闭" />
</el-form-item>
<el-form-item label="Webhook 地址">
<el-input v-model="feishuForm.feishu_webhook_url" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxxx" />
</el-form-item>
<el-form-item label="签名密钥">
<el-input v-model="feishuForm.feishu_secret" show-password
:placeholder="feishuForm.feishu_secret_set ? '已设置(留空则不修改)' : '机器人开启签名校验时填写'" />
<div class="form-tip">机器人开启签名校验时必填未开启留空即可</div>
</el-form-item>
<el-form-item label="最低级别">
<el-select v-model="feishuForm.feishu_min_severity" style="width: 200px">
<el-option label="全部 (info起)" value="info" />
<el-option label="警告及以上" value="warning" />
<el-option label="错误及以上" value="error" />
<el-option label="仅严重" value="critical" />
</el-select>
<div class="form-tip">仅推送大于等于所选级别的告警</div>
</el-form-item>
<el-form-item label="类型白名单">
<el-input v-model="feishuForm.feishu_alert_types" placeholder="留空=全部推送;如 ip_conflict,device_offline" />
<div class="form-tip">逗号分隔告警类型留空则推送全部类型</div>
</el-form-item>
</el-form>
<div style="color: #909399; font-size: 12px; margin-top: 8px;">
配置保存后立即生效无需重启服务
</div>
<template #footer>
<el-button @click="feishuDialogVisible = false">关闭</el-button>
<el-button @click="testFeishu" :loading="testing">发送测试</el-button>
<el-button type="primary" @click="saveFeishuConfig" :loading="savingFeishu">保存配置</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -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()