Files
system-backu/backend/app/core/notify.py
T
publ 07b2016f33 feat: ETCD 备份 + 邮件/飞书通知
ETCD 备份(新增类型):
- 后端 core/backup/etcd.py:调用 etcdctl snapshot save(v3.5.17 二进制)
  生成快照文件,与 META.txt 一起 tar.gz 流式打包上传。
- 配置支持 endpoints(逗号分隔多节点)、basic auth、TLS(cacert/cert/key)。
- Dockerfile 下载 etcdctl 二进制(绕过 apt 签名问题)。

通知模块:
- 核心 app/core/notify.py:
  - 邮件:smtplib + MIMEMultipart(线程池里跑避免阻塞)
  - 飞书:httpx 调自定义机器人 webhook,发 interactive card
- settings API:
  - GET/PUT /api/settings/notifications(密码脱敏 *** 编辑占位)
  - POST /api/settings/notifications/test 测试通道
- Job 增加 notify_on 字段(none/failure/all),Alembic 0002 迁移。
- executor 在 run 完成后根据 job.notify_on 与 run.status 异步触发通知,
  重新加载最新 run 信息生成上下文。

前端:
- JobForm 加 ETCD 表单字段与 notify_on 选择器;type 选项加 etcd。
- JobList 显示通知策略徽标。
- Settings 页加通知配置卡片:邮件(折叠面板 + 测试按钮)+ 飞书 + 保存。
- 新增 api/settings.ts。
- 类型扩展:EtcdSourceConfig、NotificationsConfig、SmtpConfig。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 11:24:53 +08:00

218 lines
7.1 KiB
Python

