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="获取飞书通知配置状态") @router.get("/notify/feishu/config", summary="获取飞书通知配置")
def get_feishu_status(): 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 from app.services.feishu_service import get_feishu_service
svc = get_feishu_service() svc = get_feishu_service(db)
return { return {
"enabled": svc.enabled, "enabled": svc.enabled,
"configured": bool(svc.webhook_url), "configured": bool(svc.webhook_url),
@@ -202,14 +248,14 @@ def get_feishu_status():
@router.post("/notify/feishu/test", summary="发送飞书测试告警") @router.post("/notify/feishu/test", summary="发送飞书测试告警")
def test_feishu_notification(): def test_feishu_notification(db: Session = Depends(get_db)):
"""发送一条测试告警到飞书,验证 webhook 配置是否正确""" """发送一条测试告警到飞书,验证 webhook 配置是否正确"""
from app.services.feishu_service import get_feishu_service from app.services.feishu_service import get_feishu_service
svc = get_feishu_service() svc = get_feishu_service(db)
if not svc.is_configured(): if not svc.is_configured():
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="飞书通知未启用或未配置 webhook。请在 backend/.env 设置 FEISHU_ENABLED=true 和 FEISHU_WEBHOOK_URL" detail="飞书通知未启用或未配置 webhook。请在告警中心「飞书通知设置」中配置并开启"
) )
ok = svc.send_alert({ ok = svc.send_alert({
"alert_type": "new_device_detected", "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.alert import Alert, AlertRule, WhitelistedMAC
from app.models.auth import User, UserRole, UserStatus, Permission, RolePermission, RefreshToken from app.models.auth import User, UserRole, UserStatus, Permission, RolePermission, RefreshToken
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.models.notification import NotificationConfig
@asynccontextmanager @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
+4 -10
View File
@@ -13,15 +13,9 @@ from app.models.network import Network, IPAddress
from app.models.snmp import NetworkDevice, ARPEntry from app.models.snmp import NetworkDevice, ARPEntry
# 飞书告警通知(延迟导入避免循环依赖) # 飞书告警通知(延迟导入避免循环依赖)
_feishu_service = None def _get_feishu_service(db=None):
from app.services.feishu_service import get_feishu_service
return get_feishu_service(db)
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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -85,7 +79,7 @@ class AlertService:
# 飞书告警通知(仅新创建的告警推送;existing 去重返回的不会走到这里,避免刷屏) # 飞书告警通知(仅新创建的告警推送;existing 去重返回的不会走到这里,避免刷屏)
try: 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), "alert_type": alert_type.value if hasattr(alert_type, "value") else str(alert_type),
"severity": severity.value if hasattr(severity, "value") else str(severity), "severity": severity.value if hasattr(severity, "value") else str(severity),
"title": title, "title": title,
+66 -7
View File
@@ -200,19 +200,59 @@ class FeishuService:
_service: Optional[FeishuService] = None _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 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: if _service is None:
try: try:
from app.core.config import settings from app.core.config import settings
alert_types = [t.strip() for t in (settings.FEISHU_ALERT_TYPES or "").split(",") if t.strip()] _service = _build_from_values(
_service = FeishuService(
webhook_url=settings.FEISHU_WEBHOOK_URL, webhook_url=settings.FEISHU_WEBHOOK_URL,
secret=settings.FEISHU_SECRET, secret=settings.FEISHU_SECRET,
enabled=settings.FEISHU_ENABLED, enabled=settings.FEISHU_ENABLED,
min_severity=settings.FEISHU_MIN_SEVERITY or "warning", min_severity=settings.FEISHU_MIN_SEVERITY or "warning",
alert_types=alert_types, alert_types_str=settings.FEISHU_ALERT_TYPES or "",
) )
except Exception as e: except Exception as e:
logger.error(f"初始化飞书服务失败: {e}") logger.error(f"初始化飞书服务失败: {e}")
@@ -220,6 +260,25 @@ def get_feishu_service() -> FeishuService:
return _service 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) { removeFromWhitelist(id) {
return api.delete(`/alerts/whitelist/macs/${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-icon><Check /></el-icon>
全部确认 全部确认
</el-button> </el-button>
<el-button @click="openFeishuDialog">
<el-icon><ChatDotRound /></el-icon>
飞书通知设置
</el-button>
</el-button-group> </el-button-group>
</div> </div>
</template> </template>
@@ -231,6 +235,47 @@
<el-button type="primary" @click="addToWhitelist" :loading="submitting">确定</el-button> <el-button type="primary" @click="addToWhitelist" :loading="submitting">确定</el-button>
</template> </template>
</el-dialog> </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> </div>
</template> </template>
@@ -463,6 +508,67 @@ const getAlertTypeText = (type) => {
// 旧 formatDate 已由 @/utils/datetime 统一替代 // 旧 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(() => { onMounted(() => {
loadStats() loadStats()
loadAlerts() loadAlerts()