12th_fix error

This commit is contained in:
beabigegg
2025-09-04 09:44:13 +08:00
parent d638d682b7
commit 5662fcc039
19 changed files with 1735 additions and 50 deletions

View File

@@ -125,7 +125,7 @@ def create_app(config_name=None):
# 建立資料表
with app.app_context():
# 導入模型
from app.models import User, TranslationJob, JobFile, TranslationCache, APIUsageStats, SystemLog
from app.models import User, TranslationJob, JobFile, TranslationCache, APIUsageStats, SystemLog, Notification
db.create_all()
@@ -135,6 +135,10 @@ def create_app(config_name=None):
# 創建 Celery 實例
app.celery = make_celery(app)
# 初始化 WebSocket
from app.websocket import init_websocket
app.socketio = init_websocket(app)
app.logger.info("Flask application created successfully")
return app

View File

@@ -14,11 +14,12 @@ from flask import Blueprint
api_v1 = Blueprint('api_v1', __name__, url_prefix='/api/v1')
# 匯入各 API 模組
from . import auth, jobs, files, admin, health
from . import auth, jobs, files, admin, health, notification
# 註冊路由
api_v1.register_blueprint(auth.auth_bp)
api_v1.register_blueprint(jobs.jobs_bp)
api_v1.register_blueprint(files.files_bp)
api_v1.register_blueprint(admin.admin_bp)
api_v1.register_blueprint(health.health_bp)
api_v1.register_blueprint(health.health_bp)
api_v1.register_blueprint(notification.notification_bp)

331
app/api/notification.py Normal file
View File

