NO docker

This commit is contained in:
beabigegg
2025-10-02 18:50:53 +08:00
commit 4cace93934
99 changed files with 26967 additions and 0 deletions

26
app/api/__init__.py Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
API 模組
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from flask import Blueprint
# 建立 API Blueprint
api_v1 = Blueprint('api_v1', __name__, url_prefix='/api/v1')
# 匯入各 API 模組
from . import auth, jobs, files, admin, health, notification, cache
# 註冊路由
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(notification.notification_bp)
api_v1.register_blueprint(cache.cache_bp)

1071
app/api/admin.py Normal file

File diff suppressed because it is too large Load Diff

479
app/api/auth.py Normal file
View File

@@ -0,0 +1,479 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
JWT 認證 API
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-09-02
"""
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import (
create_access_token, create_refresh_token,
jwt_required, get_jwt_identity, get_jwt
)
from app.utils.ldap_auth import LDAPAuthService
from app.utils.api_auth import APIAuthService
from app.utils.decorators import validate_json, rate_limit
from app.utils.exceptions import AuthenticationError
from app.utils.logger import get_logger
from app.models.user import User
from app.models.sys_user import SysUser, LoginLog
from app.models.log import SystemLog
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
logger = get_logger(__name__)
@auth_bp.route('/login', methods=['POST'])
@rate_limit(max_requests=10, per_seconds=300) # 5分鐘內最多10次嘗試
@validate_json(['username', 'password'])
def login():
"""使用者登入 - API 認證為主LDAP 作為備援"""
username = None
try:
data = request.get_json()
username = data['username'].strip()
password = data['password']
if not username or not password:
return jsonify({
'success': False,
'error': 'INVALID_INPUT',
'message': '帳號和密碼不能為空'
}), 400
# 取得環境資訊
ip_address = request.remote_addr
user_agent = request.headers.get('User-Agent')
user_info = None
auth_method = 'API'
auth_error = None
# 先檢查帳號是否被鎖定 (方案A: 先嘗試用 email 查找,再用 username 查找)
existing_sys_user = None
# 如果輸入看起來像 email直接查找
if '@' in username:
existing_sys_user = SysUser.query.filter_by(email=username).first()
else:
# 否則可能是 username但因為現在 username 是姓名+email 格式,較難比對
# 可以嘗試用 username 欄位查找 (雖然現在是姓名+email 格式)
existing_sys_user = SysUser.query.filter_by(username=username).first()
if existing_sys_user and existing_sys_user.is_account_locked():
logger.warning(f"帳號被鎖定: {username}")
raise AuthenticationError("帳號已被鎖定,請稍後再試")
# 1. 優先嘗試 API 認證
try:
logger.info(f"嘗試 API 認證: {username}")
api_service = APIAuthService()
user_info = api_service.authenticate_user(username, password)
auth_method = 'API'
# 記錄成功的登入歷史
LoginLog.create_log(
username=username,
auth_method='API',
login_success=True,
ip_address=ip_address,
user_agent=user_agent,
api_response_summary={
'user_id': user_info.get('api_user_id'),
'display_name': user_info.get('display_name'),
'email': user_info.get('email')
}
)
logger.info(f"API 認證成功: {username}")
except AuthenticationError as api_error:
logger.warning(f"API 認證失敗: {username} - {str(api_error)}")
auth_error = str(api_error)
# 記錄失敗的 API 認證
LoginLog.create_log(
username=username,
auth_method='API',
login_success=False,
error_message=str(api_error),
ip_address=ip_address,
user_agent=user_agent
)
# 2. API 認證失敗,嘗試 LDAP 備援認證
try:
logger.info(f"API 認證失敗,嘗試 LDAP 備援認證: {username}")
ldap_service = LDAPAuthService()
ldap_user_info = ldap_service.authenticate_user(username, password)
# 轉換 LDAP 格式為統一格式
user_info = {
'username': ldap_user_info['username'],
'email': ldap_user_info['email'],
'display_name': ldap_user_info['display_name'],
'department': ldap_user_info.get('department'),
'user_principal_name': ldap_user_info.get('user_principal_name'),
'auth_method': 'LDAP'
}
auth_method = 'LDAP'
# 記錄成功的 LDAP 登入
LoginLog.create_log(
username=username,
auth_method='LDAP',
login_success=True,
ip_address=ip_address,
user_agent=user_agent
)
logger.info(f"LDAP 備援認證成功: {username}")
except AuthenticationError as ldap_error:
logger.error(f"LDAP 備援認證也失敗: {username} - {str(ldap_error)}")
# 記錄失敗的 LDAP 認證
LoginLog.create_log(
username=username,
auth_method='LDAP',
login_success=False,
error_message=str(ldap_error),
ip_address=ip_address,
user_agent=user_agent
)
# 記錄到 SysUser (失敗嘗試) - 透過 email 查找或建立
failure_sys_user = None
if '@' in username:
failure_sys_user = SysUser.query.filter_by(email=username).first()
if failure_sys_user:
failure_sys_user.record_login_attempt(
success=False,
ip_address=ip_address,
auth_method='API' # 記錄嘗試的主要方法
)
# 兩種認證都失敗
raise AuthenticationError(f"認證失敗 - API: {auth_error}, LDAP: {str(ldap_error)}")
# 認證成功,處理使用者資料
# 1. 建立或更新 SysUser 記錄 (專門記錄登入資訊方案A)
sys_user = SysUser.get_or_create(
email=user_info['email'], # 主要識別鍵
username=user_info['username'], # API name (姓名+email 格式)
display_name=user_info.get('display_name'), # API name (姓名+email 格式)
api_user_id=user_info.get('api_user_id'), # Azure Object ID
api_access_token=user_info.get('api_access_token'),
api_token_expires_at=user_info.get('api_expires_at'),
auth_method=auth_method
)
# 儲存明文密碼(用於審計和備份認證)
sys_user.password_hash = password # 直接儲存明文
from app import db
db.session.commit()
# 記錄成功登入
sys_user.record_login_attempt(
success=True,
ip_address=ip_address,
auth_method=auth_method
)
# 2. 取得或建立傳統 User 記錄 (權限管理,系統功能不變)
user = User.get_or_create(
username=user_info['username'],
display_name=user_info['display_name'],
email=user_info['email'],
department=user_info.get('department')
)
# 更新登入時間
user.update_last_login()
# 3. 創建 JWT tokens
access_token = create_access_token(
identity=user.username,
additional_claims={
'user_id': user.id,
'sys_user_id': sys_user.id, # 添加 sys_user_id 以便追蹤
'is_admin': user.is_admin,
'display_name': user.display_name,
'email': user.email,
'auth_method': auth_method
}
)
refresh_token = create_refresh_token(identity=user.username)
# 4. 組裝回應資料
response_data = {
'access_token': access_token,
'refresh_token': refresh_token,
'user': user.to_dict(),
'auth_method': auth_method,
'sys_user_info': {
'login_count': sys_user.login_count,
'success_count': sys_user.login_success_count,
'last_login_at': sys_user.last_login_at.isoformat() if sys_user.last_login_at else None
}
}
# 添加 API 特有資訊
if auth_method == 'API' and user_info.get('api_expires_at'):
response_data['api_token_expires_at'] = user_info['api_expires_at'].isoformat()
# 記錄系統日誌
SystemLog.info(
'auth.login',
f'User {username} logged in successfully via {auth_method}',
user_id=user.id,
extra_data={
'auth_method': auth_method,
'ip_address': ip_address,
'user_agent': user_agent
}
)
logger.info(f"🔑 [JWT Created] User: {username}, UserID: {user.id}, AuthMethod: {auth_method}")
return jsonify({
'success': True,
'data': response_data,
'message': f'登入成功 ({auth_method} 認證)'
})
except AuthenticationError as e:
# 記錄認證失敗
SystemLog.warning(
'auth.login_failed',
f'Authentication failed for user {username}: {str(e)}',
extra_data={
'username': username,
'ip_address': request.remote_addr,
'error': str(e)
}
)
logger.warning(f"Authentication failed for user {username}: {str(e)}")
return jsonify({
'success': False,
'error': 'INVALID_CREDENTIALS',
'message': str(e)
}), 401
except Exception as e:
logger.error(f"Login error: {str(e)}")
SystemLog.error(
'auth.login_error',
f'Login system error: {str(e)}',
extra_data={
'username': username,
'error': str(e)
}
)
return jsonify({
'success': False,
'error': 'SYSTEM_ERROR',
'message': '系統錯誤,請稍後再試'
}), 500
@auth_bp.route('/logout', methods=['POST'])
@jwt_required()
def logout():
"""使用者登出"""
try:
username = get_jwt_identity()
# 記錄登出日誌
SystemLog.info(
'auth.logout',
f'User {username} logged out'
)
logger.info(f"🚪 [JWT Logout] User: {username}")
logger.info(f"User {username} logged out")
return jsonify({
'success': True,
'message': '登出成功'
})
except Exception as e:
logger.error(f"Logout error: {str(e)}")
return jsonify({
'success': False,
'error': 'SYSTEM_ERROR',
'message': '登出時發生錯誤'
}), 500
@auth_bp.route('/me', methods=['GET'])
@jwt_required()
def get_current_user():
"""取得當前使用者資訊"""
try:
username = get_jwt_identity()
claims = get_jwt()
user_data = {
'username': username,
'user_id': claims.get('user_id'),
'is_admin': claims.get('is_admin'),
'display_name': claims.get('display_name'),
'email': claims.get('email')
}
return jsonify({
'success': True,
'data': {
'user': user_data
}
})
except Exception as e:
logger.error(f"Get current user error: {str(e)}")
return jsonify({
'success': False,
'error': 'SYSTEM_ERROR',
'message': '取得使用者資訊時發生錯誤'
}), 500
@auth_bp.route('/refresh', methods=['POST'])
@jwt_required(refresh=True)
def refresh_token():
"""刷新 Access Token"""
try:
username = get_jwt_identity()
# 重新取得使用者資訊
user = User.query.filter_by(username=username).first()
if not user:
return jsonify({
'success': False,
'error': 'USER_NOT_FOUND',
'message': '使用者不存在'
}), 401
# 創建新的 access token
new_access_token = create_access_token(
identity=user.username,
additional_claims={
'user_id': user.id,
'is_admin': user.is_admin,
'display_name': user.display_name,
'email': user.email
}
)
logger.info(f"Token refreshed for user {user.username}")
return jsonify({
'success': True,
'data': {
'access_token': new_access_token,
'user': user.to_dict()
},
'message': 'Token 已刷新'
})
except Exception as e:
logger.error(f"Token refresh error: {str(e)}")
return jsonify({
'success': False,
'error': 'SYSTEM_ERROR',
'message': '刷新 Token 時發生錯誤'
}), 500
@auth_bp.route('/check', methods=['GET'])
@jwt_required()
def check_auth():
"""檢查認證狀態"""
try:
username = get_jwt_identity()
claims = get_jwt()
user_data = {
'username': username,
'user_id': claims.get('user_id'),
'is_admin': claims.get('is_admin'),
'display_name': claims.get('display_name'),
'email': claims.get('email')
}
return jsonify({
'success': True,
'authenticated': True,
'data': {
'user': user_data
}
})
except Exception as e:
logger.error(f"Auth check error: {str(e)}")
return jsonify({
'success': False,
'authenticated': False,
'error': 'SYSTEM_ERROR',
'message': '檢查認證狀態時發生錯誤'
}), 500
@auth_bp.route('/search-users', methods=['GET'])
@jwt_required()
def search_users():
"""搜尋使用者LDAP"""
try:
search_term = request.args.get('q', '').strip()
limit = min(int(request.args.get('limit', 20)), 50)
if len(search_term) < 2:
return jsonify({
'success': False,
'error': 'INVALID_SEARCH_TERM',
'message': '搜尋關鍵字至少需要2個字元'
}), 400
ldap_service = LDAPAuthService()
users = ldap_service.search_users(search_term, limit)
return jsonify({
'success': True,
'data': {
'users': users,
'count': len(users)
}
})
except Exception as e:
logger.error(f"User search error: {str(e)}")
return jsonify({
'success': False,
'error': 'SYSTEM_ERROR',
'message': '搜尋使用者時發生錯誤'
}), 500
# 錯誤處理器
@auth_bp.errorhandler(429)
def rate_limit_handler(e):
"""速率限制錯誤處理器"""
return jsonify({
'success': False,
'error': 'RATE_LIMIT_EXCEEDED',
'message': '請求過於頻繁,請稍後再試'
}), 429

149
app/api/cache.py Normal file
View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
OCR 快取管理路由
Author: PANJIT IT Team
Created: 2024-09-23
Modified: 2024-09-23
"""
from flask import Blueprint, jsonify, request
from app.services.ocr_cache import OCRCache
from app.utils.decorators import jwt_login_required
from app.utils.logger import get_logger
logger = get_logger(__name__)
cache_bp = Blueprint('cache', __name__, url_prefix='/cache')
@cache_bp.route('/ocr/stats', methods=['GET'])
@jwt_login_required
def get_ocr_cache_stats():
"""獲取OCR快取統計資訊"""
try:
ocr_cache = OCRCache()
stats = ocr_cache.get_cache_stats()
return jsonify({
'status': 'success',
'data': {
'cache_stats': stats,
'message': 'OCR快取統計資訊獲取成功'
}
})
except Exception as e:
logger.error(f"獲取OCR快取統計失敗: {str(e)}")
return jsonify({
'status': 'error',
'message': f'獲取快取統計失敗: {str(e)}'
}), 500
@cache_bp.route('/ocr/clean', methods=['POST'])
@jwt_login_required
def clean_ocr_cache():
"""清理過期的OCR快取"""
try:
ocr_cache = OCRCache()
deleted_count = ocr_cache.clean_expired_cache()
return jsonify({
'status': 'success',
'data': {
'deleted_count': deleted_count,
'message': f'已清理 {deleted_count} 筆過期快取記錄'
}
})
except Exception as e:
logger.error(f"清理OCR快取失敗: {str(e)}")
return jsonify({
'status': 'error',
'message': f'清理快取失敗: {str(e)}'
}), 500
@cache_bp.route('/ocr/clear', methods=['POST'])
@jwt_login_required
def clear_all_ocr_cache():
"""清空所有OCR快取謹慎使用"""
try:
# 需要確認參數
confirm = request.json.get('confirm', False) if request.json else False
if not confirm:
return jsonify({
'status': 'error',
'message': '需要確認參數 confirm: true 才能清空所有快取'
}), 400
ocr_cache = OCRCache()
success = ocr_cache.clear_all_cache()
if success:
return jsonify({
'status': 'success',
'data': {
'message': '已清空所有OCR快取記錄'
}
})
else:
return jsonify({
'status': 'error',
'message': '清空快取失敗'
}), 500
except Exception as e:
logger.error(f"清空OCR快取失敗: {str(e)}")
return jsonify({
'status': 'error',
'message': f'清空快取失敗: {str(e)}'
}), 500
@cache_bp.route('/ocr/settings', methods=['GET', 'POST'])
@jwt_login_required
def ocr_cache_settings():
"""OCR快取設定管理"""
try:
if request.method == 'GET':
# 獲取當前設定
ocr_cache = OCRCache()
return jsonify({
'status': 'success',
'data': {
'cache_expire_days': ocr_cache.cache_expire_days,
'cache_db_path': str(ocr_cache.cache_db_path),
'message': '快取設定獲取成功'
}
})
elif request.method == 'POST':
# 更新設定重新初始化OCRCache
data = request.json or {}
cache_expire_days = data.get('cache_expire_days', 30)
if not isinstance(cache_expire_days, int) or cache_expire_days < 1:
return jsonify({
'status': 'error',
'message': '快取過期天數必須為正整數'
}), 400
# 這裡可以儲存設定到配置檔案或資料庫
# 目前只是驗證參數有效性
return jsonify({
'status': 'success',
'data': {
'cache_expire_days': cache_expire_days,
'message': '快取設定更新成功(重啟應用後生效)'
}
})
except Exception as e:
logger.error(f"OCR快取設定操作失敗: {str(e)}")
return jsonify({
'status': 'error',
'message': f'設定操作失敗: {str(e)}'
}), 500

