304 lines
12 KiB
Python
304 lines
12 KiB
Python
"""
|
||
预警通知推送模块 — 管理会计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 send_wecom_message(content: str, title: str = "管理会计OS通知", alert_level: str = "green") -> dict:
|
||
"""发送企业微信消息到所有已启用的通知渠道(验证结果/行动报告推送用)
|
||
|
||
- 读取 notification_channels 表中启用的 wecom_app / wecom 渠道
|
||
- 无渠道时记日志并返回失败(不抛异常,保证主流程不中断)
|
||
"""
|
||
try:
|
||
from app.database import get_session_local
|
||
from app.models import NotificationChannel
|
||
except Exception as e:
|
||
logger.warning(f"send_wecom_message: 加载依赖失败: {e}")
|
||
return {"success": False, "message": f"依赖加载失败: {e}"}
|
||
|
||
db = get_session_local()()
|
||
try:
|
||
channels = db.query(NotificationChannel).filter(
|
||
NotificationChannel.enabled == True,
|
||
NotificationChannel.channel_type.in_(["wecom_app", "wecom"]),
|
||
).all()
|
||
if not channels:
|
||
logger.info(f"send_wecom_message: 无已启用的企微渠道,跳过推送: {title}")
|
||
return {"success": False, "message": "无已启用的企微通知渠道"}
|
||
|
||
results = []
|
||
for ch in channels:
|
||
config = ch.config or {}
|
||
if ch.channel_type == "wecom_app":
|
||
r = 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", "@all"),
|
||
title=title, content=content, alert_level=alert_level,
|
||
)
|
||
else:
|
||
r = send_wecom_robot(
|
||
webhook_url=config.get("webhook_url", ""),
|
||
title=title, content=content, alert_level=alert_level,
|
||
)
|
||
r["channel"] = ch.channel_type
|
||
results.append(r)
|
||
return {
|
||
"success": any(r.get("success") for r in results),
|
||
"results": results,
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"send_wecom_message 异常: {e}")
|
||
return {"success": False, "message": f"推送异常: {e}"}
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
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
|