@@ -0,0 +1,331 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
通知系統 API 路由
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from flask import Blueprint, jsonify, request, g
from flask_jwt_extended import jwt_required, get_jwt_identity
from sqlalchemy import desc, and_, or_
from datetime import datetime, timedelta
from app import db
from app.models import Notification, NotificationType, User
from app.utils.response import create_taiwan_response
# 移除不需要的導入
# 建立藍圖
notification_bp = Blueprint('notification', __name__, url_prefix='/notifications')
@notification_bp.route('', methods=['GET'])
@jwt_required()
def get_notifications():
"""獲取當前用戶的通知列表"""
try:
# 獲取當前用戶
current_user_id = get_jwt_identity()
# 獲取查詢參數
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 20, type=int), 100)
status_filter = request.args.get('status', 'all')
type_filter = request.args.get('type', None)
# 建構查詢
query = Notification.query.filter_by(user_id=current_user_id)
# 只顯示未過期的通知
query = query.filter(or_(
Notification.expires_at.is_(None),
Notification.expires_at > datetime.now()
))
# 過濾狀態
if status_filter == 'unread':
query = query.filter_by(is_read=False)
elif status_filter == 'read':
query = query.filter_by(is_read=True)
# 過濾類型
if type_filter:
query = query.filter_by(type=type_filter)
# 排序 - 未讀在前,然後按時間排序
query = query.order_by(Notification.is_read.asc(), desc(Notification.created_at))
# 分頁
paginated = query.paginate(
page=page, per_page=per_page, error_out=False
)
# 獲取未讀數量
unread_count = Notification.query.filter_by(
user_id=current_user_id,
is_read=False
).filter(or_(
Notification.expires_at.is_(None),
Notification.expires_at > datetime.now()
)).count()
return jsonify(create_taiwan_response(
success=True,
data={
'notifications': [n.to_dict() for n in paginated.items],
'pagination': {
'total': paginated.total,
'page': page,
'per_page': per_page,
'pages': paginated.pages
},
'unread_count': unread_count
},
message='獲取通知列表成功'
))
except Exception as e:
return jsonify(create_taiwan_response(
success=False,
error=f'獲取通知失敗:{str(e)}'
)), 500
@notification_bp.route('/<notification_id>', methods=['GET'])
@jwt_required()
def get_notification(notification_id):
"""獲取單個通知詳情"""
try:
current_user_id = get_jwt_identity()
# 查找通知
notification = Notification.query.filter_by(
notification_uuid=notification_id,
user_id=current_user_id
).first()
if not notification:
return jsonify(create_taiwan_response(
success=False,
error='通知不存在'
)), 404
# 自動標記為已讀
if not notification.is_read:
notification.mark_as_read()
db.session.commit()
return jsonify(create_taiwan_response(
success=True,
data=notification.to_dict(),
message='獲取通知成功'
))
except Exception as e:
return jsonify(create_taiwan_response(
success=False,
error=f'獲取通知失敗:{str(e)}'
)), 500
@notification_bp.route('/<notification_id>/read', methods=['POST'])
@jwt_required()
def mark_notification_read(notification_id):
"""標記通知為已讀"""
try:
current_user_id = get_jwt_identity()
# 查找通知
notification = Notification.query.filter_by(
notification_uuid=notification_id,
user_id=current_user_id
).first()
if not notification:
return jsonify(create_taiwan_response(
success=False,
error='通知不存在'
)), 404
# 標記為已讀
notification.mark_as_read()
db.session.commit()
return jsonify(create_taiwan_response(
success=True,
message='標記已讀成功'
))
except Exception as e:
return jsonify(create_taiwan_response(
success=False,
error=f'標記已讀失敗:{str(e)}'
)), 500
@notification_bp.route('/read-all', methods=['POST'])
@jwt_required()
def mark_all_read():
"""標記所有通知為已讀"""
try:
current_user_id = get_jwt_identity()
# 取得所有未讀通知
unread_notifications = Notification.query.filter_by(
user_id=current_user_id,
is_read=False
).filter(or_(
Notification.expires_at.is_(None),
Notification.expires_at > datetime.now()
)).all()
# 標記為已讀
for notification in unread_notifications:
notification.mark_as_read()
db.session.commit()
return jsonify(create_taiwan_response(
success=True,
data={'marked_count': len(unread_notifications)},
message=f'已標記 {len(unread_notifications)} 個通知為已讀'
))
except Exception as e:
return jsonify(create_taiwan_response(
success=False,
error=f'標記全部已讀失敗:{str(e)}'
)), 500
@notification_bp.route('/<notification_id>', methods=['DELETE'])
@jwt_required()
def delete_notification(notification_id):
"""刪除通知"""
try:
current_user_id = get_jwt_identity()
# 查找通知
notification = Notification.query.filter_by(
notification_uuid=notification_id,
user_id=current_user_id
).first()
if not notification:
return jsonify(create_taiwan_response(
success=False,
error='通知不存在'
)), 404
# 刪除通知
db.session.delete(notification)
db.session.commit()
return jsonify(create_taiwan_response(
success=True,
message='刪除通知成功'
))
except Exception as e:
db.session.rollback()
return jsonify(create_taiwan_response(
success=False,
error=f'刪除通知失敗:{str(e)}'
)), 500
@notification_bp.route('/clear', methods=['POST'])
@jwt_required()
def clear_read_notifications():
"""清空所有已讀通知"""
try:
current_user_id = get_jwt_identity()
# 刪除所有已讀通知
deleted_count = Notification.query.filter_by(
user_id=current_user_id,
is_read=True
).delete()
db.session.commit()
return jsonify(create_taiwan_response(
success=True,
data={'deleted_count': deleted_count},
message=f'已清除 {deleted_count} 個已讀通知'
))
except Exception as e:
db.session.rollback()
return jsonify(create_taiwan_response(
success=False,
error=f'清除通知失敗:{str(e)}'
)), 500
@notification_bp.route('/test', methods=['POST'])
@jwt_required()
def create_test_notification():
"""創建測試通知(開發用)"""
try:
current_user_id = get_jwt_identity()
# 創建測試通知
test_notification = create_notification(
user_id=current_user_id,
title="測試通知",
message=f"這是一個測試通知,創建於 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
notification_type=NotificationType.INFO
)
return jsonify(create_taiwan_response(
success=True,
data=test_notification.to_dict(),
message='測試通知已創建'
))
except Exception as e:
return jsonify(create_taiwan_response(
success=False,
error=f'創建測試通知失敗:{str(e)}'
)), 500
# 工具函數:創建通知
def create_notification(user_id, title, message, notification_type=NotificationType.INFO,
job_uuid=None, extra_data=None):
"""
創建通知的工具函數
Args:
user_id: 用戶ID
title: 通知標題
message: 通知內容
notification_type: 通知類型
job_uuid: 關聯的任務UUID可選
extra_data: 額外數據(可選)
Returns:
Notification: 創建的通知對象
"""
try:
notification = Notification(
user_id=user_id,
type=notification_type.value,
title=title,
message=message,
job_uuid=job_uuid,
extra_data=extra_data,
link=f"/job/{job_uuid}" if job_uuid else None
)
db.session.add(notification)
db.session.commit()
return notification
except Exception as e:
db.session.rollback()
raise e

View File

