""" 预警通知推送模块 — 管理会计OS 支持渠道:企业微信 (群机器人/应用消息)、邮件 """ import json import logging import urllib.request import urllib.error import smtplib from email.mime.text import MIMEText from email.header import Header from datetime import datetime from typing import Optional logger = logging.getLogger("cma.notifier") # ============================================================ # 企业微信机器人推送 # ============================================================ def send_wecom_robot(webhook_url: str, title: str, content: str, alert_level: str = "yellow") -> dict: """通过企业微信群机器人发送告警""" color_tag = {"red": "🔴", "yellow": "🟡", "green": "🟢"}.get(alert_level, "⚪") msg = { "msgtype": "markdown", "markdown": { "content": f"## {color_tag} 管理会计OS预警通知\n" f"**{title}**\n\n" f"{content}\n\n" f"---\n" f"⏰ {datetime.now().strftime('%Y-%m-%d %H:%M')}" } } data = json.dumps(msg).encode("utf-8") req = urllib.request.Request(webhook_url, data=data, headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=10) as resp: result = json.loads(resp.read().decode()) if result.get("errcode") == 0: return {"success": True, "message": "已推送至企业微信群"} else: return {"success": False, "message": f"推送失败: {result.get('errmsg', '未知错误')}"} except Exception as e: return {"success": False, "message": f"推送异常: {str(e)}"} # ============================================================ # 企业微信应用消息推送(通过自建应用 message/send API) # ============================================================ def send_wecom_app(corp_id: str, corp_secret: str, agent_id: str, touser: str, title: str, content: str, alert_level: str = "yellow") -> dict: """通过企微自建应用发送应用消息""" import requests try: token_url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corp_id}&corpsecret={corp_secret}" r = requests.get(token_url, timeout=10) token_data = r.json() if token_data.get("errcode") != 0: return {"success": False, "message": f"获取token失败: {token_data.get('errmsg', '')}"} access_token = token_data["access_token"] color_tag = {"red": "🔴", "yellow": "🟡", "green": "🟢"}.get(alert_level, "⚪") md = "## " + color_tag + " 管理会计OS预警通知\n\n" md += "**" + title + "**\n\n" md += content + "\n\n---\n" md += "⏰ " + datetime.now().strftime("%Y-%m-%d %H:%M") payload = { "touser": touser, "msgtype": "markdown", "agentid": int(agent_id), "markdown": {"content": md}, "safe": 0, } send_url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + access_token r2 = requests.post(send_url, json=payload, timeout=10) send_data = r2.json() if send_data.get("errcode") == 0: return {"success": True, "message": f"已推送到企微用户 {touser}"} else: return {"success": False, "message": f"推送失败: {send_data.get('errmsg', '')}"} except Exception as e: return {"success": False, "message": f"推送异常: {str(e)}"} # ============================================================ # 邮件推送 # ============================================================ def send_mail(smtp_config: dict, to_addrs: list, title: str, content: str) -> dict: """通过 SMTP 发送邮件告警""" try: msg = MIMEText(content, "plain", "utf-8") msg["Subject"] = Header(f"[管理会计OS预警] {title}", "utf-8") msg["From"] = smtp_config.get("from_addr", "") msg["To"] = ", ".join(to_addrs) host = smtp_config.get("host", "smtp.qq.com") port = int(smtp_config.get("port", 465)) user = smtp_config.get("user", "") password = smtp_config.get("password", "") use_ssl = smtp_config.get("use_ssl", True) if use_ssl: server = smtplib.SMTP_SSL(host, port, timeout=10) else: server = smtplib.SMTP(host, port, timeout=10) server.starttls() server.login(user, password) server.sendmail(user, to_addrs, msg.as_string()) server.quit() return {"success": True, "message": f"已发送邮件至 {', '.join(to_addrs)}"} except Exception as e: return {"success": False, "message": f"邮件发送失败: {str(e)}"} # ============================================================ # 主推送函数 # ============================================================ def push_alert(alert: dict, channels: list[dict]) -> list[dict]: """向所有已启用渠道推送一条预警""" results = [] alert_level = alert.get("alert_level", "yellow") title = alert.get("alert_message", "预警通知") content = _build_content(alert) for ch in channels: if not ch.get("enabled", True): continue ch_type = ch.get("channel_type", "") config = ch.get("config", {}) result = {"channel": ch_type, "channel_name": ch.get("name", ""), "success": False} if ch_type == "wecom": webhook = config.get("webhook_url", "") if webhook: result = send_wecom_robot(webhook, title, content, alert_level) result["channel"] = "wecom" elif ch_type == "wecom_app": result = send_wecom_app( corp_id=config.get("corp_id", ""), corp_secret=config.get("corp_secret", ""), agent_id=config.get("agent_id", ""), touser=config.get("touser", ""), title=title, content=content, alert_level=alert_level, ) result["channel"] = "wecom_app" elif ch_type == "mail": to_list = config.get("to", []) if to_list: result = send_mail(config, to_list, title, content) result["channel"] = "mail" results.append({**result, "channel_name": ch.get("name", "")}) return results def _build_content(alert: dict) -> str: """构建预警详情内容""" parts = [ f"KPI: {alert.get('kpi_name', '未知')}", f"期间: {alert.get('period', '')}", f"实际值: {alert.get('actual_value', '-')}", f"目标值: {alert.get('target_value', '-')}", f"预警级别: {'🔴 紧急' if alert.get('alert_level') == 'red' else '🟡 警告'}", ] if alert.get("resolution"): parts.append(f"处理建议: {alert['resolution']}") return "\n".join(parts) # ============================================================ # 从数据库加载渠道配置并推送待处理预警 # ============================================================ def push_pending_alerts(db_session) -> int: """推送所有待处理预警""" from app.models import NotificationChannel, NotificationLog, KPIAlert, KPIDefinition, KPIValue # 加载已启用的通知渠道 channels = db_session.query(NotificationChannel).filter( NotificationChannel.enabled == True ).all() if not channels: logger.info("无已启用的通知渠道,跳过推送") return 0 # 查待处理的预警 alerts = db_session.query(KPIAlert).filter( KPIAlert.status == "pending" ).all() if not alerts: logger.info("无待处理预警") return 0 channel_configs = [json.loads(json.dumps({ "name": c.name, "channel_type": c.channel_type, "config": c.config, "enabled": c.enabled })) for c in channels] pushed = 0 for alert in alerts: # 获取预警详情 kpi = db_session.query(KPIDefinition).filter( KPIDefinition.id == alert.kpi_id ).first() kpi_value = db_session.query(KPIValue).filter( KPIValue.id == alert.kpi_value_id ).first() alert_data = { "alert_level": alert.alert_level, "alert_message": alert.alert_message, "kpi_name": kpi.kpi_name if kpi else "未知", "period": kpi_value.period if kpi_value else "", "actual_value": kpi_value.actual_value if kpi_value else None, "target_value": kpi.target_value if kpi else None, } results = push_alert(alert_data, channel_configs) # 记录推送日志 for r in results: log = NotificationLog( alert_id=alert.id, channel=r.get("channel", ""), recipient=r.get("channel_name", ""), title=alert.alert_message[:200], content=alert.alert_message, status="sent" if r.get("success") else "failed", error_msg=r.get("message") if not r.get("success") else None, sent_at=datetime.now(), ) db_session.add(log) if r.get("success"): pushed += 1 logger.info(f" 已推送预警 #{alert.id} -> {r.get('channel_name')}") db_session.commit() return pushed