1st_fix_login_issue
This commit is contained in:
24
app/api/__init__.py
Normal file
24
app/api/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/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
|
||||
|
||||
# 註冊路由
|
||||
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)
|
494
app/api/admin.py
Normal file
494
app/api/admin.py
Normal file
@@ -0,0 +1,494 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
管理員 API
|
||||
|
||||
Author: PANJIT IT Team
|
||||
Created: 2024-01-28
|
||||
Modified: 2024-01-28
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.validators import validate_pagination, validate_date_range
|
||||
from app.utils.helpers import create_response
|
||||
from app.utils.exceptions import ValidationError
|
||||
from app.utils.logger import get_logger
|
||||
from app.models.user import User
|
||||
from app.models.job import TranslationJob
|
||||
from app.models.stats import APIUsageStats
|
||||
from app.models.log import SystemLog
|
||||
from app.models.cache import TranslationCache
|
||||
from sqlalchemy import func, desc
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@admin_bp.route('/stats', methods=['GET'])
|
||||
@admin_required
|
||||
def get_system_stats():
|
||||
"""取得系統統計資料"""
|
||||
try:
|
||||
# 取得時間範圍參數
|
||||
period = request.args.get('period', 'month') # day, week, month
|
||||
|
||||
# 計算時間範圍
|
||||
end_date = datetime.utcnow()
|
||||
if period == 'day':
|
||||
start_date = end_date - timedelta(days=1)
|
||||
elif period == 'week':
|
||||
start_date = end_date - timedelta(days=7)
|
||||
else: # month
|
||||
start_date = end_date - timedelta(days=30)
|
||||
|
||||
from app import db
|
||||
|
||||
# 系統概覽統計
|
||||
overview = {
|
||||
'total_jobs': TranslationJob.query.count(),
|
||||
'completed_jobs': TranslationJob.query.filter_by(status='COMPLETED').count(),
|
||||
'failed_jobs': TranslationJob.query.filter_by(status='FAILED').count(),
|
||||
'pending_jobs': TranslationJob.query.filter_by(status='PENDING').count(),
|
||||
'processing_jobs': TranslationJob.query.filter_by(status='PROCESSING').count(),
|
||||
'total_users': User.query.count(),
|
||||
'active_users_today': User.query.filter(
|
||||
User.last_login >= datetime.utcnow() - timedelta(days=1)
|
||||
).count(),
|
||||
'total_cost': db.session.query(func.sum(APIUsageStats.cost)).scalar() or 0.0
|
||||
}
|
||||
|
||||
# 每日統計
|
||||
daily_stats = db.session.query(
|
||||
func.date(TranslationJob.created_at).label('date'),
|
||||
func.count(TranslationJob.id).label('jobs'),
|
||||
func.sum(func.case(
|
||||
(TranslationJob.status == 'COMPLETED', 1),
|
||||
else_=0
|
||||
)).label('completed'),
|
||||
func.sum(func.case(
|
||||
(TranslationJob.status == 'FAILED', 1),
|
||||
else_=0
|
||||
)).label('failed')
|
||||
).filter(
|
||||
TranslationJob.created_at >= start_date
|
||||
).group_by(func.date(TranslationJob.created_at)).order_by(
|
||||
func.date(TranslationJob.created_at)
|
||||
).all()
|
||||
|
||||
# 每日成本統計
|
||||
daily_costs = db.session.query(
|
||||
func.date(APIUsageStats.created_at).label('date'),
|
||||
func.sum(APIUsageStats.cost).label('cost')
|
||||
).filter(
|
||||
APIUsageStats.created_at >= start_date
|
||||
).group_by(func.date(APIUsageStats.created_at)).order_by(
|
||||
func.date(APIUsageStats.created_at)
|
||||
).all()
|
||||
|
||||
# 組合每日統計資料
|
||||
daily_stats_dict = {stat.date: stat for stat in daily_stats}
|
||||
daily_costs_dict = {cost.date: cost for cost in daily_costs}
|
||||
|
||||
combined_daily_stats = []
|
||||
current_date = start_date.date()
|
||||
while current_date <= end_date.date():
|
||||
stat = daily_stats_dict.get(current_date)
|
||||
cost = daily_costs_dict.get(current_date)
|
||||
|
||||
combined_daily_stats.append({
|
||||
'date': current_date.isoformat(),
|
||||
'jobs': stat.jobs if stat else 0,
|
||||
'completed': stat.completed if stat else 0,
|
||||
'failed': stat.failed if stat else 0,
|
||||
'cost': float(cost.cost) if cost and cost.cost else 0.0
|
||||
})
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# 使用者排行榜
|
||||
user_rankings = db.session.query(
|
||||
User.id,
|
||||
User.display_name,
|
||||
func.count(TranslationJob.id).label('job_count'),
|
||||
func.sum(APIUsageStats.cost).label('total_cost')
|
||||
).outerjoin(TranslationJob).outerjoin(APIUsageStats).filter(
|
||||
TranslationJob.created_at >= start_date
|
||||
).group_by(User.id, User.display_name).order_by(
|
||||
func.sum(APIUsageStats.cost).desc().nullslast()
|
||||
).limit(10).all()
|
||||
|
||||
user_rankings_data = []
|
||||
for ranking in user_rankings:
|
||||
user_rankings_data.append({
|
||||
'user_id': ranking.id,
|
||||
'display_name': ranking.display_name,
|
||||
'job_count': ranking.job_count or 0,
|
||||
'total_cost': float(ranking.total_cost) if ranking.total_cost else 0.0
|
||||
})
|
||||
|
||||
return jsonify(create_response(
|
||||
success=True,
|
||||
data={
|
||||
'overview': overview,
|
||||
'daily_stats': combined_daily_stats,
|
||||
'user_rankings': user_rankings_data,
|
||||
'period': period,
|
||||
'start_date': start_date.isoformat(),
|
||||
'end_date': end_date.isoformat()
|
||||
}
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get system stats error: {str(e)}")
|
||||
|
||||
return jsonify(create_response(
|
||||
success=False,
|
||||
error='SYSTEM_ERROR',
|
||||
message='取得系統統計失敗'
|
||||
)), 500
|
||||
|
||||
|
||||
@admin_bp.route('/jobs', methods=['GET'])
|
||||
@admin_required
|
||||
def get_all_jobs():
|
||||
"""取得所有使用者任務"""
|
||||
try:
|
||||
# 取得查詢參數
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 50, type=int)
|
||||
user_id = request.args.get('user_id', type=int)
|
||||
status = request.args.get('status')
|
||||
|
||||
# 驗證分頁參數
|
||||
page, per_page = validate_pagination(page, min(per_page, 100))
|
||||
|
||||
# 建立查詢
|
||||
query = TranslationJob.query
|
||||
|
||||
# 使用者篩選
|
||||
if user_id:
|
||||
query = query.filter_by(user_id=user_id)
|
||||
|
||||
# 狀態篩選
|
||||
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()
|
||||
job_data['user'] = {
|
||||
'id': job.user.id,
|
||||
'username': job.user.username,
|
||||
'display_name': job.user.display_name,
|
||||
'email': job.user.email
|
||||
}
|
||||
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 all jobs error: {str(e)}")
|
||||
|
||||
return jsonify(create_response(
|
||||
success=False,
|
||||
error='SYSTEM_ERROR',
|
||||
message='取得任務列表失敗'
|
||||
)), 500
|
||||
|
||||
|
||||
@admin_bp.route('/users', methods=['GET'])
|
||||
@admin_required
|
||||
def get_all_users():
|
||||
"""取得所有使用者"""
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int)
|
||||
|
||||
# 驗證分頁參數
|
||||
page, per_page = validate_pagination(page, per_page)
|
||||
|
||||
# 分頁查詢
|
||||
pagination = User.query.order_by(
|
||||
User.last_login.desc().nullslast(),
|
||||
User.created_at.desc()
|
||||
).paginate(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
error_out=False
|
||||
)
|
||||
|
||||
users = pagination.items
|
||||
|
||||
# 組合使用者資料(包含統計)
|
||||
users_data = []
|
||||
for user in users:
|
||||
user_data = user.to_dict(include_stats=True)
|
||||
users_data.append(user_data)
|
||||
|
||||
return jsonify(create_response(
|
||||
success=True,
|
||||
data={
|
||||
'users': users_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 all users error: {str(e)}")
|
||||
|
||||
return jsonify(create_response(
|
||||
success=False,
|
||||
error='SYSTEM_ERROR',
|
||||
message='取得使用者列表失敗'
|
||||
)), 500
|
||||
|
||||
|
||||
@admin_bp.route('/logs', methods=['GET'])
|
||||
@admin_required
|
||||
def get_system_logs():
|
||||
"""取得系統日誌"""
|
||||
try:
|
||||
# 取得查詢參數
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 100, type=int)
|
||||
level = request.args.get('level')
|
||||
module = request.args.get('module')
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
|
||||
# 驗證參數
|
||||
page, per_page = validate_pagination(page, min(per_page, 500))
|
||||
|
||||
if start_date or end_date:
|
||||
start_date, end_date = validate_date_range(start_date, end_date)
|
||||
|
||||
# 取得日誌
|
||||
logs = SystemLog.get_logs(
|
||||
level=level,
|
||||
module=module,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=per_page,
|
||||
offset=(page - 1) * per_page
|
||||
)
|
||||
|
||||
# 取得總數(簡化版本,不完全精確)
|
||||
total = len(logs) if len(logs) < per_page else (page * per_page) + 1
|
||||
|
||||
logs_data = [log.to_dict() for log in logs]
|
||||
|
||||
return jsonify(create_response(
|
||||
success=True,
|
||||
data={
|
||||
'logs': logs_data,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total': total,
|
||||
'has_more': len(logs) == per_page
|
||||
}
|
||||
}
|
||||
))
|
||||
|
||||
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 system logs error: {str(e)}")
|
||||
|
||||
return jsonify(create_response(
|
||||
success=False,
|
||||
error='SYSTEM_ERROR',
|
||||
message='取得系統日誌失敗'
|
||||
)), 500
|
||||
|
||||
|
||||
@admin_bp.route('/api-usage', methods=['GET'])
|
||||
@admin_required
|
||||
def get_api_usage():
|
||||
"""取得 API 使用統計"""
|
||||
try:
|
||||
# 取得時間範圍
|
||||
days = request.args.get('days', 30, type=int)
|
||||
days = min(days, 90) # 最多90天
|
||||
|
||||
# 取得每日統計
|
||||
daily_stats = APIUsageStats.get_daily_statistics(days=days)
|
||||
|
||||
# 取得使用量排行
|
||||
top_users = APIUsageStats.get_top_users(limit=10)
|
||||
|
||||
# 取得端點統計
|
||||
endpoint_stats = APIUsageStats.get_endpoint_statistics()
|
||||
|
||||
# 取得成本趨勢
|
||||
cost_trend = APIUsageStats.get_cost_trend(days=days)
|
||||
|
||||
return jsonify(create_response(
|
||||
success=True,
|
||||
data={
|
||||
'daily_stats': daily_stats,
|
||||
'top_users': top_users,
|
||||
'endpoint_stats': endpoint_stats,
|
||||
'cost_trend': cost_trend,
|
||||
'period_days': days
|
||||
}
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get API usage error: {str(e)}")
|
||||
|
||||
return jsonify(create_response(
|
||||
success=False,
|
||||
error='SYSTEM_ERROR',
|
||||
message='取得API使用統計失敗'
|
||||
)), 500
|
||||
|
||||
|
||||
@admin_bp.route('/cache/stats', methods=['GET'])
|
||||
@admin_required
|
||||
def get_cache_stats():
|
||||
"""取得翻譯快取統計"""
|
||||
try:
|
||||
cache_stats = TranslationCache.get_cache_statistics()
|
||||
|
||||
return jsonify(create_response(
|
||||
success=True,
|
||||
data=cache_stats
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get cache stats error: {str(e)}")
|
||||
|
||||
return jsonify(create_response(
|
||||
success=False,
|
||||
error='SYSTEM_ERROR',
|
||||
message='取得快取統計失敗'
|
||||
)), 500
|
||||
|
||||
|
||||
@admin_bp.route('/maintenance/cleanup', methods=['POST'])
|
||||
@admin_required
|
||||
def cleanup_system():
|
||||
"""系統清理維護"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
# 清理選項
|
||||
cleanup_logs = data.get('cleanup_logs', False)
|
||||
cleanup_cache = data.get('cleanup_cache', False)
|
||||
cleanup_files = data.get('cleanup_files', False)
|
||||
|
||||
logs_days = data.get('logs_days', 30)
|
||||
cache_days = data.get('cache_days', 90)
|
||||
files_days = data.get('files_days', 7)
|
||||
|
||||
cleanup_results = {}
|
||||
|
||||
# 清理舊日誌
|
||||
if cleanup_logs:
|
||||
deleted_logs = SystemLog.cleanup_old_logs(days_to_keep=logs_days)
|
||||
cleanup_results['logs'] = {
|
||||
'deleted_count': deleted_logs,
|
||||
'days_kept': logs_days
|
||||
}
|
||||
|
||||
# 清理舊快取
|
||||
if cleanup_cache:
|
||||
deleted_cache = TranslationCache.clear_old_cache(days_to_keep=cache_days)
|
||||
cleanup_results['cache'] = {
|
||||
'deleted_count': deleted_cache,
|
||||
'days_kept': cache_days
|
||||
}
|
||||
|
||||
# 清理舊檔案(這裡會在檔案服務中實作)
|
||||
if cleanup_files:
|
||||
# from app.services.file_service import cleanup_old_files
|
||||
# deleted_files = cleanup_old_files(days_to_keep=files_days)
|
||||
cleanup_results['files'] = {
|
||||
'message': 'File cleanup not implemented yet',
|
||||
'days_kept': files_days
|
||||
}
|
||||
|
||||
# 記錄維護日誌
|
||||
SystemLog.info(
|
||||
'admin.maintenance',
|
||||
f'System cleanup performed by {g.current_user.username}',
|
||||
user_id=g.current_user.id,
|
||||
extra_data={
|
||||
'cleanup_options': data,
|
||||
'results': cleanup_results
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"System cleanup performed by {g.current_user.username}")
|
||||
|
||||
return jsonify(create_response(
|
||||
success=True,
|
||||
data=cleanup_results,
|
||||
message='系統清理完成'
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"System cleanup error: {str(e)}")
|
||||
|
||||
return jsonify(create_response(
|
||||
success=False,
|
||||
error='SYSTEM_ERROR',
|
||||
message='系統清理失敗'
|
||||
)), 500
|
325
app/api/auth.py
Normal file
325
app/api/auth.py
Normal file
@@ -0,0 +1,325 @@
|
||||
#!/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.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.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():
|
||||
"""使用者登入"""
|
||||
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
|
||||
|
||||
# LDAP 認證
|
||||
ldap_service = LDAPAuthService()
|
||||
user_info = ldap_service.authenticate_user(username, password)
|
||||
|
||||
# 取得或建立使用者
|
||||
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()
|
||||
|
||||
# 創建 JWT tokens
|
||||
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
|
||||
}
|
||||
)
|
||||
refresh_token = create_refresh_token(identity=user.username)
|
||||
|
||||
# 記錄登入日誌
|
||||
SystemLog.info(
|
||||
'auth.login',
|
||||
f'User {username} logged in successfully',
|
||||
user_id=user.id,
|
||||
extra_data={
|
||||
'ip_address': request.remote_addr,
|
||||
'user_agent': request.headers.get('User-Agent')
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"🔑 [JWT Created] User: {username}, UserID: {user.id}")
|
||||
logger.info(f"User {username} logged in successfully")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'user': user.to_dict()
|
||||
},
|
||||
'message': '登入成功'
|
||||
})
|
||||
|
||||
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
|
317
app/api/auth_old.py
Normal file
317
app/api/auth_old.py
Normal file
@@ -0,0 +1,317 @@
|
||||
#!/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, current_app
|
||||
from flask_jwt_extended import create_access_token, create_refresh_token, jwt_required, get_jwt_identity
|
||||
from app.utils.ldap_auth import LDAPAuthService
|
||||
from app.utils.decorators import login_required, 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.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():
|
||||
"""使用者登入"""
|
||||
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
|
||||
|
||||
# LDAP 認證
|
||||
ldap_service = LDAPAuthService()
|
||||
user_info = ldap_service.authenticate_user(username, password)
|
||||
|
||||
# 取得或建立使用者
|
||||
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()
|
||||
|
||||
# 創建 JWT tokens
|
||||
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
|
||||
}
|
||||
)
|
||||
refresh_token = create_refresh_token(identity=user.username)
|
||||
|
||||
# 記錄登入日誌
|
||||
SystemLog.info(
|
||||
'auth.login',
|
||||
f'User {username} logged in successfully',
|
||||
user_id=user.id,
|
||||
extra_data={
|
||||
'ip_address': request.remote_addr,
|
||||
'user_agent': request.headers.get('User-Agent')
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"🔑 [JWT Created] User: {username}, UserID: {user.id}")
|
||||
logger.info(f"User {username} logged in successfully")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'user': user.to_dict()
|
||||
},
|
||||
'message': '登入成功'
|
||||
})
|
||||
|
||||
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:
|
||||
from flask_jwt_extended import get_jwt
|
||||
|
||||
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():
|
||||
"""刷新 Session"""
|
||||
try:
|
||||
from flask import g
|
||||
user = g.current_user
|
||||
|
||||
# 更新 Session 資訊
|
||||
session['user_id'] = user.id
|
||||
session['username'] = user.username
|
||||
session['is_admin'] = user.is_admin
|
||||
session.permanent = True
|
||||
|
||||
logger.info(f"Session refreshed for user {user.username}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'user': user.to_dict(),
|
||||
'session_refreshed': True
|
||||
},
|
||||
'message': 'Session 已刷新'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Session refresh error: {str(e)}")
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'SYSTEM_ERROR',
|
||||
'message': '刷新 Session 時發生錯誤'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/check', methods=['GET'])
|
||||
def check_auth():
|
||||
"""檢查認證狀態"""
|
||||
try:
|
||||
user_id = session.get('user_id')
|
||||
|
||||
if not user_id:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'authenticated': False,
|
||||
'message': '未登入'
|
||||
}), 401
|
||||
|
||||
# 驗證使用者是否仍然存在
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
session.clear()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'authenticated': False,
|
||||
'message': '使用者不存在'
|
||||
}), 401
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'authenticated': True,
|
||||
'data': {
|
||||
'user': user.to_dict()
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Auth check error: {str(e)}")
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'SYSTEM_ERROR',
|
||||
'message': '檢查認證狀態時發生錯誤'
|
||||
}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/search-users', methods=['GET'])
|
||||
@login_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
|
443
app/api/files.py
Normal file
443
app/api/files.py
Normal file
@@ -0,0 +1,443 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
檔案管理 API
|
||||
|
||||
Author: PANJIT IT Team
|
||||
Created: 2024-01-28
|
||||
Modified: 2024-01-28
|
||||
"""
|
||||
|
||||
import json
|
||||
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__)
|
||||
|
||||
|
||||
@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']}")
|
||||
|
||||
# 觸發翻譯任務(這裡會在實作 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,
|
||||
'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.filename}',
|
||||
user_id=g.current_user_id,
|
||||
job_id=job.id,
|
||||
extra_data={
|
||||
'filename': translated_file.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.filename,
|
||||
mimetype='application/octet-stream'
|
||||
)
|
||||
|
||||
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.filename}',
|
||||
user_id=g.current_user_id,
|
||||
job_id=job.id,
|
||||
extra_data={
|
||||
'filename': original_file.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='application/octet-stream'
|
||||
)
|
||||
|
||||
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
|
222
app/api/health.py
Normal file
222
app/api/health.py
Normal file
@@ -0,0 +1,222 @@
|
||||
#!/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
|
||||
|
||||
health_bp = Blueprint('health', __name__, url_prefix='/health')
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@health_bp.route('', methods=['GET'])
|
||||
def health_check():
|
||||
"""系統健康檢查"""
|
||||
try:
|
||||
status = {
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'status': 'healthy',
|
||||
'services': {}
|
||||
}
|
||||
|
||||
# 資料庫檢查
|
||||
try:
|
||||
from app import db
|
||||
db.session.execute('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': datetime.utcnow().isoformat(),
|
||||
'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': datetime.utcnow().isoformat(),
|
||||
'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': datetime.utcnow().isoformat(),
|
||||
'message': 'pong'
|
||||
})
|
443
app/api/jobs.py
Normal file
443
app/api/jobs.py
Normal file
@@ -0,0 +1,443 @@
|
||||
#!/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)
|
||||
|
||||
# 狀態篩選
|
||||
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).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).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 狀態)"""
|
||||
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 != 'PENDING':
|
||||
return jsonify(create_response(
|
||||
success=False,
|
||||
error='CANNOT_CANCEL',
|
||||
message='只能取消等待中的任務'
|
||||
)), 400
|
||||
|
||||
# 更新任務狀態為失敗(取消)
|
||||
job.update_status('FAILED', error_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
|
Reference in New Issue
Block a user