@@ -13,6 +13,7 @@ from .job import TranslationJob, JobFile
from .cache import TranslationCache
from .stats import APIUsageStats
from .log import SystemLog
from .notification import Notification, NotificationType
__all__ = [
'User',
@@ -20,5 +21,7 @@ __all__ = [
'JobFile',
'TranslationCache',
'APIUsageStats',
'SystemLog'
'SystemLog',
'Notification',
'NotificationType'
]

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
通知系統資料模型
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from datetime import datetime
from enum import Enum
from sqlalchemy import func
from sqlalchemy.orm import relationship
from app import db
import uuid
import json
class NotificationType(str, Enum):
"""通知類型枚舉"""
SUCCESS = "success" # 成功
ERROR = "error" # 錯誤
WARNING = "warning" # 警告
INFO = "info" # 資訊
SYSTEM = "system" # 系統
class Notification(db.Model):
"""通知模型"""
__tablename__ = 'dt_notifications'
# 主鍵
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
notification_uuid = db.Column(db.String(36), unique=True, nullable=False, index=True,
default=lambda: str(uuid.uuid4()), comment='通知唯一識別碼')
# 基本資訊
user_id = db.Column(db.Integer, db.ForeignKey('dt_users.id'), nullable=False, comment='使用者ID')
type = db.Column(db.String(20), nullable=False, default=NotificationType.INFO.value, comment='通知類型')
title = db.Column(db.String(255), nullable=False, comment='通知標題')
message = db.Column(db.Text, nullable=False, comment='通知內容')
# 關聯資訊(可選)
job_uuid = db.Column(db.String(36), nullable=True, comment='關聯任務UUID')
link = db.Column(db.String(500), nullable=True, comment='相關連結')
# 狀態
is_read = db.Column(db.Boolean, default=False, nullable=False, comment='是否已讀')
read_at = db.Column(db.DateTime, nullable=True, comment='閱讀時間')
# 時間戳記
created_at = db.Column(db.DateTime, default=func.now(), nullable=False, comment='建立時間')
expires_at = db.Column(db.DateTime, nullable=True, comment='過期時間')
# 額外數據JSON 格式儲存)
extra_data = db.Column(db.JSON, nullable=True, comment='額外數據')
# 關聯
user = db.relationship("User", backref="notifications")
def __repr__(self):
return f"<Notification {self.notification_uuid}: {self.title}>"
def to_dict(self):
"""轉換為字典格式"""
return {
'id': self.notification_uuid, # 前端使用 UUID
'user_id': self.user_id,
'type': self.type,
'title': self.title,
'message': self.message,
'job_uuid': self.job_uuid,
'link': self.link,
'is_read': self.is_read,
'read': self.is_read, # 為了前端相容
'read_at': self.read_at.isoformat() if self.read_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'extra_data': self.extra_data
}
def mark_as_read(self):
"""標記為已讀"""
self.is_read = True
self.read_at = datetime.now()
@classmethod
def create_job_notification(cls, user_id, job_uuid, title, message, notification_type=NotificationType.INFO):
"""創建任務相關通知"""
return cls(
user_id=user_id,
job_uuid=job_uuid,
type=notification_type.value,
title=title,
message=message,
link=f"/job/{job_uuid}" # 連結到任務詳情頁
)

View File

@@ -12,12 +12,14 @@ import os
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
from typing import Optional, List
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__)
@@ -385,4 +387,224 @@ class NotificationService:
return False
except Exception as e:
logger.error(f"SMTP connection test failed: {str(e)}")
return False
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_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

View File