"""通知模块:邮件 (SMTP) + 飞书群机器人 (Webhook)。
设计:纯函数式 + 异步,调用方传入配置 dict 与上下文。
不依赖数据库或 settings 表(settings 读取由调用方负责)。
"""
import asyncio
import smtplib
from dataclasses import dataclass
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Iterable, Optional
import httpx
from app.utils.logging import get_logger
log = get_logger(__name__)
@dataclass
class NotificationContext:
job_name: str
job_type: str
status: str # "success" / "failed"
run_id: int
artifact_size: Optional[int] = None
duration_seconds: Optional[int] = None
error_message: Optional[str] = None
artifact_path: Optional[str] = None
started_at: Optional[str] = None
finished_at: Optional[str] = None
def _human_size(n: Optional[int]) -> str:
if n is None:
return "-"
if n < 1024:
return f"{n} B"
if n < 1024 * 1024:
return f"{n / 1024:.1f} KB"
if n < 1024 * 1024 * 1024:
return f"{n / 1024 / 1024:.1f} MB"
return f"{n / 1024 / 1024 / 1024:.2f} GB"
def _build_text(ctx: NotificationContext) -> str:
lines = [
f"[备份系统] {ctx.job_name}",
f"状态: {ctx.status.upper()}",
f"类型: {ctx.job_type}",
f"Run ID: {ctx.run_id}",
]
if ctx.started_at:
lines.append(f"开始: {ctx.started_at}")
if ctx.finished_at:
lines.append(f"结束: {ctx.finished_at}")
if ctx.duration_seconds is not None:
lines.append(f"耗时: {ctx.duration_seconds}s")
if ctx.artifact_size is not None:
lines.append(f"大小: {_human_size(ctx.artifact_size)}")
if ctx.artifact_path:
lines.append(f"路径: {ctx.artifact_path}")
if ctx.error_message:
lines.append(f"错误: {ctx.error_message[:1000]}")
return "\n".join(lines)
def _build_feishu_card(ctx: NotificationContext) -> dict:
color = "green" if ctx.status == "success" else "red"
fields = [
{"is_short": True, "text": {"tag": "lark_md", "content": f"**类型**\n{ctx.job_type}"}},
{"is_short": True, "text": {"tag": "lark_md", "content": f"**Run ID**\n{ctx.run_id}"}},
]
if ctx.duration_seconds is not None:
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**耗时**\n{ctx.duration_seconds}s"}})
if ctx.artifact_size is not None:
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**大小**\n{_human_size(ctx.artifact_size)}"}})
if ctx.error_message:
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**错误**\n{ctx.error_message[:1000]}"}})
return {
"msg_type": "interactive",
"card": {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": f"[备份] {ctx.job_name}{ctx.status.upper()}"},
"template": color,
},
"elements": [
{"tag": "div", "fields": fields},
{"tag": "hr"},
{
"tag": "note",
"elements": [
{"tag": "plain_text", "content": f"Backup System • {ctx.started_at or ''}"}
],
},
],
},
}
# ===== Email =====
async def send_email(
smtp_config: dict,
to_addrs: Iterable[str],
subject: str,
body: str,
) -> tuple[bool, str]:
"""通过 SMTP 发送邮件。smtp_config 必填字段:
host, port, user, password, from_addr, use_tls (bool)
"""
host = smtp_config.get("host")
if not host:
return False, "SMTP host 未配置"
port = int(smtp_config.get("port") or 587)
user = smtp_config.get("user", "")
password = smtp_config.get("password", "")
from_addr = smtp_config.get("from_addr") or user
use_tls = bool(smtp_config.get("use_tls", True))
to_list = [a.strip() for a in to_addrs if a and a.strip()]
if not to_list:
return False, "收件人列表为空"
msg = MIMEMultipart()
msg["From"] = from_addr
msg["To"] = ", ".join(to_list)
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain", "utf-8"))
def _send() -> tuple[bool, str]:
try:
if use_tls:
client = smtplib.SMTP(host, port, timeout=15)
client.ehlo()
client.starttls()
client.ehlo()
else:
client = smtplib.SMTP_SSL(host, port, timeout=15)
client.ehlo()
if user:
client.login(user, password)
client.sendmail(from_addr, to_list, msg.as_string())
client.quit()
return True, f"sent to {len(to_list)} recipient(s)"
except Exception as e:
return False, f"{type(e).__name__}: {e}"
ok, msg_text = await asyncio.get_event_loop().run_in_executor(None, _send)
return ok, msg_text
# ===== Feishu Webhook =====
async def send_feishu(webhook_url: str, ctx: NotificationContext) -> tuple[bool, str]:
"""飞书自定义机器人:发送 interactive card。"""
if not webhook_url:
return False, "webhook URL 未配置"
payload = _build_feishu_card(ctx)
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(webhook_url, json=payload)
if resp.status_code != 200:
return False, f"HTTP {resp.status_code}: {resp.text[:300]}"
data = resp.json()
if data.get("code") not in (0, None):
return False, f"feishu err: {data.get('msg') or data}"
return True, "ok"
except Exception as e:
return False, f"{type(e).__name__}: {e}"
# ===== Aggregator =====
@dataclass
class NotifyResult:
email_ok: bool = True
email_msg: str = ""
feishu_ok: bool = True
feishu_msg: str = ""
async def send_notifications(
notify_settings: dict,
ctx: NotificationContext,
) -> NotifyResult:
"""根据 settings 发送通知。settings 结构:
{
"email_enabled": true,
"smtp": {"host", "port", "user", "password", "from_addr", "use_tls"},
"email_to": ["ops@example.com", ...],
"feishu_enabled": true,
"feishu_webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/...",
}
"""
res = NotifyResult()
if notify_settings.get("email_enabled"):
smtp = notify_settings.get("smtp") or {}
to_list = notify_settings.get("email_to") or []
subject = f"[{'成功' if ctx.status == 'success' else '失败'}] 备份任务 {ctx.job_name}"
ok, msg = await send_email(smtp, to_list, subject, _build_text(ctx))
res.email_ok = ok
res.email_msg = msg
log.info("Email notify: ok=%s msg=%s", ok, msg)
if notify_settings.get("feishu_enabled"):
url = notify_settings.get("feishu_webhook_url", "")
ok, msg = await send_feishu(url, ctx)
res.feishu_ok = ok
res.feishu_msg = msg
log.info("Feishu notify: ok=%s msg=%s", ok, msg)
return res