You've already forked pruchase_jhw
first commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
from src.notifications.models import NotificationEvent
|
||||
from src.notifications.service import NotificationService
|
||||
|
||||
__all__ = ["NotificationEvent", "NotificationService"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from src.notifications.channels.email import EmailNotificationChannel
|
||||
|
||||
__all__ = ["EmailNotificationChannel"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user