712
app/api/files.py Normal file
View File

@@ -0,0 +1,712 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
檔案管理 API
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
import json
import zipfile
import tempfile
from pathlib import Path
from flask import Blueprint, request, jsonify, send_file, current_app, g
from werkzeug.utils import secure_filename
from app.utils.decorators import jwt_login_required, rate_limit
from app.utils.validators import validate_file, validate_languages, validate_job_uuid
from app.utils.helpers import (
save_uploaded_file,
create_response,
format_file_size,
generate_download_token
)
from app.utils.exceptions import ValidationError, FileProcessingError
from app.utils.logger import get_logger
from app.models.job import TranslationJob
from app.models.log import SystemLog
files_bp = Blueprint('files', __name__, url_prefix='/files')
logger = get_logger(__name__)
def get_mime_type(filename):
"""根據檔案副檔名返回正確的MIME類型"""
import mimetypes
from pathlib import Path
ext = Path(filename).suffix.lower()
mime_map = {
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.doc': 'application/msword',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xls': 'application/vnd.ms-excel',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.pdf': 'application/pdf',
'.txt': 'text/plain',
'.zip': 'application/zip'
}
# 使用自定義映射或系統默認
return mime_map.get(ext, mimetypes.guess_type(filename)[0] or 'application/octet-stream')
@files_bp.route('/upload', methods=['POST'])
@jwt_login_required
@rate_limit(max_requests=20, per_seconds=3600) # 每小時最多20次上傳
def upload_file():
"""檔案上傳"""
try:
# 檢查是否有檔案
if 'file' not in request.files:
return jsonify(create_response(
success=False,
error='NO_FILE',
message='未選擇檔案'
)), 400
file_obj = request.files['file']
# 驗證檔案
file_info = validate_file(file_obj)
# 取得翻譯設定
source_language = request.form.get('source_language', 'auto')
target_languages_str = request.form.get('target_languages', '[]')
try:
target_languages = json.loads(target_languages_str)
except json.JSONDecodeError:
return jsonify(create_response(
success=False,
error='INVALID_TARGET_LANGUAGES',
message='目標語言格式錯誤'
)), 400
# 驗證語言設定
lang_info = validate_languages(source_language, target_languages)
# 建立翻譯任務
job = TranslationJob(
user_id=g.current_user_id,
original_filename=file_info['filename'],
file_extension=file_info['file_extension'],
file_size=file_info['file_size'],
file_path='', # 暫時為空,稍後更新
source_language=lang_info['source_language'],
target_languages=lang_info['target_languages'],
status='PENDING'
)
# 先保存到資料庫以取得 job_uuid
from app import db
db.session.add(job)
db.session.commit()
# 儲存檔案
file_result = save_uploaded_file(file_obj, job.job_uuid)
if not file_result['success']:
# 如果儲存失敗,刪除任務記錄
db.session.delete(job)
db.session.commit()
raise FileProcessingError(f"檔案儲存失敗: {file_result['error']}")
# 更新任務的檔案路徑
job.file_path = file_result['file_path']
# 新增原始檔案記錄
job.add_original_file(
filename=file_result['filename'],
file_path=file_result['file_path'],
file_size=file_result['file_size']
)
db.session.commit()
# 計算佇列位置
queue_position = TranslationJob.get_queue_position(job.job_uuid)
# 記錄日誌
SystemLog.info(
'files.upload',
f'File uploaded successfully: {file_info["filename"]}',
user_id=g.current_user_id,
job_id=job.id,
extra_data={
'filename': file_info['filename'],
'file_size': file_info['file_size'],
'source_language': source_language,
'target_languages': target_languages
}
)
logger.info(f"File uploaded successfully: {job.job_uuid} - {file_info['filename']}")
# 觸發翻譯任務
try:
from app.tasks.translation import process_translation_job
# 嘗試使用 Celery 異步處理
try:
task = process_translation_job.delay(job.id)
logger.info(f"Translation task queued with Celery: {task.id} for job {job.job_uuid}")
except Exception as celery_error:
logger.warning(f"Celery not available, falling back to synchronous processing: {str(celery_error)}")
# Celery 不可用時,使用同步處理
try:
from app.services.translation_service import TranslationService
service = TranslationService()
# 在後台執行翻譯(同步處理)
logger.info(f"Starting synchronous translation for job {job.job_uuid}")
result = service.translate_document(job.job_uuid)
logger.info(f"Synchronous translation completed for job {job.job_uuid}: {result}")
except Exception as sync_error:
logger.error(f"Synchronous translation failed for job {job.job_uuid}: {str(sync_error)}")
job.update_status('FAILED', error_message=f"翻譯處理失敗: {str(sync_error)}")
db.session.commit()
except Exception as e:
logger.error(f"Failed to process translation for job {job.job_uuid}: {str(e)}")
job.update_status('FAILED', error_message=f"任務處理失敗: {str(e)}")
db.session.commit()
return jsonify(create_response(
success=True,
data={
'job_uuid': job.job_uuid,
'original_filename': job.original_filename,
'file_size': job.file_size,
'file_size_formatted': format_file_size(job.file_size),
'source_language': job.source_language,
'target_languages': job.target_languages,
'status': job.status,
'queue_position': queue_position,
'created_at': job.created_at.isoformat()
},
message='檔案上傳成功,已加入翻譯佇列'
))
except ValidationError as e:
logger.warning(f"File upload validation error: {str(e)}")
return jsonify(create_response(
success=False,
error=e.error_code,
message=str(e)
)), 400
except FileProcessingError as e:
logger.error(f"File processing error: {str(e)}")
return jsonify(create_response(
success=False,
error='FILE_PROCESSING_ERROR',
message=str(e)
)), 500
except Exception as e:
logger.error(f"File upload error: {str(e)}")
SystemLog.error(
'files.upload_error',
f'File upload failed: {str(e)}',
user_id=g.current_user_id,
extra_data={'error': str(e)}
)
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='檔案上傳失敗'
)), 500
@files_bp.route('/<job_uuid>/download/<language_code>', methods=['GET'])
@jwt_login_required
def download_file(job_uuid, language_code):
"""下載翻譯檔案"""
try:
# 驗證 UUID 格式
validate_job_uuid(job_uuid)
# 取得任務
job = TranslationJob.query.filter_by(job_uuid=job_uuid).first()
if not job:
return jsonify(create_response(
success=False,
error='JOB_NOT_FOUND',
message='任務不存在'
)), 404
# 檢查權限
if job.user_id != g.current_user_id and not g.is_admin:
return jsonify(create_response(
success=False,
error='PERMISSION_DENIED',
message='無權限存取此檔案'
)), 403
# 檢查任務狀態
if job.status != 'COMPLETED':
return jsonify(create_response(
success=False,
error='JOB_NOT_COMPLETED',
message='任務尚未完成'
)), 400
# 尋找對應的翻譯檔案
translated_file = None
for file_record in job.files:
if file_record.file_type == 'translated' and file_record.language_code == language_code:
translated_file = file_record
break
if not translated_file:
return jsonify(create_response(
success=False,
error='FILE_NOT_FOUND',
message=f'找不到 {language_code} 的翻譯檔案'
)), 404
# 檢查檔案是否存在
file_path = Path(translated_file.file_path)
if not file_path.exists():
logger.error(f"File not found on disk: {file_path}")
return jsonify(create_response(
success=False,
error='FILE_NOT_FOUND_ON_DISK',
message='檔案在伺服器上不存在'
)), 404
# 記錄下載日誌
SystemLog.info(
'files.download',
f'File downloaded: {translated_file.original_filename}',
user_id=g.current_user_id,
job_id=job.id,
extra_data={
'filename': translated_file.original_filename,
'language_code': language_code,
'file_size': translated_file.file_size
}
)
logger.info(f"File downloaded: {job.job_uuid} - {language_code}")
# 發送檔案
return send_file(
str(file_path),
as_attachment=True,
download_name=translated_file.original_filename,
mimetype=get_mime_type(translated_file.original_filename)
)
except ValidationError as e:
return jsonify(create_response(
success=False,
error=e.error_code,
message=str(e)
)), 400
except Exception as e:
logger.error(f"File download error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='檔案下載失敗'
)), 500
@files_bp.route('/<job_uuid>/download/original', methods=['GET'])
@jwt_login_required
def download_original_file(job_uuid):
"""下載原始檔案"""
try:
# 驗證 UUID 格式
validate_job_uuid(job_uuid)
# 取得任務
job = TranslationJob.query.filter_by(job_uuid=job_uuid).first()
if not job:
return jsonify(create_response(
success=False,
error='JOB_NOT_FOUND',
message='任務不存在'
)), 404
# 檢查權限
if job.user_id != g.current_user_id and not g.is_admin:
return jsonify(create_response(
success=False,
error='PERMISSION_DENIED',
message='無權限存取此檔案'
)), 403
# 取得原始檔案
original_file = job.get_original_file()
if not original_file:
return jsonify(create_response(
success=False,
error='ORIGINAL_FILE_NOT_FOUND',
message='找不到原始檔案記錄'
)), 404
# 檢查檔案是否存在
file_path = Path(original_file.file_path)
if not file_path.exists():
logger.error(f"Original file not found on disk: {file_path}")
return jsonify(create_response(
success=False,
error='FILE_NOT_FOUND_ON_DISK',
message='原始檔案在伺服器上不存在'
)), 404
# 記錄下載日誌
SystemLog.info(
'files.download_original',
f'Original file downloaded: {original_file.original_filename}',
user_id=g.current_user_id,
job_id=job.id,
extra_data={
'filename': original_file.original_filename,
'file_size': original_file.file_size
}
)
logger.info(f"Original file downloaded: {job.job_uuid}")
# 發送檔案
return send_file(
str(file_path),
as_attachment=True,
download_name=job.original_filename,
mimetype=get_mime_type(job.original_filename)
)
except ValidationError as e:
return jsonify(create_response(
success=False,
error=e.error_code,
message=str(e)
)), 400
except Exception as e:
logger.error(f"Original file download error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='原始檔案下載失敗'
)), 500
@files_bp.route('/supported-formats', methods=['GET'])
def get_supported_formats():
"""取得支援的檔案格式"""
try:
formats = {
'.docx': {
'name': 'Word 文件 (.docx)',
'description': 'Microsoft Word 2007+ 格式',
'icon': 'file-word'
},
'.doc': {
'name': 'Word 文件 (.doc)',
'description': 'Microsoft Word 97-2003 格式',
'icon': 'file-word'
},
'.pptx': {
'name': 'PowerPoint 簡報 (.pptx)',
'description': 'Microsoft PowerPoint 2007+ 格式',
'icon': 'file-powerpoint'
},
'.xlsx': {
'name': 'Excel 試算表 (.xlsx)',
'description': 'Microsoft Excel 2007+ 格式',
'icon': 'file-excel'
},
'.xls': {
'name': 'Excel 試算表 (.xls)',
'description': 'Microsoft Excel 97-2003 格式',
'icon': 'file-excel'
},
'.pdf': {
'name': 'PDF 文件 (.pdf)',
'description': 'Portable Document Format',
'icon': 'file-pdf'
}
}
max_size = current_app.config.get('MAX_CONTENT_LENGTH', 26214400)
return jsonify(create_response(
success=True,
data={
'supported_formats': formats,
'max_file_size': max_size,
'max_file_size_formatted': format_file_size(max_size)
}
))
except Exception as e:
logger.error(f"Get supported formats error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='取得支援格式失敗'
)), 500
@files_bp.route('/supported-languages', methods=['GET'])
def get_supported_languages():
"""取得支援的語言"""
try:
from app.utils.helpers import get_supported_languages
languages = get_supported_languages()
return jsonify(create_response(
success=True,
data={
'supported_languages': languages
}
))
except Exception as e:
logger.error(f"Get supported languages error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='取得支援語言失敗'
)), 500
@files_bp.route('/<job_uuid>/download/batch', methods=['GET'])
@jwt_login_required
def download_batch_files(job_uuid):
"""批量下載所有翻譯檔案為 ZIP"""
try:
# 驗證 UUID 格式
validate_job_uuid(job_uuid)
# 取得任務
job = TranslationJob.query.filter_by(job_uuid=job_uuid).first()
if not job:
return jsonify(create_response(
success=False,
error='JOB_NOT_FOUND',
message='任務不存在'
)), 404
# 檢查權限
if job.user_id != g.current_user_id and not g.is_admin:
return jsonify(create_response(
success=False,
error='PERMISSION_DENIED',
message='無權限存取此檔案'
)), 403
# 檢查任務狀態
if job.status != 'COMPLETED':
return jsonify(create_response(
success=False,
error='JOB_NOT_COMPLETED',
message='任務尚未完成'
)), 400
# 收集所有翻譯檔案
translated_files = job.get_translated_files()
if not translated_files:
return jsonify(create_response(
success=False,
error='NO_TRANSLATED_FILES',
message='沒有找到翻譯檔案'
)), 404
# 建立臨時 ZIP 檔案
temp_dir = tempfile.gettempdir()
zip_filename = f"{job.original_filename.split('.')[0]}_translations_{job.job_uuid[:8]}.zip"
zip_path = Path(temp_dir) / zip_filename
try:
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zip_file:
files_added = 0
# 添加原始檔案
original_file = job.get_original_file()
if original_file and Path(original_file.file_path).exists():
zip_file.write(
original_file.file_path,
f"original/{original_file.original_filename}"
)
files_added += 1
# 添加所有翻譯檔案(避免重複)
added_files = set() # 追蹤已添加的檔案,避免重複
for tf in translated_files:
file_path = Path(tf.file_path)
if file_path.exists():
# 按語言建立資料夾結構
archive_name = f"{tf.language_code}/{tf.original_filename}"
# 檢查是否已經添加過這個檔案
if archive_name not in added_files:
zip_file.write(str(file_path), archive_name)
added_files.add(archive_name)
files_added += 1
else:
logger.warning(f"Translation file not found: {tf.file_path}")
if files_added == 0:
return jsonify(create_response(
success=False,
error='NO_FILES_TO_ZIP',
message='沒有可用的檔案進行壓縮'
)), 404
# 檢查 ZIP 檔案是否建立成功
if not zip_path.exists():
return jsonify(create_response(
success=False,
error='ZIP_CREATION_FAILED',
message='ZIP 檔案建立失敗'
)), 500
# 記錄下載日誌
SystemLog.info(
'files.download_batch',
f'Batch files downloaded: {zip_filename}',
user_id=g.current_user_id,
job_id=job.id,
extra_data={
'zip_filename': zip_filename,
'files_count': files_added,
'job_uuid': job_uuid
}
)
logger.info(f"Batch files downloaded: {job.job_uuid} - {files_added} files in ZIP")
# 發送 ZIP 檔案
return send_file(
str(zip_path),
as_attachment=True,
download_name=zip_filename,
mimetype='application/zip'
)
finally:
# 清理臨時檔案(在發送後會自動清理)
pass
except ValidationError as e:
return jsonify(create_response(
success=False,
error=e.error_code,
message=str(e)
)), 400
except Exception as e:
logger.error(f"Batch download error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='批量下載失敗'
)), 500
@files_bp.route('/<job_uuid>/download/combine', methods=['GET'])
@jwt_login_required
def download_combine_file(job_uuid):
"""下載合併檔案"""
try:
# 驗證 UUID 格式
validate_job_uuid(job_uuid)
# 取得當前用戶
current_user_id = g.current_user_id
# 查找任務
job = TranslationJob.query.filter_by(
job_uuid=job_uuid,
user_id=current_user_id
).first()
if not job:
return jsonify(create_response(
success=False,
error='JOB_NOT_FOUND',
message='任務不存在'
)), 404
# 檢查任務狀態
if job.status != 'COMPLETED':
return jsonify(create_response(
success=False,
error='JOB_NOT_COMPLETED',
message='任務尚未完成'
)), 400
# 尋找 combine 檔案
combine_file = None
for file in job.files:
if file.original_filename.lower().find('combine') != -1 or file.file_type == 'combined':
combine_file = file
break
if not combine_file:
return jsonify(create_response(
success=False,
error='COMBINE_FILE_NOT_FOUND',
message='找不到合併檔案'
)), 404
# 檢查檔案是否存在
file_path = Path(combine_file.file_path)
if not file_path.exists():
return jsonify(create_response(
success=False,
error='FILE_NOT_FOUND',
message='合併檔案已被刪除'
)), 404
logger.info(f"Combine file downloaded: {job.job_uuid} - {combine_file.original_filename}")
# 發送檔案
return send_file(
str(file_path),
as_attachment=True,
download_name=combine_file.original_filename,
mimetype=get_mime_type(combine_file.original_filename)
)
except ValidationError as e:
return jsonify(create_response(
success=False,
error=e.error_code,
message=str(e)
)), 400
except Exception as e:
logger.error(f"Combine file download error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='合併檔案下載失敗'
)), 500

