init: 管理会计OS初始代码

包含前后端完整代码:
- 前端:Vue3+Vite+ElementPlus
- 后端:FastAPI+SQLAlchemy
- 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动
- 当前版本:v1.0.0
This commit is contained in:
Hermes CI Fix
2026-05-28 17:32:22 +08:00
commit 3dddd36866
142 changed files with 18533 additions and 0 deletions
+251
View File
@@ -0,0 +1,251 @@
"""
预警通知推送模块 — 管理会计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