Files
Hermes CI Fix 3dddd36866 init: 管理会计OS初始代码
包含前后端完整代码:
- 前端:Vue3+Vite+ElementPlus
- 后端:FastAPI+SQLAlchemy
- 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动
- 当前版本:v1.0.0
2026-05-28 17:32:22 +08:00

252 lines
9.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
预警通知推送模块 — 管理会计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