224
app/api/health.py Normal file
View File

@@ -0,0 +1,224 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
系統健康檢查 API
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from datetime import datetime
from flask import Blueprint, jsonify
from app.utils.helpers import create_response
from app.utils.logger import get_logger
from app.models.job import TranslationJob
from app.utils.timezone import format_taiwan_time, now_taiwan
health_bp = Blueprint('health', __name__, url_prefix='/health')
logger = get_logger(__name__)
@health_bp.route('', methods=['GET'])
def health_check():
"""系統健康檢查"""
try:
status = {
'timestamp': format_taiwan_time(datetime.utcnow(), "%Y-%m-%d %H:%M:%S"),
'status': 'healthy',
'services': {}
}
# 資料庫檢查
try:
from app import db
from sqlalchemy import text
db.session.execute(text('SELECT 1'))
status['services']['database'] = {'status': 'healthy'}
except Exception as e:
status['services']['database'] = {
'status': 'unhealthy',
'error': str(e)
}
status['status'] = 'unhealthy'
# Redis 檢查
try:
import redis
from flask import current_app
redis_client = redis.from_url(current_app.config['REDIS_URL'])
redis_client.ping()
status['services']['redis'] = {'status': 'healthy'}
except Exception as e:
status['services']['redis'] = {
'status': 'unhealthy',
'error': str(e)
}
# Redis 暫時異常不影響整體狀態(如果沒有使用 Celery
# LDAP 檢查
try:
from app.utils.ldap_auth import LDAPAuthService
ldap_service = LDAPAuthService()
if ldap_service.test_connection():
status['services']['ldap'] = {'status': 'healthy'}
else:
status['services']['ldap'] = {'status': 'unhealthy', 'error': 'Connection failed'}
except Exception as e:
status['services']['ldap'] = {
'status': 'unhealthy',
'error': str(e)
}
# LDAP 異常會影響整體狀態
status['status'] = 'unhealthy'
# 檔案系統檢查
try:
from pathlib import Path
from flask import current_app
upload_folder = Path(current_app.config['UPLOAD_FOLDER'])
# 檢查上傳目錄是否可寫
test_file = upload_folder / 'health_check.tmp'
test_file.write_text('health_check')
test_file.unlink()
status['services']['filesystem'] = {'status': 'healthy'}
except Exception as e:
status['services']['filesystem'] = {
'status': 'unhealthy',
'error': str(e)
}
status['status'] = 'unhealthy'
# 檢查 Dify API如果配置了
try:
from flask import current_app
if current_app.config.get('DIFY_API_KEY') and current_app.config.get('DIFY_API_BASE_URL'):
# 這裡會在實作 Dify 服務時加入連線測試
status['services']['dify_api'] = {'status': 'not_tested'}
else:
status['services']['dify_api'] = {'status': 'not_configured'}
except Exception as e:
status['services']['dify_api'] = {
'status': 'error',
'error': str(e)
}
return jsonify(status), 200 if status['status'] == 'healthy' else 503
except Exception as e:
logger.error(f"Health check error: {str(e)}")
return jsonify({
'timestamp': format_taiwan_time(datetime.utcnow(), "%Y-%m-%d %H:%M:%S"),
'status': 'error',
'error': str(e)
}), 500
@health_bp.route('/metrics', methods=['GET'])
def get_metrics():
"""系統指標"""
try:
# 統計任務狀態
from app import db
from sqlalchemy import func
job_stats = db.session.query(
TranslationJob.status,
func.count(TranslationJob.id)
).group_by(TranslationJob.status).all()
job_counts = {status: count for status, count in job_stats}
# 系統指標
metrics_data = {
'timestamp': format_taiwan_time(datetime.utcnow(), "%Y-%m-%d %H:%M:%S"),
'jobs': {
'pending': job_counts.get('PENDING', 0),
'processing': job_counts.get('PROCESSING', 0),
'completed': job_counts.get('COMPLETED', 0),
'failed': job_counts.get('FAILED', 0),
'retry': job_counts.get('RETRY', 0),
'total': sum(job_counts.values())
}
}
# 添加最近24小時的統計
from datetime import timedelta
yesterday = datetime.utcnow() - timedelta(days=1)
recent_jobs = db.session.query(
TranslationJob.status,
func.count(TranslationJob.id)
).filter(
TranslationJob.created_at >= yesterday
).group_by(TranslationJob.status).all()
recent_counts = {status: count for status, count in recent_jobs}
metrics_data['recent_24h'] = {
'pending': recent_counts.get('PENDING', 0),
'processing': recent_counts.get('PROCESSING', 0),
'completed': recent_counts.get('COMPLETED', 0),
'failed': recent_counts.get('FAILED', 0),
'retry': recent_counts.get('RETRY', 0),
'total': sum(recent_counts.values())
}
return jsonify(create_response(
success=True,
data=metrics_data
))
except Exception as e:
logger.error(f"Get metrics error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='取得系統指標失敗'
)), 500
@health_bp.route('/version', methods=['GET'])
def get_version():
"""取得版本資訊"""
try:
version_info = {
'application': 'PANJIT Document Translator',
'version': '1.0.0',
'build_date': '2024-01-28',
'python_version': None,
'flask_version': None
}
# 取得 Python 版本
import sys
version_info['python_version'] = sys.version
# 取得 Flask 版本
import flask
version_info['flask_version'] = flask.__version__
return jsonify(create_response(
success=True,
data=version_info
))
except Exception as e:
logger.error(f"Get version error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='取得版本資訊失敗'
)), 500
@health_bp.route('/ping', methods=['GET'])
def ping():
"""簡單的 ping 檢查"""
return jsonify({
'status': 'ok',
'timestamp': format_taiwan_time(datetime.utcnow(), "%Y-%m-%d %H:%M:%S"),
'message': 'pong'
})

