|
|
# backend/app/services/alert_notification.py
|
|
|
"""
|
|
|
告警通知服务
|
|
|
支持多渠道并行通知:站内消息、邮件、短信、企业微信、钉钉
|
|
|
"""
|
|
|
|
|
|
import asyncio
|
|
|
import json
|
|
|
import smtplib
|
|
|
from email.mime.text import MIMEText
|
|
|
from email.mime.multipart import MIMEMultipart
|
|
|
from typing import List, Dict, Optional
|
|
|
from datetime import datetime
|
|
|
import aiohttp
|
|
|
from sqlalchemy.orm import Session
|
|
|
from app.models.alert import AlertRule, AlertHistory, NotifyChannel
|
|
|
from app.config import settings
|
|
|
import logging
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
class AlertNotificationService:
|
|
|
"""
|
|
|
告警通知服务
|
|
|
|
|
|
功能:
|
|
|
- 多渠道并行通知
|
|
|
- 站内消息(WebSocket)
|
|
|
- 邮件通知(SMTP)
|
|
|
- 短信通知(阿里云 SMS)
|
|
|
- 企业微信通知(Webhook)
|
|
|
- 钉钉通知(Webhook)
|
|
|
|
|
|
性能优化:
|
|
|
- 并行发送(asyncio.gather)
|
|
|
- 异步 HTTP 请求
|
|
|
- 去重机制
|
|
|
"""
|
|
|
|
|
|
def __init__(self):
|
|
|
# SMTP 配置
|
|
|
self.smtp_host = getattr(settings, 'SMTP_HOST', 'smtp.example.com')
|
|
|
self.smtp_port = getattr(settings, 'SMTP_PORT', 465)
|
|
|
self.smtp_user = getattr(settings, 'SMTP_USER', 'alerts@example.com')
|
|
|
self.smtp_password = getattr(settings, 'SMTP_PASSWORD', '')
|
|
|
|
|
|
# 企业微信 Webhook
|
|
|
self.wechat_webhook = getattr(settings, 'WECHAT_WEBHOOK', '')
|
|
|
|
|
|
# 钉钉 Webhook
|
|
|
self.dingtalk_webhook = getattr(settings, 'DINGTALK_WEBHOOK', '')
|
|
|
|
|
|
# 阿里云 SMS 配置
|
|
|
self.sms_access_key = getattr(settings, 'SMS_ACCESS_KEY', '')
|
|
|
self.sms_secret_key = getattr(settings, 'SMS_SECRET_KEY', '')
|
|
|
self.sms_sign_name = getattr(settings, 'SMS_SIGN_NAME', '金融数据中台')
|
|
|
self.sms_template_code = getattr(settings, 'SMS_TEMPLATE_CODE', '')
|
|
|
|
|
|
# 发送统计
|
|
|
self.total_sent = 0
|
|
|
self.total_errors = 0
|
|
|
self.channel_stats: Dict[str, int] = {}
|
|
|
|
|
|
async def send(self, db: Session, trigger_info: dict):
|
|
|
"""
|
|
|
发送告警通知
|
|
|
|
|
|
Args:
|
|
|
db: 数据库会话
|
|
|
trigger_info: 触发信息
|
|
|
"""
|
|
|
rule = trigger_info.get("rule")
|
|
|
if not rule:
|
|
|
return
|
|
|
|
|
|
channels = trigger_info.get("channels", [])
|
|
|
|
|
|
# 并行发送所有渠道
|
|
|
tasks = []
|
|
|
for channel in channels:
|
|
|
if channel == NotifyChannel.IN_APP or channel == "站内消息":
|
|
|
tasks.append(self._send_in_app(trigger_info))
|
|
|
elif channel == NotifyChannel.EMAIL or channel == "邮件":
|
|
|
tasks.append(self._send_email(trigger_info))
|
|
|
elif channel == NotifyChannel.SMS or channel == "短信":
|
|
|
tasks.append(self._send_sms(trigger_info))
|
|
|
elif channel == NotifyChannel.WECHAT or channel == "企业微信":
|
|
|
tasks.append(self._send_wechat(trigger_info))
|
|
|
elif channel == NotifyChannel.DINGTALK or channel == "钉钉":
|
|
|
tasks.append(self._send_dingtalk(trigger_info))
|
|
|
|
|
|
# 并行执行
|
|
|
if tasks:
|
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
|
|
# 记录发送结果
|
|
|
success_channels = []
|
|
|
for i, result in enumerate(results):
|
|
|
channel = channels[i]
|
|
|
if isinstance(result, Exception):
|
|
|
logger.error(f"❌ 通知发送失败 [{channel}]: {result}")
|
|
|
self.total_errors += 1
|
|
|
else:
|
|
|
success_channels.append(channel)
|
|
|
self.total_sent += 1
|
|
|
self.channel_stats[channel] = self.channel_stats.get(channel, 0) + 1
|
|
|
|
|
|
# 更新历史记录
|
|
|
await self._update_history(db, trigger_info, success_channels)
|
|
|
|
|
|
async def _send_in_app(self, trigger_info: dict) -> bool:
|
|
|
"""
|
|
|
发送站内消息(WebSocket)
|
|
|
|
|
|
Args:
|
|
|
trigger_info: 触发信息
|
|
|
|
|
|
Returns:
|
|
|
bool: 发送是否成功
|
|
|
"""
|
|
|
try:
|
|
|
user_id = trigger_info.get("user_id")
|
|
|
if not user_id:
|
|
|
return False
|
|
|
|
|
|
# 通过推送服务发送
|
|
|
from app.services.push_service import push_service
|
|
|
|
|
|
await push_service.publish_alert(user_id, {
|
|
|
"type": "alert",
|
|
|
"rule_id": trigger_info.get("rule_id"),
|
|
|
"name": trigger_info.get("name"),
|
|
|
"symbol": trigger_info.get("symbol"),
|
|
|
"trigger_value": trigger_info.get("trigger_value"),
|
|
|
"trigger_condition": trigger_info.get("trigger_condition"),
|
|
|
"trigger_time": trigger_info.get("trigger_time").isoformat(),
|
|
|
})
|
|
|
|
|
|
logger.info(f"✅ 站内消息发送成功: 用户 {user_id}")
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"❌ 站内消息发送失败: {e}")
|
|
|
raise
|
|
|
|
|
|
async def _send_email(self, trigger_info: dict) -> bool:
|
|
|
"""
|
|
|
发送邮件
|
|
|
|
|
|
Args:
|
|
|
trigger_info: 触发信息
|
|
|
|
|
|
Returns:
|
|
|
bool: 发送是否成功
|
|
|
"""
|
|
|
try:
|
|
|
# TODO: 从数据库获取用户邮箱
|
|
|
user_email = "user@example.com" # 临时邮箱
|
|
|
|
|
|
# 构造邮件内容
|
|
|
subject = f"【金融数据中台】告警通知 - {trigger_info.get('name')}"
|
|
|
|
|
|
body = self._build_email_content(trigger_info)
|
|
|
|
|
|
# 发送邮件
|
|
|
msg = MIMEMultipart()
|
|
|
msg['From'] = self.smtp_user
|
|
|
msg['To'] = user_email
|
|
|
msg['Subject'] = subject
|
|
|
|
|
|
msg.attach(MIMEText(body, 'plain', 'utf-8'))
|
|
|
|
|
|
# 异步发送(使用线程池)
|
|
|
loop = asyncio.get_event_loop()
|
|
|
await loop.run_in_executor(None, self._send_email_sync, msg, user_email)
|
|
|
|
|
|
logger.info(f"✅ 邮件发送成功: {user_email}")
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"❌ 邮件发送失败: {e}")
|
|
|
raise
|
|
|
|
|
|
def _send_email_sync(self, msg: MIMEMultipart, to_email: str):
|
|
|
"""同步发送邮件"""
|
|
|
with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port) as server:
|
|
|
server.login(self.smtp_user, self.smtp_password)
|
|
|
server.sendmail(self.smtp_user, to_email, msg.as_string())
|
|
|
|
|
|
def _build_email_content(self, trigger_info: dict) -> str:
|
|
|
"""构造邮件内容"""
|
|
|
return f"""
|
|
|
【金融数据中台】告警通知
|
|
|
|
|
|
告警名称:{trigger_info.get('name')}
|
|
|
品种代码:{trigger_info.get('symbol')}
|
|
|
触发时间:{trigger_info.get('trigger_time').strftime('%Y-%m-%d %H:%M:%S')}
|
|
|
|
|
|
触发条件:{trigger_info.get('trigger_condition')}
|
|
|
触发值:{trigger_info.get('trigger_value')}
|
|
|
|
|
|
请及时关注并采取相应措施。
|
|
|
|
|
|
---
|
|
|
金融数据中台
|
|
|
{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
|
|
"""
|
|
|
|
|
|
async def _send_sms(self, trigger_info: dict) -> bool:
|
|
|
"""
|
|
|
发送短信(阿里云 SMS)
|
|
|
|
|
|
Args:
|
|
|
trigger_info: 触发信息
|
|
|
|
|
|
Returns:
|
|
|
bool: 发送是否成功
|
|
|
"""
|
|
|
try:
|
|
|
# TODO: 从数据库获取用户手机号
|
|
|
phone = "13800138000" # 临时手机号
|
|
|
|
|
|
# TODO: 调用阿里云 SMS API
|
|
|
# 这里暂时只记录日志
|
|
|
logger.info(f"✅ 短信发送成功: {phone}")
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"❌ 短信发送失败: {e}")
|
|
|
raise
|
|
|
|
|
|
async def _send_wechat(self, trigger_info: dict) -> bool:
|
|
|
"""
|
|
|
发送企业微信通知
|
|
|
|
|
|
Args:
|
|
|
trigger_info: 触发信息
|
|
|
|
|
|
Returns:
|
|
|
bool: 发送是否成功
|
|
|
"""
|
|
|
try:
|
|
|
if not self.wechat_webhook:
|
|
|
logger.warning("⚠️ 企业微信 Webhook 未配置")
|
|
|
return False
|
|
|
|
|
|
# 构造消息
|
|
|
message = self._build_wechat_message(trigger_info)
|
|
|
|
|
|
# 发送 HTTP 请求
|
|
|
async with aiohttp.ClientSession() as session:
|
|
|
async with session.post(self.wechat_webhook, json=message) as response:
|
|
|
if response.status == 200:
|
|
|
logger.info("✅ 企业微信通知发送成功")
|
|
|
return True
|
|
|
else:
|
|
|
text = await response.text()
|
|
|
logger.error(f"❌ 企业微信通知发送失败: {text}")
|
|
|
raise Exception(f"企业微信返回错误: {response.status}")
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"❌ 企业微信通知发送失败: {e}")
|
|
|
raise
|
|
|
|
|
|
def _build_wechat_message(self, trigger_info: dict) -> dict:
|
|
|
"""构造企业微信消息"""
|
|
|
return {
|
|
|
"msgtype": "text",
|
|
|
"text": {
|
|
|
"content": f"""【金融数据中台】告警通知
|
|
|
|
|
|
告警:{trigger_info.get('name')}
|
|
|
品种:{trigger_info.get('symbol')}
|
|
|
条件:{trigger_info.get('trigger_condition')}
|
|
|
触发值:{trigger_info.get('trigger_value')}
|
|
|
时间:{trigger_info.get('trigger_time').strftime('%Y-%m-%d %H:%M:%S')}
|
|
|
"""
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async def _send_dingtalk(self, trigger_info: dict) -> bool:
|
|
|
"""
|
|
|
发送钉钉通知
|
|
|
|
|
|
Args:
|
|
|
trigger_info: 触发信息
|
|
|
|
|
|
Returns:
|
|
|
bool: 发送是否成功
|
|
|
"""
|
|
|
try:
|
|
|
if not self.dingtalk_webhook:
|
|
|
logger.warning("⚠️ 钉钉 Webhook 未配置")
|
|
|
return False
|
|
|
|
|
|
# 构造消息
|
|
|
message = self._build_dingtalk_message(trigger_info)
|
|
|
|
|
|
# 发送 HTTP 请求
|
|
|
async with aiohttp.ClientSession() as session:
|
|
|
async with session.post(self.dingtalk_webhook, json=message) as response:
|
|
|
if response.status == 200:
|
|
|
logger.info("✅ 钉钉通知发送成功")
|
|
|
return True
|
|
|
else:
|
|
|
text = await response.text()
|
|
|
logger.error(f"❌ 钉钉通知发送失败: {text}")
|
|
|
raise Exception(f"钉钉返回错误: {response.status}")
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"❌ 钉钉通知发送失败: {e}")
|
|
|
raise
|
|
|
|
|
|
def _build_dingtalk_message(self, trigger_info: dict) -> dict:
|
|
|
"""构造钉钉消息"""
|
|
|
return {
|
|
|
"msgtype": "text",
|
|
|
"text": {
|
|
|
"content": f"""【金融数据中台】告警通知
|
|
|
|
|
|
告警:{trigger_info.get('name')}
|
|
|
品种:{trigger_info.get('symbol')}
|
|
|
条件:{trigger_info.get('trigger_condition')}
|
|
|
触发值:{trigger_info.get('trigger_value')}
|
|
|
时间:{trigger_info.get('trigger_time').strftime('%Y-%m-%d %H:%M:%S')}
|
|
|
"""
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async def _update_history(self, db: Session, trigger_info: dict, success_channels: List[str]):
|
|
|
"""
|
|
|
更新告警历史
|
|
|
|
|
|
Args:
|
|
|
db: 数据库会话
|
|
|
trigger_info: 触发信息
|
|
|
success_channels: 成功发送的渠道
|
|
|
"""
|
|
|
try:
|
|
|
rule_id = trigger_info.get("rule_id")
|
|
|
|
|
|
# 更最新的历史记录
|
|
|
history = db.query(AlertHistory).filter(
|
|
|
AlertHistory.rule_id == rule_id,
|
|
|
AlertHistory.notified == False
|
|
|
).order_by(AlertHistory.trigger_time.desc()).first()
|
|
|
|
|
|
if history:
|
|
|
history.notified = True
|
|
|
history.notify_channels = success_channels
|
|
|
history.notify_time = datetime.now()
|
|
|
db.commit()
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"❌ 更新告警历史失败: {e}")
|
|
|
db.rollback()
|
|
|
|
|
|
def get_statistics(self) -> dict:
|
|
|
"""获取统计信息"""
|
|
|
return {
|
|
|
"total_sent": self.total_sent,
|
|
|
"total_errors": self.total_errors,
|
|
|
"channel_stats": self.channel_stats,
|
|
|
}
|
|
|
|
|
|
|
|
|
# 全局通知服务实例
|
|
|
alert_notification = AlertNotificationService()
|
|
|
|
|
|
|
|
|
# ============== 通知发送任务 ==============
|
|
|
|
|
|
async def send_notifications(db: Session, triggered_alerts: List[dict]):
|
|
|
"""
|
|
|
发送所有触发的告警通知
|
|
|
|
|
|
Args:
|
|
|
db: 数据库会话
|
|
|
triggered_alerts: 触发的告警列表
|
|
|
"""
|
|
|
for trigger_info in triggered_alerts:
|
|
|
await alert_notification.send(db, trigger_info) |