first commit

This commit is contained in:
zmq
2026-05-26 14:16:16 +08:00
committed by Jerry Yan
commit 535d7f4ac5
40 changed files with 4571 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
from src.notifications.models import NotificationEvent
from src.notifications.service import NotificationService
__all__ = ["NotificationEvent", "NotificationService"]
+14
View File
@@ -0,0 +1,14 @@
from abc import ABC, abstractmethod
from src.notifications.models import NotificationEvent
class NotificationChannel(ABC):
channel_name: str
@abstractmethod
async def send(self, event: NotificationEvent) -> None:
"""
发送通知事件。
"""
raise NotImplementedError
+3
View File
@@ -0,0 +1,3 @@
from src.notifications.channels.email import EmailNotificationChannel
__all__ = ["EmailNotificationChannel"]
+54
View File
@@ -0,0 +1,54 @@
import asyncio
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from src.config.settings import Settings
from src.notifications.base import NotificationChannel
from src.notifications.models import NotificationEvent
class EmailNotificationChannel(NotificationChannel):
channel_name = "email"
def __init__(self, settings: Settings) -> None:
self.settings = settings
async def send(self, event: NotificationEvent) -> None:
await asyncio.to_thread(self._send_sync, event)
def _send_sync(self, event: NotificationEvent) -> None:
message = EmailMessage()
from_display = formataddr(
(
self.settings.mail_from_name,
self.settings.mail_from_address or "",
)
)
message["Subject"] = event.title
message["From"] = from_display
message["To"] = ", ".join(self.settings.mail_to_addresses)
message.set_content(event.content_text)
host = self.settings.mail_smtp_host or ""
port = self.settings.mail_smtp_port
timeout = self.settings.mail_timeout_seconds
if self.settings.mail_smtp_use_ssl:
with smtplib.SMTP_SSL(host, port, timeout=timeout) as server:
self._login_and_send(server, message)
return
with smtplib.SMTP(host, port, timeout=timeout) as server:
if self.settings.mail_smtp_starttls:
server.starttls()
self._login_and_send(server, message)
def _login_and_send(self, server: smtplib.SMTP, message: EmailMessage) -> None:
username = self.settings.mail_username or ""
password = self.settings.mail_password or ""
if username:
server.login(username, password)
server.send_message(message)
+11
View File
@@ -0,0 +1,11 @@
from typing import Any, Literal
from pydantic import BaseModel, Field
class NotificationEvent(BaseModel):
event_type: str
title: str
content_text: str
severity: Literal["info", "warning", "error"] = "info"
metadata: dict[str, Any] = Field(default_factory=dict)
+131
View File
@@ -0,0 +1,131 @@
from datetime import datetime
import json
from typing import Any
from loguru import logger
from src.config.settings import Settings, get_settings
from src.notifications.base import NotificationChannel
from src.notifications.channels import EmailNotificationChannel
from src.notifications.models import NotificationEvent
class NotificationService:
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or get_settings()
def validate_configuration(self) -> None:
if not self.settings.notify_enabled:
return
channels = self._get_enabled_channel_names()
if not channels:
logger.warning("通知模块已启用,但未配置任何有效通知渠道。")
return
logger.info(f"通知模块已启用,当前渠道: {', '.join(channels)}")
if "email" in channels and self._get_email_configuration_issues():
logger.warning(
"邮件通知配置不完整,失败通知将被跳过: "
+ "; ".join(self._get_email_configuration_issues())
)
async def notify_task_failed(self, task_context: dict[str, Any]) -> None:
"""
通知任务执行失败。
:param task_context: 任务上下文,包含任务ID、失败原因等信息。
"""
if not self.settings.notify_on_task_failed:
return
title = f"{self.settings.mail_subject_prefix} {task_context.get("title", "")}"
event = NotificationEvent(
event_type="task_failed",
title=title,
content_text=self._render_task_failed_content(task_context),
severity="error",
metadata=task_context,
)
await self.notify(event)
async def notify(self, event: NotificationEvent) -> None:
if not self.settings.notify_enabled:
logger.debug("通知模块未启用,跳过发送通知。")
return
channels = self._build_channels()
if not channels:
logger.warning("通知模块未找到可用渠道,跳过发送通知。")
return
for channel in channels:
try:
await channel.send(event)
logger.success(
f"通知发送成功,渠道={channel.channel_name},事件={event.event_type}"
)
except Exception as exc:
logger.error(
f"通知发送失败,渠道={channel.channel_name},事件={event.event_type},错误: {exc}"
)
def _build_channels(self) -> list[NotificationChannel]:
channels: list[NotificationChannel] = []
for channel_name in self._get_enabled_channel_names():
if channel_name == "email":
if not self.settings.mail_enabled:
logger.debug("邮件渠道已在配置中关闭。")
continue
issues = self._get_email_configuration_issues()
if issues:
logger.warning(
"邮件通知配置不完整,跳过邮件渠道: " + "; ".join(issues)
)
continue
channels.append(EmailNotificationChannel(self.settings))
return channels
def _get_enabled_channel_names(self) -> list[str]:
return [channel.lower() for channel in self.settings.notify_channels if channel.strip()]
def _get_email_configuration_issues(self) -> list[str]:
issues: list[str] = []
if not self.settings.mail_smtp_host:
issues.append("缺少 MAIL_SMTP_HOST")
if not self.settings.mail_from_address:
issues.append("缺少 MAIL_FROM_ADDRESS")
if not self.settings.mail_to_addresses:
issues.append("缺少 MAIL_TO_ADDRESSES")
if not self.settings.mail_username:
issues.append("缺少 MAIL_USERNAME")
if self.settings.mail_username and self.settings.mail_password is None:
issues.append("缺少 MAIL_PASSWORD")
return issues
def _render_task_failed_content(self, task_context: dict[str, Any]) -> str:
message = task_context.get("message", "")
message_txt = ""
# 格式化为json字符串
if message:
message_txt = json.dumps(message, ensure_ascii=False, indent=4)
lines = [
"采购任务失败通知",
"",
f"任务ID: {task_context.get('task_id', 'N/A')}",
f"Topic: {task_context.get('topic', 'unknown')}",
f"采购账号: {task_context.get('account_id', '')}",
f"失败原因: {task_context.get('error_message', '') or '未知错误'}",
f"截图路径: {task_context.get('screenshot_path', '')}",
f"录屏路径: {task_context.get('video_path', '')}",
f"发生时间: {task_context.get('occurred_at', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))}",
f"原始消息: {message_txt}",
]
return "\n".join(lines)