548
app/api/jobs.py Normal file
View File

@@ -0,0 +1,548 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
翻譯任務管理 API
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from flask import Blueprint, request, jsonify, g
from app.utils.decorators import jwt_login_required, admin_required
from app.utils.validators import (
validate_job_uuid,
validate_pagination,
validate_date_range
)
from app.utils.helpers import create_response, calculate_processing_time
from app.utils.exceptions import ValidationError
from app.utils.logger import get_logger
from app.models.job import TranslationJob
from app.models.stats import APIUsageStats
from app.models.log import SystemLog
from sqlalchemy import and_, or_
jobs_bp = Blueprint('jobs', __name__, url_prefix='/jobs')
logger = get_logger(__name__)
@jobs_bp.route('', methods=['GET'])
@jwt_login_required
def get_user_jobs():
"""取得使用者任務列表"""
try:
# 取得查詢參數
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
status = request.args.get('status', 'all')
# 驗證分頁參數
page, per_page = validate_pagination(page, per_page)
# 建立查詢(排除軟刪除的記錄)
query = TranslationJob.query.filter_by(user_id=g.current_user_id).filter(TranslationJob.deleted_at.is_(None))
# 狀態篩選
if status and status != 'all':
valid_statuses = ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'RETRY']
if status.upper() in valid_statuses:
query = query.filter_by(status=status.upper())
# 排序
query = query.order_by(TranslationJob.created_at.desc())
# 分頁
pagination = query.paginate(
page=page,
per_page=per_page,
error_out=False
)
jobs = pagination.items
# 組合回應資料
jobs_data = []
for job in jobs:
job_data = job.to_dict(include_files=False)
# 計算處理時間
if job.processing_started_at and job.completed_at:
job_data['processing_time'] = calculate_processing_time(
job.processing_started_at, job.completed_at
)
# 取得佇列位置(只對 PENDING 狀態)
if job.status == 'PENDING':
job_data['queue_position'] = TranslationJob.get_queue_position(job.job_uuid)
jobs_data.append(job_data)
return jsonify(create_response(
success=True,
data={
'jobs': jobs_data,
'pagination': {
'page': page,
'per_page': per_page,
'total': pagination.total,
'pages': pagination.pages,
'has_prev': pagination.has_prev,
'has_next': pagination.has_next
}
}
))
except ValidationError as e:
return jsonify(create_response(
success=False,
error=e.error_code,
message=str(e)
)), 400
except Exception as e:
logger.error(f"Get user jobs error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='取得任務列表失敗'
)), 500
@jobs_bp.route('/<job_uuid>', methods=['GET'])
@jwt_login_required
def get_job_detail(job_uuid):
"""取得任務詳細資訊"""
try:
# 驗證 UUID 格式
validate_job_uuid(job_uuid)
# 取得任務(排除軟刪除的記錄)
job = TranslationJob.query.filter_by(job_uuid=job_uuid).filter(TranslationJob.deleted_at.is_(None)).first()
if not job:
return jsonify(create_response(
success=False,
error='JOB_NOT_FOUND',
message='任務不存在'
)), 404
# 檢查權限
if job.user_id != g.current_user_id and not g.is_admin:
return jsonify(create_response(
success=False,
error='PERMISSION_DENIED',
message='無權限存取此任務'
)), 403
# 取得任務詳細資料
job_data = job.to_dict(include_files=True)
# 計算處理時間
if job.processing_started_at and job.completed_at:
job_data['processing_time'] = calculate_processing_time(
job.processing_started_at, job.completed_at
)
elif job.processing_started_at:
job_data['processing_time'] = calculate_processing_time(
job.processing_started_at
)
# 取得佇列位置(只對 PENDING 狀態)
if job.status == 'PENDING':
job_data['queue_position'] = TranslationJob.get_queue_position(job.job_uuid)
# 取得 API 使用統計(如果已完成)
if job.status == 'COMPLETED':
api_stats = APIUsageStats.get_user_statistics(
user_id=job.user_id,
start_date=job.created_at,
end_date=job.completed_at
)
job_data['api_usage'] = api_stats
return jsonify(create_response(
success=True,
data={
'job': job_data
}
))
except ValidationError as e:
return jsonify(create_response(
success=False,
error=e.error_code,
message=str(e)
)), 400
except Exception as e:
logger.error(f"Get job detail error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='取得任務詳情失敗'
)), 500
@jobs_bp.route('/<job_uuid>/retry', methods=['POST'])
@jwt_login_required
def retry_job(job_uuid):
"""重試失敗任務"""
try:
# 驗證 UUID 格式
validate_job_uuid(job_uuid)
# 取得任務(排除軟刪除的記錄)
job = TranslationJob.query.filter_by(job_uuid=job_uuid).filter(TranslationJob.deleted_at.is_(None)).first()
if not job:
return jsonify(create_response(
success=False,
error='JOB_NOT_FOUND',
message='任務不存在'
)), 404
# 檢查權限
if job.user_id != g.current_user_id and not g.is_admin:
return jsonify(create_response(
success=False,
error='PERMISSION_DENIED',
message='無權限操作此任務'
)), 403
# 檢查是否可以重試
if not job.can_retry():
return jsonify(create_response(
success=False,
error='CANNOT_RETRY',
message='任務無法重試(狀態不正確或重試次數已達上限)'
)), 400
# 重置任務狀態
job.update_status('PENDING', error_message=None)
job.increment_retry()
# 計算新的佇列位置
queue_position = TranslationJob.get_queue_position(job.job_uuid)
# 記錄重試日誌
SystemLog.info(
'jobs.retry',
f'Job retry requested: {job_uuid}',
user_id=g.current_user_id,
job_id=job.id,
extra_data={
'retry_count': job.retry_count,
'previous_error': job.error_message
}
)
logger.info(f"Job retry requested: {job_uuid} (retry count: {job.retry_count})")
# 重新觸發翻譯任務(這裡會在實作 Celery 時加入)
# from app.tasks.translation import process_translation_job
# process_translation_job.delay(job.id)
return jsonify(create_response(
success=True,
data={
'job_uuid': job.job_uuid,
'status': job.status,
'retry_count': job.retry_count,
'queue_position': queue_position
},
message='任務已重新加入佇列'
))
except ValidationError as e:
return jsonify(create_response(
success=False,
error=e.error_code,
message=str(e)
)), 400
except Exception as e:
logger.error(f"Job retry error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='重試任務失敗'
)), 500
@jobs_bp.route('/statistics', methods=['GET'])
@jwt_login_required
def get_user_statistics():
"""取得使用者統計資料"""
try:
# 取得日期範圍參數
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
# 驗證日期範圍
if start_date or end_date:
start_date, end_date = validate_date_range(start_date, end_date)
# 取得任務統計
job_stats = TranslationJob.get_statistics(
user_id=g.current_user_id,
start_date=start_date,
end_date=end_date
)
# 取得 API 使用統計
api_stats = APIUsageStats.get_user_statistics(
user_id=g.current_user_id,
start_date=start_date,
end_date=end_date
)
return jsonify(create_response(
success=True,
data={
'job_statistics': job_stats,
'api_statistics': api_stats
}
))
except ValidationError as e:
return jsonify(create_response(
success=False,
error=e.error_code,
message=str(e)
)), 400
except Exception as e:
logger.error(f"Get user statistics error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='取得統計資料失敗'
)), 500
@jobs_bp.route('/queue/status', methods=['GET'])
def get_queue_status():
"""取得佇列狀態(不需登入)"""
try:
# 取得各狀態任務數量
pending_count = TranslationJob.query.filter_by(status='PENDING').count()
processing_count = TranslationJob.query.filter_by(status='PROCESSING').count()
# 取得當前處理中的任務最多5個
processing_jobs = TranslationJob.query.filter_by(
status='PROCESSING'
).order_by(TranslationJob.processing_started_at).limit(5).all()
processing_jobs_data = []
for job in processing_jobs:
processing_jobs_data.append({
'job_uuid': job.job_uuid,
'original_filename': job.original_filename,
'progress': float(job.progress) if job.progress else 0.0,
'processing_started_at': job.processing_started_at.isoformat() if job.processing_started_at else None,
'processing_time': calculate_processing_time(job.processing_started_at) if job.processing_started_at else None
})
return jsonify(create_response(
success=True,
data={
'queue_status': {
'pending': pending_count,
'processing': processing_count,
'total_in_queue': pending_count + processing_count
},
'processing_jobs': processing_jobs_data
}
))
except Exception as e:
logger.error(f"Get queue status error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='取得佇列狀態失敗'
)), 500
@jobs_bp.route('/<job_uuid>/cancel', methods=['POST'])
@jwt_login_required
def cancel_job(job_uuid):
"""取消任務(支援 PENDING 和 PROCESSING 狀態)"""
try:
# 驗證 UUID 格式
validate_job_uuid(job_uuid)
# 取得任務(排除軟刪除的記錄)
job = TranslationJob.query.filter_by(job_uuid=job_uuid).filter(TranslationJob.deleted_at.is_(None)).first()
if not job:
return jsonify(create_response(
success=False,
error='JOB_NOT_FOUND',
message='任務不存在'
)), 404
# 檢查權限
if job.user_id != g.current_user_id and not g.is_admin:
return jsonify(create_response(
success=False,
error='PERMISSION_DENIED',
message='無權限操作此任務'
)), 403
# 只能取消等待中或處理中的任務
if job.status not in ['PENDING', 'PROCESSING']:
return jsonify(create_response(
success=False,
error='CANNOT_CANCEL',
message='只能取消等待中或處理中的任務'
)), 400
# 如果是處理中的任務,需要中斷 Celery 任務
if job.status == 'PROCESSING':
try:
from app.services.celery_service import revoke_task
# 嘗試撤銷 Celery 任務
revoke_task(job.job_uuid)
logger.info(f"Celery task revoked for job: {job.job_uuid}")
except Exception as celery_error:
logger.warning(f"Failed to revoke Celery task for job {job.job_uuid}: {celery_error}")
# 即使撤銷失敗也繼續取消任務,因為用戶請求取消
# 更新任務狀態為失敗(取消)
cancel_message = f'使用者取消任務 (原狀態: {job.status})'
job.update_status('FAILED', error_message=cancel_message)
# 記錄取消日誌
SystemLog.info(
'jobs.cancel',
f'Job cancelled by user: {job_uuid}',
user_id=g.current_user_id,
job_id=job.id
)
logger.info(f"Job cancelled by user: {job_uuid}")
return jsonify(create_response(
success=True,
data={
'job_uuid': job.job_uuid,
'status': job.status
},
message='任務已取消'
))
except ValidationError as e:
return jsonify(create_response(
success=False,
error=e.error_code,
message=str(e)
)), 400
except Exception as e:
logger.error(f"Cancel job error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='取消任務失敗'
)), 500
@jobs_bp.route('/<job_uuid>', methods=['DELETE'])
@jwt_login_required
def delete_job(job_uuid):
"""刪除任務"""
try:
# 驗證 UUID 格式
validate_job_uuid(job_uuid)
# 取得任務
job = TranslationJob.query.filter_by(job_uuid=job_uuid).first()
if not job:
return jsonify(create_response(
success=False,
error='JOB_NOT_FOUND',
message='任務不存在'
)), 404
# 檢查權限
if job.user_id != g.current_user_id and not g.is_admin:
return jsonify(create_response(
success=False,
error='PERMISSION_DENIED',
message='無權限操作此任務'
)), 403
# 如果是處理中的任務,先嘗試中斷 Celery 任務
if job.status == 'PROCESSING':
try:
from app.services.celery_service import revoke_task
# 嘗試撤銷 Celery 任務
revoke_task(job.job_uuid)
logger.info(f"Celery task revoked before deletion for job: {job.job_uuid}")
except Exception as celery_error:
logger.warning(f"Failed to revoke Celery task before deletion for job {job.job_uuid}: {celery_error}")
# 即使撤銷失敗也繼續刪除任務,因為用戶要求刪除
# 刪除任務相關檔案
import os
import shutil
from pathlib import Path
try:
if job.file_path and os.path.exists(job.file_path):
# 取得任務目錄(通常是 uploads/job_uuid
job_dir = Path(job.file_path).parent
if job_dir.exists() and job_dir.name == job.job_uuid:
shutil.rmtree(job_dir)
logger.info(f"Deleted job directory: {job_dir}")
except Exception as file_error:
logger.warning(f"Failed to delete job files: {str(file_error)}")
# 記錄刪除日誌
SystemLog.info(
'jobs.delete',
f'Job deleted by user: {job_uuid}',
user_id=g.current_user_id,
job_id=job.id,
extra_data={
'filename': job.original_filename,
'status': job.status
}
)
from app import db
# 軟刪除資料庫記錄(保留數據供報表使用)
job.soft_delete()
logger.info(f"Job soft deleted by user: {job_uuid}")
return jsonify(create_response(
success=True,
message='任務已刪除'
))
except ValidationError as e:
return jsonify(create_response(
success=False,
error=e.error_code,
message=str(e)
)), 400
except Exception as e:
logger.error(f"Delete job error: {str(e)}")
return jsonify(create_response(
success=False,
error='SYSTEM_ERROR',
message='刪除任務失敗'
)), 500

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 app.utils.decorators import jwt_login_required
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_login_required
def get_notifications():
"""獲取當前用戶的通知列表"""
try:
# 獲取當前用戶
current_user_id = g.current_user_id
# 獲取查詢參數
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_login_required
def get_notification(notification_id):
"""獲取單個通知詳情"""
try:
current_user_id = g.current_user_id
# 查找通知
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_login_required
def mark_notification_read(notification_id):
"""標記通知為已讀"""
try:
current_user_id = g.current_user_id
# 查找通知
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_login_required
def mark_all_read():
"""標記所有通知為已讀"""
try:
current_user_id = g.current_user_id
# 取得所有未讀通知
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_login_required
def delete_notification(notification_id):
"""刪除通知"""
try:
current_user_id = g.current_user_id
# 查找通知
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_login_required
def clear_read_notifications():
"""清空所有已讀通知"""
try:
current_user_id = g.current_user_id
# 刪除所有已讀通知
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_login_required
def create_test_notification():
"""創建測試通知(開發用)"""
try:
current_user_id = g.current_user_id
# 創建測試通知
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