"""通知模块:邮件 (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