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