Files
Document_Translator/app/services/notification_service.py
2025-09-04 10:21:16 +08:00

645 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
通知服務
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
import os
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from flask import current_app, url_for
from app import db
from app.utils.logger import get_logger
from app.models.job import TranslationJob
from app.models.user import User
from app.models.notification import Notification, NotificationType
logger = get_logger(__name__)
class NotificationService:
"""通知服務"""
def __init__(self):
self.smtp_server = current_app.config.get('SMTP_SERVER')
self.smtp_port = current_app.config.get('SMTP_PORT', 587)
self.use_tls = current_app.config.get('SMTP_USE_TLS', False)
self.use_ssl = current_app.config.get('SMTP_USE_SSL', False)
self.auth_required = current_app.config.get('SMTP_AUTH_REQUIRED', False)
self.sender_email = current_app.config.get('SMTP_SENDER_EMAIL')
self.sender_password = current_app.config.get('SMTP_SENDER_PASSWORD', '')
self.app_name = current_app.config.get('APP_NAME', 'PANJIT Document Translator')
def _create_smtp_connection(self):
"""建立 SMTP 連線"""
try:
if self.use_ssl:
server = smtplib.SMTP_SSL(self.smtp_server, self.smtp_port)
else:
server = smtplib.SMTP(self.smtp_server, self.smtp_port)
if self.use_tls:
server.starttls()
if self.auth_required and self.sender_password:
server.login(self.sender_email, self.sender_password)
return server
except Exception as e:
logger.error(f"SMTP connection failed: {str(e)}")
return None
def _send_email(self, to_email: str, subject: str, html_content: str, text_content: str = None) -> bool:
"""發送郵件的基礎方法"""
try:
if not self.smtp_server or not self.sender_email:
logger.error("SMTP configuration incomplete")
return False
# 建立郵件
msg = MIMEMultipart('alternative')
msg['From'] = f"{self.app_name} <{self.sender_email}>"
msg['To'] = to_email
msg['Subject'] = subject
# 添加文本內容
if text_content:
text_part = MIMEText(text_content, 'plain', 'utf-8')
msg.attach(text_part)
# 添加 HTML 內容
html_part = MIMEText(html_content, 'html', 'utf-8')
msg.attach(html_part)
# 發送郵件
server = self._create_smtp_connection()
if not server:
return False
server.send_message(msg)
server.quit()
logger.info(f"Email sent successfully to {to_email}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {str(e)}")
return False
def send_job_completion_notification(self, job: TranslationJob) -> bool:
"""發送任務完成通知"""
try:
if not job.user or not job.user.email:
logger.warning(f"No email address for job {job.job_uuid}")
return False
# 準備郵件內容
subject = f"📄 翻譯完成通知 - {job.original_filename}"
# 計算處理時間
processing_time = ""
if job.processing_started_at and job.completed_at:
duration = job.completed_at - job.processing_started_at
total_seconds = int(duration.total_seconds())
if total_seconds < 60:
processing_time = f"{total_seconds}"
elif total_seconds < 3600:
minutes = total_seconds // 60
seconds = total_seconds % 60
processing_time = f"{minutes}{seconds}"
else:
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
processing_time = f"{hours}小時{minutes}"
# 生成下載連結(簡化版本)
download_links = []
for lang in job.target_languages:
download_links.append(f"{lang}: [下載翻譯檔案]")
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #2563eb; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f8fafc; padding: 30px; border: 1px solid #e5e7eb; }}
.info-box {{ background-color: #dbeafe; border-left: 4px solid #2563eb; padding: 15px; margin: 20px 0; }}
.footer {{ background-color: #374151; color: #d1d5db; padding: 15px; text-align: center; font-size: 12px; border-radius: 0 0 8px 8px; }}
.success {{ color: #059669; font-weight: bold; }}
.download-section {{ margin: 20px 0; }}
.download-link {{ display: inline-block; background-color: #2563eb; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; margin: 5px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎉 翻譯任務完成</h1>
</div>
<div class="content">
<p>親愛的 <strong>{job.user.display_name}</strong></p>
<p class="success">您的文件翻譯任務已成功完成!</p>
<div class="info-box">
<h3>📋 任務詳細資訊</h3>
<p><strong>檔案名稱:</strong> {job.original_filename}</p>
<p><strong>任務編號:</strong> {job.job_uuid}</p>
<p><strong>來源語言:</strong> {job.source_language}</p>
<p><strong>目標語言:</strong> {', '.join(job.target_languages)}</p>
<p><strong>處理時間:</strong> {processing_time}</p>
<p><strong>完成時間:</strong> {job.completed_at.strftime('%Y-%m-%d %H:%M:%S') if job.completed_at else '未知'}</p>
{f'<p><strong>總成本:</strong> ${job.total_cost:.4f}</p>' if job.total_cost else ''}
</div>
<div class="download-section">
<h3>📥 下載翻譯檔案</h3>
<p>請登入系統下載您的翻譯檔案:</p>
<p>{'<br>'.join(download_links)}</p>
<p style="margin-top: 15px;">
<strong>注意:</strong> 翻譯檔案將在系統中保留 7 天,請及時下載。
</p>
</div>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p>感謝您使用 {self.app_name}</p>
<p>如有任何問題,請聯繫系統管理員。</p>
</div>
</div>
<div class="footer">
<p>此郵件由 {self.app_name} 系統自動發送,請勿回覆。</p>
<p>發送時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
</div>
</body>
</html>
"""
# 純文字版本
text_content = f"""
翻譯任務完成通知
親愛的 {job.user.display_name}
您的文件翻譯任務已成功完成!
任務詳細資訊:
- 檔案名稱: {job.original_filename}
- 任務編號: {job.job_uuid}
- 來源語言: {job.source_language}
- 目標語言: {', '.join(job.target_languages)}
- 處理時間: {processing_time}
- 完成時間: {job.completed_at.strftime('%Y-%m-%d %H:%M:%S') if job.completed_at else '未知'}
請登入系統下載您的翻譯檔案。翻譯檔案將在系統中保留 7 天。
感謝您使用 {self.app_name}
----
此郵件由系統自動發送,請勿回覆。
"""
return self._send_email(job.user.email, subject, html_content, text_content)
except Exception as e:
logger.error(f"Failed to send completion notification for job {job.job_uuid}: {str(e)}")
return False
def send_job_failure_notification(self, job: TranslationJob) -> bool:
"""發送任務失敗通知"""
try:
if not job.user or not job.user.email:
logger.warning(f"No email address for job {job.job_uuid}")
return False
subject = f"⚠️ 翻譯失敗通知 - {job.original_filename}"
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #dc2626; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f8fafc; padding: 30px; border: 1px solid #e5e7eb; }}
.error-box {{ background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 15px; margin: 20px 0; }}
.footer {{ background-color: #374151; color: #d1d5db; padding: 15px; text-align: center; font-size: 12px; border-radius: 0 0 8px 8px; }}
.error {{ color: #dc2626; font-weight: bold; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>❌ 翻譯任務失敗</h1>
</div>
<div class="content">
<p>親愛的 <strong>{job.user.display_name}</strong></p>
<p class="error">很抱歉,您的文件翻譯任務處理失敗。</p>
<div class="error-box">
<h3>📋 任務資訊</h3>
<p><strong>檔案名稱:</strong> {job.original_filename}</p>
<p><strong>任務編號:</strong> {job.job_uuid}</p>
<p><strong>重試次數:</strong> {job.retry_count}</p>
<p><strong>錯誤訊息:</strong> {job.error_message or '未知錯誤'}</p>
<p><strong>失敗時間:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
<div style="margin-top: 20px;">
<p><strong>建議處理方式:</strong></p>
<ul>
<li>檢查檔案格式是否正確</li>
<li>確認檔案沒有損壞</li>
<li>稍後再次嘗試上傳</li>
<li>如問題持續,請聯繫系統管理員</li>
</ul>
</div>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p>如需協助,請聯繫系統管理員。</p>
</div>
</div>
<div class="footer">
<p>此郵件由 {self.app_name} 系統自動發送,請勿回覆。</p>
<p>發送時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
</div>
</body>
</html>
"""
text_content = f"""
翻譯任務失敗通知
親愛的 {job.user.display_name}
很抱歉,您的文件翻譯任務處理失敗。
任務資訊:
- 檔案名稱: {job.original_filename}
- 任務編號: {job.job_uuid}
- 重試次數: {job.retry_count}
- 錯誤訊息: {job.error_message or '未知錯誤'}
建議處理方式:
1. 檢查檔案格式是否正確
2. 確認檔案沒有損壞
3. 稍後再次嘗試上傳
4. 如問題持續,請聯繫系統管理員
如需協助,請聯繫系統管理員。
----
此郵件由 {self.app_name} 系統自動發送,請勿回覆。
"""
return self._send_email(job.user.email, subject, html_content, text_content)
except Exception as e:
logger.error(f"Failed to send failure notification for job {job.job_uuid}: {str(e)}")
return False
def send_admin_notification(self, subject: str, message: str, admin_emails: List[str] = None) -> bool:
"""發送管理員通知"""
try:
if not admin_emails:
# 取得所有管理員郵件地址
admin_users = User.get_admin_users()
admin_emails = [user.email for user in admin_users if user.email]
if not admin_emails:
logger.warning("No admin email addresses found")
return False
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #f59e0b; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f8fafc; padding: 30px; border: 1px solid #e5e7eb; }}
.footer {{ background-color: #374151; color: #d1d5db; padding: 15px; text-align: center; font-size: 12px; border-radius: 0 0 8px 8px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔔 系統管理通知</h1>
</div>
<div class="content">
<p>系統管理員您好,</p>
<div style="background-color: #fef3c7; border-left: 4px solid #f59e0b; padding: 15px; margin: 20px 0;">
<h3>{subject}</h3>
<p>{message}</p>
</div>
<p>發送時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
<div class="footer">
<p>此郵件由 {self.app_name} 系統自動發送,請勿回覆。</p>
</div>
</div>
</body>
</html>
"""
success_count = 0
for email in admin_emails:
if self._send_email(email, f"[管理通知] {subject}", html_content):
success_count += 1
return success_count > 0
except Exception as e:
logger.error(f"Failed to send admin notification: {str(e)}")
return False
def test_smtp_connection(self) -> bool:
"""測試 SMTP 連線"""
try:
server = self._create_smtp_connection()
if server:
server.quit()
return True
return False
except Exception as e:
logger.error(f"SMTP connection test failed: {str(e)}")
return False
# ========== 資料庫通知方法 ==========
def create_db_notification(
self,
user_id: int,
title: str,
message: str,
notification_type: NotificationType = NotificationType.INFO,
job_uuid: Optional[str] = None,
extra_data: Optional[Dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
link: Optional[str] = None
) -> Optional[Notification]:
"""
創建資料庫通知
Args:
user_id: 用戶ID
title: 通知標題
message: 通知內容
notification_type: 通知類型
job_uuid: 關聯任務UUID
extra_data: 額外數據
expires_at: 過期時間
link: 相關連結
Returns:
Notification: 創建的通知對象
"""
try:
# 如果沒有指定連結但有任務UUID自動生成任務詳情連結
if not link and job_uuid:
link = f"/job/{job_uuid}"
notification = Notification(
user_id=user_id,
type=notification_type.value,
title=title,
message=message,
job_uuid=job_uuid,
link=link,
extra_data=extra_data,
expires_at=expires_at
)
db.session.add(notification)
db.session.commit()
logger.info(f"資料庫通知已創建: {notification.notification_uuid} for user {user_id}")
# 觸發 WebSocket 推送
self._send_websocket_notification(notification)
return notification
except Exception as e:
db.session.rollback()
logger.error(f"創建資料庫通知失敗: {e}")
return None
def send_job_started_db_notification(self, job: TranslationJob) -> Optional[Notification]:
"""
發送任務開始處理的資料庫通知
Args:
job: 翻譯任務對象
Returns:
Notification: 創建的通知對象
"""
try:
title = "翻譯任務開始處理"
message = f'您的文件「{job.original_filename}」已開始翻譯處理。'
if job.target_languages:
languages = ', '.join(job.target_languages)
message += f" 目標語言: {languages}"
return self.create_db_notification(
user_id=job.user_id,
title=title,
message=message,
notification_type=NotificationType.INFO,
job_uuid=job.job_uuid,
extra_data={
'filename': job.original_filename,
'target_languages': job.target_languages,
'started_at': job.processing_started_at.isoformat() if job.processing_started_at else None
}
)
except Exception as e:
logger.error(f"發送任務開始資料庫通知失敗: {e}")
return None
def send_job_completion_db_notification(self, job: TranslationJob) -> Optional[Notification]:
"""
發送任務完成的資料庫通知
Args:
job: 翻譯任務對象
Returns:
Notification: 創建的通知對象
"""
try:
if job.status != 'COMPLETED':
logger.warning(f"任務 {job.job_uuid} 狀態不是已完成,跳過完成通知")
return None
# 構建通知內容
title = "翻譯任務完成"
message = f'您的文件「{job.original_filename}」已成功翻譯完成。'
# 添加目標語言信息
if job.target_languages:
languages = ', '.join(job.target_languages)
message += f" 目標語言: {languages}"
# 添加處理時間信息
if job.processing_started_at and job.completed_at:
duration = job.completed_at - job.processing_started_at
minutes = int(duration.total_seconds() / 60)
if minutes > 0:
message += f" 處理時間: {minutes} 分鐘"
else:
message += f" 處理時間: {int(duration.total_seconds())}"
return self.create_db_notification(
user_id=job.user_id,
title=title,
message=message,
notification_type=NotificationType.SUCCESS,
job_uuid=job.job_uuid,
extra_data={
'filename': job.original_filename,
'target_languages': job.target_languages,
'total_cost': float(job.total_cost) if job.total_cost else 0,
'completed_at': job.completed_at.isoformat() if job.completed_at else None
}
)
except Exception as e:
logger.error(f"發送任務完成資料庫通知失敗: {e}")
return None
def send_job_completion_db_notification_direct(self, job: TranslationJob) -> Optional[Notification]:
"""
直接發送任務完成的資料庫通知(不檢查狀態)
"""
try:
# 構建通知內容
title = "翻譯任務完成"
message = f'您的文件「{job.original_filename}」已成功翻譯完成。'
# 添加目標語言信息
if job.target_languages:
languages = ', '.join(job.target_languages)
message += f" 目標語言: {languages}"
message += " 您可以在任務列表中下載翻譯結果。"
# 創建資料庫通知
return self.create_db_notification(
user_id=job.user_id,
title=title,
message=message,
notification_type=NotificationType.SUCCESS,
job_uuid=job.job_uuid,
extra_data={
'filename': job.original_filename,
'target_languages': job.target_languages,
'total_cost': float(job.total_cost) if job.total_cost else 0,
'completed_at': job.completed_at.isoformat() if job.completed_at else None
}
)
except Exception as e:
logger.error(f"發送任務完成資料庫通知失敗: {e}")
return None
def send_job_failure_db_notification(self, job: TranslationJob, error_message: str = None) -> Optional[Notification]:
"""
發送任務失敗的資料庫通知
Args:
job: 翻譯任務對象
error_message: 錯誤訊息
Returns:
Notification: 創建的通知對象
"""
try:
title = "翻譯任務失敗"
message = f'您的文件「{job.original_filename}」翻譯失敗。'
if error_message:
message += f" 錯誤訊息: {error_message}"
if job.retry_count > 0:
message += f" 已重試 {job.retry_count} 次。"
return self.create_db_notification(
user_id=job.user_id,
title=title,
message=message,
notification_type=NotificationType.ERROR,
job_uuid=job.job_uuid,
extra_data={
'filename': job.original_filename,
'error_message': error_message,
'retry_count': job.retry_count,
'failed_at': datetime.now().isoformat()
}
)
except Exception as e:
logger.error(f"發送任務失敗資料庫通知失敗: {e}")
return None
def _send_websocket_notification(self, notification: Notification):
"""
通過 WebSocket 發送通知
Args:
notification: 通知對象
"""
try:
from app.websocket import send_notification_to_user
send_notification_to_user(notification.user_id, notification.to_dict())
except Exception as e:
logger.error(f"WebSocket 推送通知失敗: {e}")
def get_unread_count(self, user_id: int) -> int:
"""
獲取用戶未讀通知數量
Args:
user_id: 用戶ID
Returns:
int: 未讀通知數量
"""
try:
return Notification.query.filter_by(
user_id=user_id,
is_read=False
).filter(
(Notification.expires_at.is_(None)) |
(Notification.expires_at > datetime.now())
).count()
except Exception as e:
logger.error(f"獲取未讀通知數量失敗: {e}")
return 0