Files
Document_Translator/app/api/jobs.py
beabigegg 6eabdb2f07 16th_fix
2025-09-04 18:34:05 +08:00

548 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
翻譯任務管理 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