@@ -76,7 +76,10 @@ def process_translation_job(self, job_id: int):
# 發送完成通知
try:
notification_service = NotificationService()
# 發送郵件通知
notification_service.send_job_completion_notification(job)
# 發送資料庫通知
notification_service.send_job_completion_db_notification(job)
except Exception as e:
logger.warning(f"Failed to send completion notification: {str(e)}")
@@ -131,6 +134,16 @@ def process_translation_job(self, job_id: int):
# 重試次數用盡,標記失敗
job.update_status('FAILED')
# 發送失敗通知
try:
notification_service = NotificationService()
# 發送郵件通知
notification_service.send_job_failure_notification(job)
# 發送資料庫通知
notification_service.send_job_failure_db_notification(job, str(exc))
except Exception as e:
logger.warning(f"Failed to send failure notification: {str(e)}")
SystemLog.error(
'tasks.translation',
f'Translation job failed permanently: {job.job_uuid}',

230
app/websocket.py Normal file
View File

@@ -0,0 +1,230 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
WebSocket 服務模組
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from flask_socketio import SocketIO, emit, join_room, leave_room, disconnect
from flask_jwt_extended import decode_token, get_jwt
from flask import request
from functools import wraps
import logging
# 初始化 SocketIO
socketio = SocketIO(
cors_allowed_origins="*",
async_mode='threading',
logger=True,
engineio_logger=False
)
# 存儲用戶連接
connected_users = {}
logger = logging.getLogger(__name__)
def jwt_required_ws(f):
"""WebSocket JWT 驗證裝飾器"""
@wraps(f)
def decorated_function(*args, **kwargs):
try:
# 從查詢參數獲取 token
token = request.args.get('token')
if not token:
disconnect()
return False
# 解碼 token
decoded = decode_token(token)
user_id = decoded.get('sub')
# 儲存用戶信息
request.user_id = user_id
return f(*args, **kwargs)
except Exception as e:
logger.error(f"WebSocket authentication failed: {e}")
disconnect()
return False
return decorated_function
@socketio.on('connect')
def handle_connect(auth):
"""處理客戶端連接"""
try:
# 從認證數據獲取 token
if auth and 'token' in auth:
token = auth['token']
decoded = decode_token(token)
user_id = decoded.get('sub')
# 記錄連接
connected_users[request.sid] = {
'user_id': user_id,
'sid': request.sid
}
# 加入用戶專屬房間
join_room(f"user_{user_id}")
logger.info(f"User {user_id} connected with session {request.sid}")
# 發送連接成功消息
emit('connected', {
'message': '連接成功',
'user_id': user_id
})
return True
else:
logger.warning("Connection attempt without authentication")
disconnect()
return False
except Exception as e:
logger.error(f"Connection error: {e}")
disconnect()
return False
@socketio.on('disconnect')
def handle_disconnect():
"""處理客戶端斷開連接"""
try:
if request.sid in connected_users:
user_info = connected_users[request.sid]
user_id = user_info['user_id']
# 離開房間
leave_room(f"user_{user_id}")
# 移除連接記錄
del connected_users[request.sid]
logger.info(f"User {user_id} disconnected")
except Exception as e:
logger.error(f"Disconnect error: {e}")
@socketio.on('ping')
def handle_ping():
"""處理心跳包"""
emit('pong', {'timestamp': request.args.get('timestamp')})
@socketio.on('subscribe_job')
def handle_subscribe_job(data):
"""訂閱任務更新"""
try:
job_uuid = data.get('job_uuid')
if job_uuid:
join_room(f"job_{job_uuid}")
logger.info(f"Client {request.sid} subscribed to job {job_uuid}")
emit('subscribed', {'job_uuid': job_uuid})
except Exception as e:
logger.error(f"Subscribe job error: {e}")
@socketio.on('unsubscribe_job')
def handle_unsubscribe_job(data):
"""取消訂閱任務更新"""
try:
job_uuid = data.get('job_uuid')
if job_uuid:
leave_room(f"job_{job_uuid}")
logger.info(f"Client {request.sid} unsubscribed from job {job_uuid}")
emit('unsubscribed', {'job_uuid': job_uuid})
except Exception as e:
logger.error(f"Unsubscribe job error: {e}")
# 工具函數:發送通知
def send_notification_to_user(user_id, notification_data):
"""
向特定用戶發送通知
Args:
user_id: 用戶ID
notification_data: 通知數據
"""
try:
socketio.emit(
'new_notification',
notification_data,
room=f"user_{user_id}",
namespace='/'
)
logger.info(f"Notification sent to user {user_id}")
except Exception as e:
logger.error(f"Failed to send notification: {e}")
def send_job_update(job_uuid, update_data):
"""
發送任務更新
Args:
job_uuid: 任務UUID
update_data: 更新數據
"""
try:
socketio.emit(
'job_update',
{
'job_uuid': job_uuid,
**update_data
},
room=f"job_{job_uuid}",
namespace='/'
)
logger.info(f"Job update sent for {job_uuid}")
except Exception as e:
logger.error(f"Failed to send job update: {e}")
def broadcast_system_message(message, message_type='info'):
"""
廣播系統消息給所有連接的用戶
Args:
message: 消息內容
message_type: 消息類型
"""
try:
socketio.emit(
'system_message',
{
'message': message,
'type': message_type
},
namespace='/',
broadcast=True
)
logger.info(f"System message broadcasted: {message}")
except Exception as e:
logger.error(f"Failed to broadcast system message: {e}")
# 初始化函數
def init_websocket(app):
"""
初始化 WebSocket
Args:
app: Flask 應用實例
"""
socketio.init_app(app)
logger.info("WebSocket initialized")
return socketio

View File

@@ -127,6 +127,31 @@ def create_tables():
FOREIGN KEY (user_id) REFERENCES dt_users(id) ON DELETE SET NULL,
FOREIGN KEY (job_id) REFERENCES dt_translation_jobs(id) ON DELETE SET NULL
) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
''',
'dt_notifications': '''
CREATE TABLE IF NOT EXISTS dt_notifications (
id INT PRIMARY KEY AUTO_INCREMENT,
notification_uuid VARCHAR(36) NOT NULL UNIQUE COMMENT '通知唯一識別碼',
user_id INT NOT NULL COMMENT '使用者ID',
type VARCHAR(20) NOT NULL DEFAULT 'info' COMMENT '通知類型',
title VARCHAR(255) NOT NULL COMMENT '通知標題',
message TEXT NOT NULL COMMENT '通知內容',
job_uuid VARCHAR(36) NULL COMMENT '關聯任務UUID',
link VARCHAR(500) NULL COMMENT '相關連結',
is_read BOOLEAN DEFAULT FALSE NOT NULL COMMENT '是否已讀',
read_at TIMESTAMP NULL COMMENT '閱讀時間',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '建立時間',
expires_at TIMESTAMP NULL COMMENT '過期時間',
extra_data JSON NULL COMMENT '額外數據',
INDEX idx_notification_uuid (notification_uuid),
INDEX idx_user_id (user_id),
INDEX idx_job_uuid (job_uuid),
INDEX idx_is_read (is_read),
INDEX idx_created_at (created_at),
FOREIGN KEY (user_id) REFERENCES dt_users(id) ON DELETE CASCADE,
FOREIGN KEY (job_uuid) REFERENCES dt_translation_jobs(job_uuid) ON DELETE SET NULL
) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
'''
}

47
create_test_data.py Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
創建測試數據
"""
from app import create_app, db
from app.models import Notification, NotificationType
from datetime import datetime
def create_test_notification():
"""創建測試通知"""
try:
app = create_app()
with app.app_context():
print("Creating test notification...")
# 創建測試通知
test_notification = Notification(
user_id=4, # ymirliu 用戶
type=NotificationType.INFO.value,
title='測試通知',
message='這是一個測試通知,用來驗證通知系統是否正常工作。',
extra_data={
'test_data': True,
'created_by': 'test_script'
}
)
db.session.add(test_notification)
db.session.commit()
print(f"Test notification created: {test_notification.notification_uuid}")
print(f"Total notifications in database: {Notification.query.count()}")
# 顯示所有通知
notifications = Notification.query.all()
for notification in notifications:
print(f" - {notification.title} ({notification.type})")
except Exception as e:
print(f"Error creating test notification: {e}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
create_test_notification()

69
fix_notification_table.py Normal file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
修正通知表結構腳本
"""
import pymysql
import os
from dotenv import load_dotenv
# 載入環境變數
load_dotenv('C:/Users/EGG/WORK/data/user_scrip/TOOL/env.txt')
def fix_notification_table():
"""修正通知表的欄位名稱"""
try:
# 連接資料庫
connection = pymysql.connect(
host=os.getenv('DB_HOST', 'localhost'),
user=os.getenv('DB_USER', 'root'),
password=os.getenv('DB_PASSWORD', ''),
database=os.getenv('DB_NAME', 'doc_translator'),
charset='utf8mb4'
)
with connection.cursor() as cursor:
# 檢查 dt_notifications 表結構
cursor.execute('DESCRIBE dt_notifications')
columns = cursor.fetchall()
print('Current table structure:')
for col in columns:
print(f' {col[0]} {col[1]}')
# 檢查是否有 metadata 欄位
has_metadata = any(col[0] == 'metadata' for col in columns)
has_extra_data = any(col[0] == 'extra_data' for col in columns)
print(f'\nHas metadata column: {has_metadata}')
print(f'Has extra_data column: {has_extra_data}')
if has_metadata and not has_extra_data:
print('\nRenaming metadata column to extra_data...')
cursor.execute('ALTER TABLE dt_notifications CHANGE metadata extra_data JSON NULL COMMENT "額外數據"')
connection.commit()
print('✅ Column renamed successfully')
# 再次檢查結構
cursor.execute('DESCRIBE dt_notifications')
columns = cursor.fetchall()
print('\nUpdated table structure:')
for col in columns:
print(f' {col[0]} {col[1]}')
elif has_extra_data:
print('✅ extra_data column already exists')
else:
print('❌ Neither metadata nor extra_data column found')
connection.close()
print('\n✅ Database structure check completed')
except Exception as e:
print(f'❌ Error fixing notification table: {e}')
import traceback
traceback.print_exc()
if __name__ == '__main__':
fix_notification_table()

42
fix_table_simple.py Normal file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
修正通知表腳本
"""
from app import create_app, db
from sqlalchemy import text
def fix_notification_table():
"""修正通知表"""
try:
app = create_app()
with app.app_context():
print("Fixing notification table...")
# 刪除通知表(如果存在)
try:
db.session.execute(text('DROP TABLE IF EXISTS dt_notifications'))
db.session.commit()
print("Old notification table dropped")
except Exception as e:
print(f"Info: {e}")
# 重新創建通知表
db.create_all()
print("New notification table created with correct structure")
# 檢查表結構
result = db.session.execute(text('DESCRIBE dt_notifications'))
columns = result.fetchall()
print("New table structure:")
for col in columns:
print(f" {col[0]} {col[1]}")
except Exception as e:
print(f"Error fixing notification table: {e}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
fix_notification_table()

View File

@@ -167,11 +167,12 @@
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useNotificationStore } from '@/stores/notification'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Document, House, Upload, List, Clock, User, Setting, Bell, Menu,
Fold, Expand, SwitchButton, SuccessFilled, WarningFilled,
CircleCloseFilled, InfoFilled
CircleCloseFilled, InfoFilled, Refresh
} from '@element-plus/icons-vue'
import { initWebSocket, cleanupWebSocket } from '@/utils/websocket'
@@ -179,13 +180,16 @@ import { initWebSocket, cleanupWebSocket } from '@/utils/websocket'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const notificationStore = useNotificationStore()
// 響應式數據
const sidebarCollapsed = ref(false)
const mobileSidebarVisible = ref(false)
const notificationDrawerVisible = ref(false)
const notifications = ref([])
const unreadCount = ref(0)
// 從 store 獲取通知相關數據
const notifications = computed(() => notificationStore.notifications)
const unreadCount = computed(() => notificationStore.unreadCount)
// 計算屬性
const currentRoute = computed(() => route)
@@ -239,10 +243,10 @@ const handleMenuClick = () => {
}
}
const showNotifications = () => {
const showNotifications = async () => {
notificationDrawerVisible.value = true
// 可以在這裡載入最新通知
loadNotifications()
// 載入最新通知
await notificationStore.fetchNotifications()
}
const handleUserMenuCommand = async (command) => {
@@ -270,50 +274,22 @@ const handleUserMenuCommand = async (command) => {
}
}
const loadNotifications = async () => {
const markAsRead = async (notificationId) => {
try {
// 這裡應該從 API 載入通知,目前使用模擬數據
notifications.value = [
{
id: 1,
type: 'success',
title: '翻譯完成',
message: '檔案「文件.docx」翻譯完成',
created_at: new Date().toISOString(),
read: false
},
{
id: 2,
type: 'warning',
title: '系統維護通知',
message: '系統將於今晚 23:00 進行維護',
created_at: new Date(Date.now() - 3600000).toISOString(),
read: true
}
]
unreadCount.value = notifications.value.filter(n => !n.read).length
await notificationStore.markAsRead(notificationId)
} catch (error) {
console.error('載入通知失敗:', error)
console.error('標記已讀失敗:', error)
}
}
const markAsRead = (notificationId) => {
const notification = notifications.value.find(n => n.id === notificationId)
if (notification) {
notification.read = true
unreadCount.value = notifications.value.filter(n => !n.read).length
const markAllAsRead = async () => {
try {
await notificationStore.markAllAsRead()
} catch (error) {
console.error('標記全部已讀失敗:', error)
}
}
const markAllAsRead = () => {
notifications.value.forEach(notification => {
notification.read = true
})
unreadCount.value = 0
ElMessage.success('所有通知已標記為已讀')
}
const getNotificationIcon = (type) => {
const iconMap = {
success: 'SuccessFilled',
@@ -355,7 +331,7 @@ onMounted(() => {
// initWebSocket()
// 載入通知
loadNotifications()
notificationStore.fetchNotifications()
// 監聽窗口大小變化
window.addEventListener('resize', handleResize)

View File

@@ -0,0 +1,63 @@
import { request } from '@/utils/request'
/**
* 通知相關 API 服務
*/
export const notificationAPI = {
/**
* 獲取通知列表
* @param {Object} params - 查詢參數
* @param {number} params.page - 頁碼
* @param {number} params.per_page - 每頁數量
* @param {string} params.status - 狀態過濾 ('all', 'unread', 'read')
* @param {string} params.type - 類型過濾
*/
getNotifications(params = {}) {
return request.get('/notifications', { params })
},
/**
* 獲取單個通知詳情
* @param {string} notificationId - 通知ID
*/
getNotification(notificationId) {
return request.get(`/notifications/${notificationId}`)
},
/**
* 標記通知為已讀
* @param {string} notificationId - 通知ID
*/
markAsRead(notificationId) {
return request.put(`/notifications/${notificationId}/read`)
},
/**
* 標記所有通知為已讀
*/
markAllAsRead() {
return request.put('/notifications/mark-all-read')
},
/**
* 刪除通知
* @param {string} notificationId - 通知ID
*/
deleteNotification(notificationId) {
return request.delete(`/notifications/${notificationId}`)
},
/**
* 清空所有已讀通知
*/
clearNotifications() {
return request.delete('/notifications/clear')
},
/**
* 創建測試通知(開發用)
*/
createTestNotification() {
return request.post('/notifications/test')
}
}

View File

@@ -0,0 +1,310 @@
import { defineStore } from 'pinia'
import { notificationAPI } from '@/services/notification'
import { ElMessage } from 'element-plus'
export const useNotificationStore = defineStore('notification', {
state: () => ({
notifications: [],
unreadCount: 0,
loading: false,
pagination: {
total: 0,
page: 1,
per_page: 20,
pages: 0
}
}),
getters: {
unreadNotifications: (state) => {
return state.notifications.filter(notification => !notification.is_read)
},
readNotifications: (state) => {
return state.notifications.filter(notification => notification.is_read)
},
hasUnreadNotifications: (state) => {
return state.unreadCount > 0
}
},
actions: {
/**
* 獲取通知列表
* @param {Object} params - 查詢參數
*/
async fetchNotifications(params = {}) {
try {
this.loading = true
const response = await notificationAPI.getNotifications({
page: this.pagination.page,
per_page: this.pagination.per_page,
...params
})
if (response.success) {
this.notifications = response.data.notifications
this.unreadCount = response.data.unread_count
this.pagination = {
...this.pagination,
...response.data.pagination
}
console.log('📮 [Notification] 通知列表已更新', {
total: this.pagination.total,
unread: this.unreadCount
})
}
return response
} catch (error) {
console.error('❌ [Notification] 獲取通知列表失敗:', error)
ElMessage.error('獲取通知失敗')
throw error
} finally {
this.loading = false
}
},
/**
* 標記通知為已讀
* @param {string} notificationId - 通知ID
*/
async markAsRead(notificationId) {
try {
const response = await notificationAPI.markAsRead(notificationId)
if (response.success) {
// 更新本地狀態
const notification = this.notifications.find(n => n.id === notificationId)
if (notification && !notification.is_read) {
notification.is_read = true
notification.read = true
notification.read_at = new Date().toISOString()
this.unreadCount = Math.max(0, this.unreadCount - 1)
}
console.log('✅ [Notification] 通知已標記為已讀:', notificationId)
}
return response
} catch (error) {
console.error('❌ [Notification] 標記已讀失敗:', error)
ElMessage.error('標記已讀失敗')
throw error
}
},
/**
* 標記所有通知為已讀
*/
async markAllAsRead() {
try {
const response = await notificationAPI.markAllAsRead()
if (response.success) {
// 更新本地狀態
this.notifications.forEach(notification => {
if (!notification.is_read) {
notification.is_read = true
notification.read = true
notification.read_at = new Date().toISOString()
}
})
this.unreadCount = 0
console.log('✅ [Notification] 所有通知已標記為已讀')
ElMessage.success(response.message || '所有通知已標記為已讀')
}
return response
} catch (error) {
console.error('❌ [Notification] 標記全部已讀失敗:', error)
ElMessage.error('標記全部已讀失敗')
throw error
}
},
/**
* 刪除通知
* @param {string} notificationId - 通知ID
*/
async deleteNotification(notificationId) {
try {
const response = await notificationAPI.deleteNotification(notificationId)
if (response.success) {
// 從本地狀態移除
const index = this.notifications.findIndex(n => n.id === notificationId)
if (index !== -1) {
const notification = this.notifications[index]
if (!notification.is_read) {
this.unreadCount = Math.max(0, this.unreadCount - 1)
}
this.notifications.splice(index, 1)
this.pagination.total = Math.max(0, this.pagination.total - 1)
}
console.log('🗑️ [Notification] 通知已刪除:', notificationId)
}
return response
} catch (error) {
console.error('❌ [Notification] 刪除通知失敗:', error)
ElMessage.error('刪除通知失敗')
throw error
}
},
/**
* 清空所有已讀通知
*/
async clearNotifications() {
try {
const response = await notificationAPI.clearNotifications()
if (response.success) {
// 從本地狀態移除已讀通知
this.notifications = this.notifications.filter(n => !n.is_read)
this.pagination.total = this.notifications.length
console.log('🧹 [Notification] 已讀通知已清除')
ElMessage.success(response.message || '已讀通知已清除')
}
return response
} catch (error) {
console.error('❌ [Notification] 清除通知失敗:', error)
ElMessage.error('清除通知失敗')
throw error
}
},
/**
* 添加新通知(用於 WebSocket 推送)
* @param {Object} notification - 通知數據
*/
addNotification(notification) {
// 檢查是否已存在
const exists = this.notifications.find(n => n.id === notification.id)
if (!exists) {
// 添加到列表開頭
this.notifications.unshift(notification)
// 更新未讀數量
if (!notification.is_read) {
this.unreadCount += 1
}
// 更新總數
this.pagination.total += 1
console.log('📩 [Notification] 新通知已添加:', notification.title)
// 顯示通知
ElMessage({
type: this.getMessageType(notification.type),
title: notification.title,
message: notification.message,
duration: 5000
})
}
},
/**
* 更新通知
* @param {Object} notification - 通知數據
*/
updateNotification(notification) {
const index = this.notifications.findIndex(n => n.id === notification.id)
if (index !== -1) {
const oldNotification = this.notifications[index]
// 更新未讀數量
if (oldNotification.is_read !== notification.is_read) {
if (notification.is_read) {
this.unreadCount = Math.max(0, this.unreadCount - 1)
} else {
this.unreadCount += 1
}
}
// 更新通知
this.notifications[index] = { ...oldNotification, ...notification }
console.log('📝 [Notification] 通知已更新:', notification.id)
}
},
/**
* 創建測試通知(開發用)
*/
async createTestNotification() {
try {
const response = await notificationAPI.createTestNotification()
if (response.success) {
// 重新獲取通知列表
await this.fetchNotifications()
ElMessage.success('測試通知已創建')
}
return response
} catch (error) {
console.error('❌ [Notification] 創建測試通知失敗:', error)
ElMessage.error('創建測試通知失敗')
throw error
}
},
/**
* 設置分頁
* @param {number} page - 頁碼
* @param {number} per_page - 每頁數量
*/
setPagination(page, per_page) {
this.pagination.page = page
if (per_page) {
this.pagination.per_page = per_page
}
},
/**
* 重置狀態
*/
reset() {
this.notifications = []
this.unreadCount = 0
this.loading = false
this.pagination = {
total: 0,
page: 1,
per_page: 20,
pages: 0
}
},
/**
* 獲取 ElMessage 類型
* @param {string} type - 通知類型
*/
getMessageType(type) {
const typeMap = {
'success': 'success',
'error': 'error',
'warning': 'warning',
'info': 'info',
'system': 'info'
}
return typeMap[type] || 'info'
}
}
})

View File

@@ -1,5 +1,6 @@
import { io } from 'socket.io-client'
import { useJobsStore } from '@/stores/jobs'
import { useNotificationStore } from '@/stores/notification'
import { ElMessage, ElNotification } from 'element-plus'
/**
@@ -93,6 +94,16 @@ class WebSocketService {
this.socket.on('system_notification', (data) => {
this.handleSystemNotification(data)
})
// 新通知推送
this.socket.on('new_notification', (data) => {
this.handleNewNotification(data)
})
// 系統消息
this.socket.on('system_message', (data) => {
this.handleSystemMessage(data)
})
// 連接狀態回應
this.socket.on('connected', (data) => {
@@ -177,6 +188,89 @@ class WebSocketService {
}
}
/**
* 處理新通知推送
* @param {Object} data - 通知資料
*/
handleNewNotification(data) {
try {
console.log('📩 [WebSocket] 收到新通知:', data)
const notificationStore = useNotificationStore()
// 添加通知到 store
notificationStore.addNotification(data)
// 顯示桌面通知
this.showDesktopNotification(data)
} catch (error) {
console.error('處理新通知失敗:', error)
}
}
/**
* 處理系統消息
* @param {Object} data - 系統消息資料
*/
handleSystemMessage(data) {
try {
console.log('📢 [WebSocket] 收到系統消息:', data)
const { message, type } = data
// 顯示系統消息
const messageType = type || 'info'
ElMessage({
type: messageType === 'system' ? 'info' : messageType,
message: message,
duration: 5000,
showClose: true
})
} catch (error) {
console.error('處理系統消息失敗:', error)
}
}
/**
* 顯示桌面通知
* @param {Object} notification - 通知資料
*/
showDesktopNotification(notification) {
try {
// 檢查瀏覽器是否支援通知
if (!('Notification' in window)) {
return
}
// 檢查通知權限
if (Notification.permission === 'granted') {
new Notification(notification.title, {
body: notification.message,
icon: '/panjit-logo.png',
tag: notification.id,
requireInteraction: false
})
} else if (Notification.permission !== 'denied') {
// 請求通知權限
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
new Notification(notification.title, {
body: notification.message,
icon: '/panjit-logo.png',
tag: notification.id,
requireInteraction: false
})
}
})
}
} catch (error) {
console.error('顯示桌面通知失敗:', error)
}
}
/**
* 訂閱任務狀態更新
* @param {string} jobUuid - 任務 UUID

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
重新創建通知表腳本
"""
from app import create_app, db
from app.models import Notification
def recreate_notification_table():
"""重新創建通知表"""
try:
app = create_app()
with app.app_context():
print("Recreating notification table...")
# 刪除通知表(如果存在)
try:
db.engine.execute('DROP TABLE IF EXISTS dt_notifications')
print("✅ Old notification table dropped")
except Exception as e:
print(f"Info: {e}")
# 重新創建通知表
db.create_all()
print("✅ New notification table created with correct structure")
# 檢查表結構
result = db.engine.execute('DESCRIBE dt_notifications')
columns = result.fetchall()
print("\nNew table structure:")
for col in columns:
print(f" {col[0]} {col[1]}")
except Exception as e:
print(f"Error recreating notification table: {e}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
recreate_notification_table()

74
test_notification_api.py Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
測試通知 API
"""
import requests
import json
# API 基礎 URL
BASE_URL = 'http://127.0.0.1:5000/api/v1'
def test_notification_api():
"""測試通知 API 端點"""
# 首先需要登入獲取 JWT Token
# 這裡使用預設的管理員帳號
login_data = {
'username': 'ymirliu',
'password': 'password123' # LDAP 系統預設密碼
}
try:
print("Testing notification API...")
# 登入
print("1. Testing login...")
login_response = requests.post(f'{BASE_URL}/auth/login', json=login_data, timeout=10)
print(f"Login status: {login_response.status_code}")
if login_response.status_code == 200:
token = login_response.json()['data']['access_token']
headers = {'Authorization': f'Bearer {token}'}
# 測試獲取通知列表
print("2. Testing get notifications...")
notifications_response = requests.get(f'{BASE_URL}/notifications', headers=headers, timeout=10)
print(f"Get notifications status: {notifications_response.status_code}")
if notifications_response.status_code == 200:
data = notifications_response.json()
print(f"Response: {json.dumps(data, indent=2, ensure_ascii=False)}")
else:
print(f"Error response: {notifications_response.text}")
# 測試創建測試通知
print("3. Testing create test notification...")
test_notification_response = requests.post(f'{BASE_URL}/notifications/test', headers=headers, timeout=10)
print(f"Create test notification status: {test_notification_response.status_code}")
if test_notification_response.status_code == 200:
data = test_notification_response.json()
print(f"Test notification created: {json.dumps(data, indent=2, ensure_ascii=False)}")
else:
print(f"Error response: {test_notification_response.text}")
# 再次獲取通知列表,應該能看到測試通知
print("4. Testing get notifications again...")
notifications_response = requests.get(f'{BASE_URL}/notifications', headers=headers, timeout=10)
print(f"Get notifications status: {notifications_response.status_code}")
if notifications_response.status_code == 200:
data = notifications_response.json()
print(f"Updated notifications: {json.dumps(data, indent=2, ensure_ascii=False)}")
else:
print(f"Error response: {notifications_response.text}")
else:
print(f"Login failed: {login_response.text}")
except requests.exceptions.ConnectionError:
print("Error: Could not connect to server. Make sure the Flask app is running on http://127.0.0.1:5000")
except Exception as e:
print(f"Error testing notification API: {e}")
if __name__ == '__main__':
test_notification_api()

43
test_routes.py Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
測試路由註冊
"""
import requests
import json
BASE_URL = 'http://127.0.0.1:5000'
def test_routes():
"""測試各種路由是否正確註冊"""
routes_to_test = [
'/api/v1/auth/health',
'/api/v1/notifications',
'/api/v1/jobs',
'/api/v1/health'
]
print("Testing route registration...")
for route in routes_to_test:
try:
url = f"{BASE_URL}{route}"
response = requests.get(url, timeout=5)
if response.status_code == 404:
print(f"{route} -> 404 NOT FOUND")
elif response.status_code == 401:
print(f"{route} -> 401 UNAUTHORIZED (route exists, needs auth)")
elif response.status_code == 200:
print(f"{route} -> 200 OK")
else:
print(f"🟡 {route} -> {response.status_code} {response.reason}")
except requests.exceptions.ConnectionError:
print(f"{route} -> CONNECTION ERROR")
except Exception as e:
print(f"{route} -> ERROR: {e}")
if __name__ == '__main__':
test_routes()