1st_fix_login_issue

This commit is contained in:
beabigegg
2025-09-02 10:31:35 +08:00
commit a60d965317
103 changed files with 12402 additions and 0 deletions

212
app/__init__.py Normal file
View File

@@ -0,0 +1,212 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Flask 應用程式工廠
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
import os
import redis
from flask import Flask, request, make_response
from flask_sqlalchemy import SQLAlchemy
from flask_cors import CORS
from flask_jwt_extended import JWTManager
from celery import Celery
from app.config import config
from app.utils.logger import init_logging
# 初始化擴展
db = SQLAlchemy()
cors = CORS()
jwt = JWTManager()
def make_celery(app):
"""創建 Celery 實例"""
celery = Celery(
app.import_name,
backend=app.config['CELERY_RESULT_BACKEND'],
broker=app.config['CELERY_BROKER_URL']
)
celery.conf.update(app.config)
class ContextTask(celery.Task):
"""在 Flask 應用上下文中執行任務"""
def __call__(self, *args, **kwargs):
with app.app_context():
return self.run(*args, **kwargs)
celery.Task = ContextTask
return celery
def create_app(config_name=None):
"""應用程式工廠"""
app = Flask(__name__)
# 載入配置
config_name = config_name or os.getenv('FLASK_ENV', 'default')
app.config.from_object(config[config_name])
# 載入 Dify API 配置
config[config_name].load_dify_config()
# 初始化必要目錄
config[config_name].init_directories()
# 初始化擴展
db.init_app(app)
# 不使用 Flask-CORS 避免衝突使用手動CORS處理
# 初始化 JWT
jwt.init_app(app)
app.logger.info(f"🔑 [JWT Config] JWT_SECRET_KEY: {app.config.get('JWT_SECRET_KEY')[:10]}...{app.config.get('JWT_SECRET_KEY')[-10:] if app.config.get('JWT_SECRET_KEY') else 'None'}")
app.logger.info(f"🔑 [JWT Config] JWT_ACCESS_TOKEN_EXPIRES: {app.config.get('JWT_ACCESS_TOKEN_EXPIRES')}")
app.logger.info(f"🔑 [JWT Config] JWT_REFRESH_TOKEN_EXPIRES: {app.config.get('JWT_REFRESH_TOKEN_EXPIRES')}")
app.logger.info("🔑 [JWT] Using JWT authentication")
# 設定 Redis用於Celery
try:
redis_client = redis.from_url(app.config['REDIS_URL'])
app.redis_client = redis_client
except Exception as e:
app.logger.warning(f"Redis initialization failed: {str(e)}")
app.redis_client = None
# 初始化日誌
init_logging(app)
# 註冊 API 路由
from app.api import api_v1
app.register_blueprint(api_v1)
# 註冊錯誤處理器
register_error_handlers(app)
# 添加 CORS 響應headers
@app.after_request
def after_request(response):
origin = request.headers.get('Origin')
allowed_origins = ['http://localhost:3000', 'http://127.0.0.1:3000']
if origin and origin in allowed_origins:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-Requested-With'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS, PATCH'
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Access-Control-Max-Age'] = '86400'
return response
# 處理 OPTIONS 預檢請求
@app.before_request
def before_request():
if request.method == 'OPTIONS':
response = make_response()
origin = request.headers.get('Origin')
allowed_origins = ['http://localhost:3000', 'http://127.0.0.1:3000']
if origin and origin in allowed_origins:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-Requested-With'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS, PATCH'
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Access-Control-Max-Age'] = '86400'
return response
# 建立資料表
with app.app_context():
# 導入模型
from app.models import User, TranslationJob, JobFile, TranslationCache, APIUsageStats, SystemLog
db.create_all()
# 創建默認管理員用戶(如果不存在)
create_default_admin()
# 創建 Celery 實例
app.celery = make_celery(app)
app.logger.info("Flask application created successfully")
return app
def register_error_handlers(app):
"""註冊錯誤處理器"""
@app.errorhandler(404)
def not_found(error):
return {
'success': False,
'error': 'NOT_FOUND',
'message': '請求的資源不存在'
}, 404
@app.errorhandler(403)
def forbidden(error):
return {
'success': False,
'error': 'FORBIDDEN',
'message': '權限不足'
}, 403
@app.errorhandler(401)
def unauthorized(error):
return {
'success': False,
'error': 'UNAUTHORIZED',
'message': '需要認證'
}, 401
@app.errorhandler(500)
def internal_server_error(error):
return {
'success': False,
'error': 'INTERNAL_SERVER_ERROR',
'message': '系統內部錯誤'
}, 500
@app.errorhandler(413)
def request_entity_too_large(error):
return {
'success': False,
'error': 'FILE_TOO_LARGE',
'message': '檔案大小超過限制'
}, 413
def create_default_admin():
"""創建默認管理員用戶"""
try:
from app.models import User
admin_email = os.environ.get('ADMIN_EMAIL', 'ymirliu@panjit.com.tw')
# 檢查是否已存在管理員
admin_user = User.query.filter_by(email=admin_email).first()
if not admin_user:
# 創建管理員用戶(待 LDAP 登入時完善資訊)
admin_user = User(
username=admin_email.split('@')[0],
display_name='系統管理員',
email=admin_email,
department='IT',
is_admin=True
)
db.session.add(admin_user)
db.session.commit()
print(f"Created default admin user: {admin_email}")
except Exception as e:
print(f"Failed to create default admin: {str(e)}")
# 導入模型在需要時才進行,避免循環導入

24
app/api/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

157
app/config.py Normal file
View File

@@ -0,0 +1,157 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
應用程式配置模組
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
import os
import secrets
from pathlib import Path
from datetime import timedelta
from dotenv import load_dotenv
# 載入環境變數
load_dotenv()
class Config:
"""基礎配置類別"""
# 基本應用配置
SECRET_KEY = os.environ.get('SECRET_KEY') or secrets.token_hex(32)
APP_NAME = os.environ.get('APP_NAME', 'PANJIT Document Translator')
# 資料庫配置
DATABASE_URL = os.environ.get('DATABASE_URL')
if DATABASE_URL and DATABASE_URL.startswith("mysql://"):
DATABASE_URL = DATABASE_URL.replace("mysql://", "mysql+pymysql://", 1)
SQLALCHEMY_DATABASE_URI = DATABASE_URL
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {
'pool_pre_ping': True,
'pool_recycle': 3600,
'connect_args': {
'charset': os.environ.get('MYSQL_CHARSET', 'utf8mb4'),
'connect_timeout': 30,
'read_timeout': 30,
'write_timeout': 30,
}
}
# JWT 配置 - 改用 JWT 認證
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY') or SECRET_KEY
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=8)
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
JWT_ALGORITHM = 'HS256'
# Redis 配置
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
# Celery 配置
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0')
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TIMEZONE = 'Asia/Taipei'
CELERY_ENABLE_UTC = True
# LDAP 配置
LDAP_SERVER = os.environ.get('LDAP_SERVER')
LDAP_PORT = int(os.environ.get('LDAP_PORT', 389))
LDAP_USE_SSL = os.environ.get('LDAP_USE_SSL', 'false').lower() == 'true'
LDAP_BIND_USER_DN = os.environ.get('LDAP_BIND_USER_DN')
LDAP_BIND_USER_PASSWORD = os.environ.get('LDAP_BIND_USER_PASSWORD')
LDAP_SEARCH_BASE = os.environ.get('LDAP_SEARCH_BASE')
LDAP_USER_LOGIN_ATTR = os.environ.get('LDAP_USER_LOGIN_ATTR', 'userPrincipalName')
# SMTP 配置
SMTP_SERVER = os.environ.get('SMTP_SERVER')
SMTP_PORT = int(os.environ.get('SMTP_PORT', 587))
SMTP_USE_TLS = os.environ.get('SMTP_USE_TLS', 'false').lower() == 'true'
SMTP_USE_SSL = os.environ.get('SMTP_USE_SSL', 'false').lower() == 'true'
SMTP_AUTH_REQUIRED = os.environ.get('SMTP_AUTH_REQUIRED', 'false').lower() == 'true'
SMTP_SENDER_EMAIL = os.environ.get('SMTP_SENDER_EMAIL')
SMTP_SENDER_PASSWORD = os.environ.get('SMTP_SENDER_PASSWORD', '')
# 檔案上傳配置
UPLOAD_FOLDER = Path(os.environ.get('UPLOAD_FOLDER', 'uploads')).absolute()
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 26214400)) # 25MB
ALLOWED_EXTENSIONS = {'.docx', '.doc', '.pptx', '.xlsx', '.xls', '.pdf'}
FILE_RETENTION_DAYS = int(os.environ.get('FILE_RETENTION_DAYS', 7))
# Dify API 配置(從 api.txt 載入)
DIFY_API_BASE_URL = ''
DIFY_API_KEY = ''
# 日誌配置
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
LOG_FILE = Path(os.environ.get('LOG_FILE', 'logs/app.log')).absolute()
# 管理員配置
ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL', 'ymirliu@panjit.com.tw')
@classmethod
def load_dify_config(cls):
"""從 api.txt 載入 Dify API 配置"""
api_file = Path('api.txt')
if api_file.exists():
try:
with open(api_file, 'r', encoding='utf-8') as f:
for line in f:
if line.startswith('base_url:'):
cls.DIFY_API_BASE_URL = line.split(':', 1)[1].strip()
elif line.startswith('api:'):
cls.DIFY_API_KEY = line.split(':', 1)[1].strip()
except Exception:
pass
@classmethod
def init_directories(cls):
"""初始化必要目錄"""
directories = [
cls.UPLOAD_FOLDER,
cls.LOG_FILE.parent,
]
for directory in directories:
directory.mkdir(parents=True, exist_ok=True)
class DevelopmentConfig(Config):
"""開發環境配置"""
DEBUG = True
FLASK_ENV = 'development'
class ProductionConfig(Config):
"""生產環境配置"""
DEBUG = False
FLASK_ENV = 'production'
# 生產環境的額外配置
SQLALCHEMY_ENGINE_OPTIONS = {
**Config.SQLALCHEMY_ENGINE_OPTIONS,
'pool_size': 10,
'max_overflow': 20,
}
class TestingConfig(Config):
"""測試環境配置"""
TESTING = True
WTF_CSRF_ENABLED = False
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
# 配置映射
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig
}

24
app/models/__init__.py Normal file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
資料模型模組
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from .user import User
from .job import TranslationJob, JobFile
from .cache import TranslationCache
from .stats import APIUsageStats
from .log import SystemLog
__all__ = [
'User',
'TranslationJob',
'JobFile',
'TranslationCache',
'APIUsageStats',
'SystemLog'
]

138
app/models/cache.py Normal file
View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
翻譯快取資料模型
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
import hashlib
from sqlalchemy.sql import func
from app import db
class TranslationCache(db.Model):
"""翻譯快取表 (dt_translation_cache)"""
__tablename__ = 'dt_translation_cache'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
source_text_hash = db.Column(db.String(64), nullable=False, comment='來源文字hash')
source_language = db.Column(db.String(50), nullable=False, comment='來源語言')
target_language = db.Column(db.String(50), nullable=False, comment='目標語言')
source_text = db.Column(db.Text, nullable=False, comment='來源文字')
translated_text = db.Column(db.Text, nullable=False, comment='翻譯文字')
created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間')
# 唯一約束
__table_args__ = (
db.UniqueConstraint('source_text_hash', 'source_language', 'target_language', name='uk_cache'),
db.Index('idx_languages', 'source_language', 'target_language'),
)
def __repr__(self):
return f'<TranslationCache {self.source_text_hash[:8]}...>'
def to_dict(self):
"""轉換為字典格式"""
return {
'id': self.id,
'source_text_hash': self.source_text_hash,
'source_language': self.source_language,
'target_language': self.target_language,
'source_text': self.source_text,
'translated_text': self.translated_text,
'created_at': self.created_at.isoformat() if self.created_at else None
}
@staticmethod
def generate_hash(text):
"""生成文字的 SHA256 hash"""
return hashlib.sha256(text.encode('utf-8')).hexdigest()
@classmethod
def get_translation(cls, source_text, source_language, target_language):
"""取得快取的翻譯"""
text_hash = cls.generate_hash(source_text)
cache_entry = cls.query.filter_by(
source_text_hash=text_hash,
source_language=source_language,
target_language=target_language
).first()
return cache_entry.translated_text if cache_entry else None
@classmethod
def save_translation(cls, source_text, source_language, target_language, translated_text):
"""儲存翻譯到快取"""
text_hash = cls.generate_hash(source_text)
# 檢查是否已存在
existing = cls.query.filter_by(
source_text_hash=text_hash,
source_language=source_language,
target_language=target_language
).first()
if existing:
# 更新現有記錄
existing.translated_text = translated_text
else:
# 建立新記錄
cache_entry = cls(
source_text_hash=text_hash,
source_language=source_language,
target_language=target_language,
source_text=source_text,
translated_text=translated_text
)
db.session.add(cache_entry)
db.session.commit()
return True
@classmethod
def get_cache_statistics(cls):
"""取得快取統計資料"""
total_entries = cls.query.count()
# 按語言對統計
language_pairs = db.session.query(
cls.source_language,
cls.target_language,
func.count(cls.id).label('count')
).group_by(cls.source_language, cls.target_language).all()
# 最近一週的快取命中
from datetime import datetime, timedelta
week_ago = datetime.utcnow() - timedelta(days=7)
recent_entries = cls.query.filter(cls.created_at >= week_ago).count()
return {
'total_entries': total_entries,
'language_pairs': [
{
'source_language': pair.source_language,
'target_language': pair.target_language,
'count': pair.count
}
for pair in language_pairs
],
'recent_entries': recent_entries
}
@classmethod
def clear_old_cache(cls, days_to_keep=90):
"""清理舊快取記錄"""
from datetime import datetime, timedelta
cutoff_date = datetime.utcnow() - timedelta(days=days_to_keep)
deleted_count = cls.query.filter(
cls.created_at < cutoff_date
).delete(synchronize_session=False)
db.session.commit()
return deleted_count

268
app/models/job.py Normal file
View File

@@ -0,0 +1,268 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
翻譯任務資料模型
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
import json
import uuid
from datetime import datetime, timedelta
from sqlalchemy.sql import func
from sqlalchemy import event
from app import db
class TranslationJob(db.Model):
"""翻譯任務表 (dt_translation_jobs)"""
__tablename__ = 'dt_translation_jobs'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
job_uuid = db.Column(db.String(36), unique=True, nullable=False, index=True, comment='任務唯一識別碼')
user_id = db.Column(db.Integer, db.ForeignKey('dt_users.id'), nullable=False, comment='使用者ID')
original_filename = db.Column(db.String(500), nullable=False, comment='原始檔名')
file_extension = db.Column(db.String(10), nullable=False, comment='檔案副檔名')
file_size = db.Column(db.BigInteger, nullable=False, comment='檔案大小(bytes)')
file_path = db.Column(db.String(1000), nullable=False, comment='檔案路徑')
source_language = db.Column(db.String(50), default=None, comment='來源語言')
target_languages = db.Column(db.JSON, nullable=False, comment='目標語言陣列')
status = db.Column(
db.Enum('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'RETRY', name='job_status'),
default='PENDING',
comment='任務狀態'
)
progress = db.Column(db.Numeric(5, 2), default=0.00, comment='處理進度(%)')
retry_count = db.Column(db.Integer, default=0, comment='重試次數')
error_message = db.Column(db.Text, comment='錯誤訊息')
total_tokens = db.Column(db.Integer, default=0, comment='總token數')
total_cost = db.Column(db.Numeric(10, 4), default=0.0000, comment='總成本')
processing_started_at = db.Column(db.DateTime, comment='開始處理時間')
completed_at = db.Column(db.DateTime, comment='完成時間')
created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間')
updated_at = db.Column(
db.DateTime,
default=func.now(),
onupdate=func.now(),
comment='更新時間'
)
# 關聯關係
files = db.relationship('JobFile', backref='job', lazy='dynamic', cascade='all, delete-orphan')
api_usage_stats = db.relationship('APIUsageStats', backref='job', lazy='dynamic')
def __repr__(self):
return f'<TranslationJob {self.job_uuid}>'
def __init__(self, **kwargs):
"""初始化,自動生成 UUID"""
super().__init__(**kwargs)
if not self.job_uuid:
self.job_uuid = str(uuid.uuid4())
def to_dict(self, include_files=False):
"""轉換為字典格式"""
data = {
'id': self.id,
'job_uuid': self.job_uuid,
'user_id': self.user_id,
'original_filename': self.original_filename,
'file_extension': self.file_extension,
'file_size': self.file_size,
'file_path': self.file_path,
'source_language': self.source_language,
'target_languages': self.target_languages,
'status': self.status,
'progress': float(self.progress) if self.progress else 0.0,
'retry_count': self.retry_count,
'error_message': self.error_message,
'total_tokens': self.total_tokens,
'total_cost': float(self.total_cost) if self.total_cost else 0.0,
'processing_started_at': self.processing_started_at.isoformat() if self.processing_started_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
if include_files:
data['files'] = [f.to_dict() for f in self.files]
return data
def update_status(self, status, error_message=None, progress=None):
"""更新任務狀態"""
self.status = status
if error_message:
self.error_message = error_message
if progress is not None:
self.progress = progress
if status == 'PROCESSING' and not self.processing_started_at:
self.processing_started_at = datetime.utcnow()
elif status == 'COMPLETED':
self.completed_at = datetime.utcnow()
self.progress = 100.00
self.updated_at = datetime.utcnow()
db.session.commit()
def add_original_file(self, filename, file_path, file_size):
"""新增原始檔案記錄"""
original_file = JobFile(
job_id=self.id,
file_type='ORIGINAL',
filename=filename,
file_path=file_path,
file_size=file_size
)
db.session.add(original_file)
db.session.commit()
return original_file
def add_translated_file(self, language_code, filename, file_path, file_size):
"""新增翻譯檔案記錄"""
translated_file = JobFile(
job_id=self.id,
file_type='TRANSLATED',
language_code=language_code,
filename=filename,
file_path=file_path,
file_size=file_size
)
db.session.add(translated_file)
db.session.commit()
return translated_file
def get_translated_files(self):
"""取得翻譯檔案"""
return self.files.filter_by(file_type='TRANSLATED').all()
def get_original_file(self):
"""取得原始檔案"""
return self.files.filter_by(file_type='ORIGINAL').first()
def can_retry(self):
"""是否可以重試"""
return self.status in ['FAILED', 'RETRY'] and self.retry_count < 3
def increment_retry(self):
"""增加重試次數"""
self.retry_count += 1
self.updated_at = datetime.utcnow()
db.session.commit()
@classmethod
def get_queue_position(cls, job_uuid):
"""取得任務在佇列中的位置"""
job = cls.query.filter_by(job_uuid=job_uuid).first()
if not job:
return None
position = cls.query.filter(
cls.status == 'PENDING',
cls.created_at < job.created_at
).count()
return position + 1
@classmethod
def get_pending_jobs(cls):
"""取得所有等待處理的任務"""
return cls.query.filter_by(status='PENDING').order_by(cls.created_at.asc()).all()
@classmethod
def get_processing_jobs(cls):
"""取得所有處理中的任務"""
return cls.query.filter_by(status='PROCESSING').all()
@classmethod
def get_user_jobs(cls, user_id, status=None, limit=None, offset=None):
"""取得使用者的任務列表"""
query = cls.query.filter_by(user_id=user_id)
if status and status != 'all':
query = query.filter_by(status=status.upper())
query = query.order_by(cls.created_at.desc())
if limit:
query = query.limit(limit)
if offset:
query = query.offset(offset)
return query.all()
@classmethod
def get_statistics(cls, user_id=None, start_date=None, end_date=None):
"""取得統計資料"""
query = cls.query
if user_id:
query = query.filter_by(user_id=user_id)
if start_date:
query = query.filter(cls.created_at >= start_date)
if end_date:
query = query.filter(cls.created_at <= end_date)
total = query.count()
completed = query.filter_by(status='COMPLETED').count()
failed = query.filter_by(status='FAILED').count()
processing = query.filter_by(status='PROCESSING').count()
pending = query.filter_by(status='PENDING').count()
return {
'total': total,
'completed': completed,
'failed': failed,
'processing': processing,
'pending': pending,
'success_rate': (completed / total * 100) if total > 0 else 0
}
class JobFile(db.Model):
"""檔案記錄表 (dt_job_files)"""
__tablename__ = 'dt_job_files'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
job_id = db.Column(db.Integer, db.ForeignKey('dt_translation_jobs.id'), nullable=False, comment='任務ID')
file_type = db.Column(
db.Enum('ORIGINAL', 'TRANSLATED', name='file_type'),
nullable=False,
comment='檔案類型'
)
language_code = db.Column(db.String(50), comment='語言代碼(翻譯檔案)')
filename = db.Column(db.String(500), nullable=False, comment='檔案名稱')
file_path = db.Column(db.String(1000), nullable=False, comment='檔案路徑')
file_size = db.Column(db.BigInteger, nullable=False, comment='檔案大小')
created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間')
def __repr__(self):
return f'<JobFile {self.filename}>'
def to_dict(self):
"""轉換為字典格式"""
return {
'id': self.id,
'job_id': self.job_id,
'file_type': self.file_type,
'language_code': self.language_code,
'filename': self.filename,
'file_path': self.file_path,
'file_size': self.file_size,
'created_at': self.created_at.isoformat() if self.created_at else None
}
# 事件監聽器:自動生成 UUID
@event.listens_for(TranslationJob, 'before_insert')
def receive_before_insert(mapper, connection, target):
"""在插入前自動生成 UUID"""
if not target.job_uuid:
target.job_uuid = str(uuid.uuid4())

211
app/models/log.py Normal file
View File

@@ -0,0 +1,211 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
系統日誌資料模型
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
import json
from datetime import datetime, timedelta
from sqlalchemy.sql import func
from app import db
class SystemLog(db.Model):
"""系統日誌表 (dt_system_logs)"""
__tablename__ = 'dt_system_logs'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
level = db.Column(
db.Enum('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL', name='log_level'),
nullable=False,
comment='日誌等級'
)
module = db.Column(db.String(100), nullable=False, comment='模組名稱')
user_id = db.Column(db.Integer, db.ForeignKey('dt_users.id'), comment='使用者ID')
job_id = db.Column(db.Integer, db.ForeignKey('dt_translation_jobs.id'), comment='任務ID')
message = db.Column(db.Text, nullable=False, comment='日誌訊息')
extra_data = db.Column(db.JSON, comment='額外資料')
created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間')
def __repr__(self):
return f'<SystemLog {self.level} {self.module}>'
def to_dict(self):
"""轉換為字典格式"""
return {
'id': self.id,
'level': self.level,
'module': self.module,
'user_id': self.user_id,
'job_id': self.job_id,
'message': self.message,
'extra_data': self.extra_data,
'created_at': self.created_at.isoformat() if self.created_at else None
}
@classmethod
def log(cls, level, module, message, user_id=None, job_id=None, extra_data=None):
"""記錄日誌"""
log_entry = cls(
level=level.upper(),
module=module,
message=message,
user_id=user_id,
job_id=job_id,
extra_data=extra_data
)
db.session.add(log_entry)
db.session.commit()
return log_entry
@classmethod
def debug(cls, module, message, user_id=None, job_id=None, extra_data=None):
"""記錄除錯日誌"""
return cls.log('DEBUG', module, message, user_id, job_id, extra_data)
@classmethod
def info(cls, module, message, user_id=None, job_id=None, extra_data=None):
"""記錄資訊日誌"""
return cls.log('INFO', module, message, user_id, job_id, extra_data)
@classmethod
def warning(cls, module, message, user_id=None, job_id=None, extra_data=None):
"""記錄警告日誌"""
return cls.log('WARNING', module, message, user_id, job_id, extra_data)
@classmethod
def error(cls, module, message, user_id=None, job_id=None, extra_data=None):
"""記錄錯誤日誌"""
return cls.log('ERROR', module, message, user_id, job_id, extra_data)
@classmethod
def critical(cls, module, message, user_id=None, job_id=None, extra_data=None):
"""記錄嚴重錯誤日誌"""
return cls.log('CRITICAL', module, message, user_id, job_id, extra_data)
@classmethod
def get_logs(cls, level=None, module=None, user_id=None, start_date=None, end_date=None, limit=100, offset=0):
"""查詢日誌"""
query = cls.query
if level:
query = query.filter_by(level=level.upper())
if module:
query = query.filter(cls.module.like(f'%{module}%'))
if user_id:
query = query.filter_by(user_id=user_id)
if start_date:
query = query.filter(cls.created_at >= start_date)
if end_date:
query = query.filter(cls.created_at <= end_date)
# 按時間倒序排列
query = query.order_by(cls.created_at.desc())
if limit:
query = query.limit(limit)
if offset:
query = query.offset(offset)
return query.all()
@classmethod
def get_log_statistics(cls, days=7):
"""取得日誌統計資料"""
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days)
# 按等級統計
level_stats = db.session.query(
cls.level,
func.count(cls.id).label('count')
).filter(
cls.created_at >= start_date
).group_by(cls.level).all()
# 按模組統計
module_stats = db.session.query(
cls.module,
func.count(cls.id).label('count')
).filter(
cls.created_at >= start_date
).group_by(cls.module).order_by(
func.count(cls.id).desc()
).limit(10).all()
# 每日統計
daily_stats = db.session.query(
func.date(cls.created_at).label('date'),
cls.level,
func.count(cls.id).label('count')
).filter(
cls.created_at >= start_date
).group_by(
func.date(cls.created_at), cls.level
).order_by(
func.date(cls.created_at)
).all()
return {
'level_stats': [
{'level': stat.level, 'count': stat.count}
for stat in level_stats
],
'module_stats': [
{'module': stat.module, 'count': stat.count}
for stat in module_stats
],
'daily_stats': [
{
'date': stat.date.isoformat(),
'level': stat.level,
'count': stat.count
}
for stat in daily_stats
]
}
@classmethod
def cleanup_old_logs(cls, days_to_keep=30):
"""清理舊日誌"""
cutoff_date = datetime.utcnow() - timedelta(days=days_to_keep)
deleted_count = cls.query.filter(
cls.created_at < cutoff_date
).delete(synchronize_session=False)
db.session.commit()
return deleted_count
@classmethod
def get_error_summary(cls, days=1):
"""取得錯誤摘要"""
start_date = datetime.utcnow() - timedelta(days=days)
error_logs = cls.query.filter(
cls.level.in_(['ERROR', 'CRITICAL']),
cls.created_at >= start_date
).order_by(cls.created_at.desc()).limit(50).all()
# 按模組分組錯誤
error_by_module = {}
for log in error_logs:
module = log.module
if module not in error_by_module:
error_by_module[module] = []
error_by_module[module].append(log.to_dict())
return {
'total_errors': len(error_logs),
'error_by_module': error_by_module,
'recent_errors': [log.to_dict() for log in error_logs[:10]]
}

222
app/models/stats.py Normal file
View 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, timedelta
from sqlalchemy.sql import func
from app import db
class APIUsageStats(db.Model):
"""API使用統計表 (dt_api_usage_stats)"""
__tablename__ = 'dt_api_usage_stats'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
user_id = db.Column(db.Integer, db.ForeignKey('dt_users.id'), nullable=False, comment='使用者ID')
job_id = db.Column(db.Integer, db.ForeignKey('dt_translation_jobs.id'), comment='任務ID')
api_endpoint = db.Column(db.String(200), nullable=False, comment='API端點')
prompt_tokens = db.Column(db.Integer, default=0, comment='Prompt token數')
completion_tokens = db.Column(db.Integer, default=0, comment='Completion token數')
total_tokens = db.Column(db.Integer, default=0, comment='總token數')
prompt_unit_price = db.Column(db.Numeric(10, 8), default=0.00000000, comment='單價')
prompt_price_unit = db.Column(db.String(20), default='USD', comment='價格單位')
cost = db.Column(db.Numeric(10, 4), default=0.0000, comment='成本')
response_time_ms = db.Column(db.Integer, default=0, comment='回應時間(毫秒)')
success = db.Column(db.Boolean, default=True, comment='是否成功')
error_message = db.Column(db.Text, comment='錯誤訊息')
created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間')
def __repr__(self):
return f'<APIUsageStats {self.api_endpoint}>'
def to_dict(self):
"""轉換為字典格式"""
return {
'id': self.id,
'user_id': self.user_id,
'job_id': self.job_id,
'api_endpoint': self.api_endpoint,
'prompt_tokens': self.prompt_tokens,
'completion_tokens': self.completion_tokens,
'total_tokens': self.total_tokens,
'prompt_unit_price': float(self.prompt_unit_price) if self.prompt_unit_price else 0.0,
'prompt_price_unit': self.prompt_price_unit,
'cost': float(self.cost) if self.cost else 0.0,
'response_time_ms': self.response_time_ms,
'success': self.success,
'error_message': self.error_message,
'created_at': self.created_at.isoformat() if self.created_at else None
}
@classmethod
def record_api_call(cls, user_id, job_id, api_endpoint, metadata, response_time_ms, success=True, error_message=None):
"""記錄 API 呼叫統計"""
# 從 Dify API metadata 解析使用量資訊
prompt_tokens = metadata.get('usage', {}).get('prompt_tokens', 0)
completion_tokens = metadata.get('usage', {}).get('completion_tokens', 0)
total_tokens = metadata.get('usage', {}).get('total_tokens', prompt_tokens + completion_tokens)
# 計算成本
prompt_unit_price = metadata.get('usage', {}).get('prompt_unit_price', 0.0)
prompt_price_unit = metadata.get('usage', {}).get('prompt_price_unit', 'USD')
# 成本計算:通常是 prompt_tokens * prompt_unit_price
cost = prompt_tokens * float(prompt_unit_price) if prompt_unit_price else 0.0
stats = cls(
user_id=user_id,
job_id=job_id,
api_endpoint=api_endpoint,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
prompt_unit_price=prompt_unit_price,
prompt_price_unit=prompt_price_unit,
cost=cost,
response_time_ms=response_time_ms,
success=success,
error_message=error_message
)
db.session.add(stats)
db.session.commit()
return stats
@classmethod
def get_user_statistics(cls, user_id, start_date=None, end_date=None):
"""取得使用者統計資料"""
query = cls.query.filter_by(user_id=user_id)
if start_date:
query = query.filter(cls.created_at >= start_date)
if end_date:
query = query.filter(cls.created_at <= end_date)
# 統計資料
total_calls = query.count()
successful_calls = query.filter_by(success=True).count()
total_tokens = query.with_entities(func.sum(cls.total_tokens)).scalar() or 0
total_cost = query.with_entities(func.sum(cls.cost)).scalar() or 0.0
avg_response_time = query.with_entities(func.avg(cls.response_time_ms)).scalar() or 0
return {
'total_calls': total_calls,
'successful_calls': successful_calls,
'failed_calls': total_calls - successful_calls,
'success_rate': (successful_calls / total_calls * 100) if total_calls > 0 else 0,
'total_tokens': total_tokens,
'total_cost': float(total_cost),
'avg_response_time': float(avg_response_time) if avg_response_time else 0
}
@classmethod
def get_daily_statistics(cls, days=30):
"""取得每日統計資料"""
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days)
# 按日期分組統計
daily_stats = db.session.query(
func.date(cls.created_at).label('date'),
func.count(cls.id).label('total_calls'),
func.sum(cls.total_tokens).label('total_tokens'),
func.sum(cls.cost).label('total_cost'),
func.count().filter(cls.success == True).label('successful_calls')
).filter(
cls.created_at >= start_date,
cls.created_at <= end_date
).group_by(func.date(cls.created_at)).all()
return [
{
'date': stat.date.isoformat(),
'total_calls': stat.total_calls,
'successful_calls': stat.successful_calls,
'failed_calls': stat.total_calls - stat.successful_calls,
'total_tokens': stat.total_tokens or 0,
'total_cost': float(stat.total_cost or 0)
}
for stat in daily_stats
]
@classmethod
def get_top_users(cls, limit=10, start_date=None, end_date=None):
"""取得使用量排行榜"""
query = db.session.query(
cls.user_id,
func.count(cls.id).label('total_calls'),
func.sum(cls.total_tokens).label('total_tokens'),
func.sum(cls.cost).label('total_cost')
)
if start_date:
query = query.filter(cls.created_at >= start_date)
if end_date:
query = query.filter(cls.created_at <= end_date)
top_users = query.group_by(cls.user_id).order_by(
func.sum(cls.cost).desc()
).limit(limit).all()
return [
{
'user_id': user.user_id,
'total_calls': user.total_calls,
'total_tokens': user.total_tokens or 0,
'total_cost': float(user.total_cost or 0)
}
for user in top_users
]
@classmethod
def get_cost_trend(cls, days=30):
"""取得成本趨勢"""
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days)
# 按日期統計成本
cost_trend = db.session.query(
func.date(cls.created_at).label('date'),
func.sum(cls.cost).label('daily_cost')
).filter(
cls.created_at >= start_date,
cls.created_at <= end_date
).group_by(func.date(cls.created_at)).order_by(
func.date(cls.created_at)
).all()
return [
{
'date': trend.date.isoformat(),
'cost': float(trend.daily_cost or 0)
}
for trend in cost_trend
]
@classmethod
def get_endpoint_statistics(cls):
"""取得 API 端點統計"""
endpoint_stats = db.session.query(
cls.api_endpoint,
func.count(cls.id).label('total_calls'),
func.sum(cls.cost).label('total_cost'),
func.avg(cls.response_time_ms).label('avg_response_time')
).group_by(cls.api_endpoint).order_by(
func.count(cls.id).desc()
).all()
return [
{
'endpoint': stat.api_endpoint,
'total_calls': stat.total_calls,
'total_cost': float(stat.total_cost or 0),
'avg_response_time': float(stat.avg_response_time or 0)
}
for stat in endpoint_stats
]

113
app/models/user.py Normal file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
使用者資料模型
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from datetime import datetime, timedelta
from sqlalchemy.sql import func
from app import db
class User(db.Model):
"""使用者資訊表 (dt_users)"""
__tablename__ = 'dt_users'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
username = db.Column(db.String(100), unique=True, nullable=False, index=True, comment='AD帳號')
display_name = db.Column(db.String(200), nullable=False, comment='顯示名稱')
email = db.Column(db.String(255), nullable=False, index=True, comment='電子郵件')
department = db.Column(db.String(100), comment='部門')
is_admin = db.Column(db.Boolean, default=False, comment='是否為管理員')
last_login = db.Column(db.DateTime, comment='最後登入時間')
created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間')
updated_at = db.Column(
db.DateTime,
default=func.now(),
onupdate=func.now(),
comment='更新時間'
)
# 關聯關係
translation_jobs = db.relationship('TranslationJob', backref='user', lazy='dynamic', cascade='all, delete-orphan')
api_usage_stats = db.relationship('APIUsageStats', backref='user', lazy='dynamic', cascade='all, delete-orphan')
system_logs = db.relationship('SystemLog', backref='user', lazy='dynamic')
def __repr__(self):
return f'<User {self.username}>'
def to_dict(self, include_stats=False):
"""轉換為字典格式"""
data = {
'id': self.id,
'username': self.username,
'display_name': self.display_name,
'email': self.email,
'department': self.department,
'is_admin': self.is_admin,
'last_login': self.last_login.isoformat() if self.last_login else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
if include_stats:
data.update({
'total_jobs': self.translation_jobs.count(),
'completed_jobs': self.translation_jobs.filter_by(status='COMPLETED').count(),
'failed_jobs': self.translation_jobs.filter_by(status='FAILED').count(),
'total_cost': self.get_total_cost()
})
return data
def get_total_cost(self):
"""計算使用者總成本"""
return db.session.query(
func.sum(self.api_usage_stats.cost)
).scalar() or 0.0
def update_last_login(self):
"""更新最後登入時間"""
self.last_login = datetime.utcnow()
db.session.commit()
@classmethod
def get_or_create(cls, username, display_name, email, department=None):
"""取得或建立使用者"""
user = cls.query.filter_by(username=username).first()
if user:
# 更新使用者資訊
user.display_name = display_name
user.email = email
if department:
user.department = department
user.updated_at = datetime.utcnow()
else:
# 建立新使用者
user = cls(
username=username,
display_name=display_name,
email=email,
department=department,
is_admin=(email.lower() == 'ymirliu@panjit.com.tw') # 硬編碼管理員
)
db.session.add(user)
db.session.commit()
return user
@classmethod
def get_admin_users(cls):
"""取得所有管理員使用者"""
return cls.query.filter_by(is_admin=True).all()
@classmethod
def get_active_users(cls, days=30):
"""取得活躍使用者(指定天數內有登入)"""
cutoff_date = datetime.utcnow() - timedelta(days=days)
return cls.query.filter(cls.last_login >= cutoff_date).all()

19
app/services/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
業務服務模組
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from .dify_client import DifyClient
from .translation_service import TranslationService
from .notification_service import NotificationService
__all__ = [
'DifyClient',
'TranslationService',
'NotificationService'
]

273
app/services/dify_client.py Normal file
View File

@@ -0,0 +1,273 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Dify API 客戶端服務
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
import time
import requests
from typing import Dict, Any, Optional
from flask import current_app
from app.utils.logger import get_logger
from app.utils.exceptions import APIError
from app.models.stats import APIUsageStats
logger = get_logger(__name__)
class DifyClient:
"""Dify API 客戶端"""
def __init__(self):
self.base_url = current_app.config.get('DIFY_API_BASE_URL', '')
self.api_key = current_app.config.get('DIFY_API_KEY', '')
self.timeout = (10, 60) # (連接超時, 讀取超時)
self.max_retries = 3
self.retry_delay = 1.6 # 指數退避基數
if not self.base_url or not self.api_key:
logger.warning("Dify API configuration is incomplete")
def _make_request(self, method: str, endpoint: str, data: Dict[str, Any] = None,
user_id: int = None, job_id: int = None) -> Dict[str, Any]:
"""發送 HTTP 請求到 Dify API"""
if not self.base_url or not self.api_key:
raise APIError("Dify API 未配置完整")
url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json',
'User-Agent': 'PANJIT-Document-Translator/1.0'
}
# 重試邏輯
last_exception = None
start_time = time.time()
for attempt in range(self.max_retries):
try:
logger.debug(f"Making Dify API request: {method} {url} (attempt {attempt + 1})")
if method.upper() == 'GET':
response = requests.get(url, headers=headers, timeout=self.timeout, params=data)
else:
response = requests.post(url, headers=headers, timeout=self.timeout, json=data)
# 計算響應時間
response_time_ms = int((time.time() - start_time) * 1000)
# 檢查響應狀態
response.raise_for_status()
# 解析響應
result = response.json()
# 記錄 API 使用統計
if user_id:
self._record_api_usage(
user_id=user_id,
job_id=job_id,
endpoint=endpoint,
response_data=result,
response_time_ms=response_time_ms,
success=True
)
logger.debug(f"Dify API request successful: {response_time_ms}ms")
return result
except requests.exceptions.RequestException as e:
last_exception = e
response_time_ms = int((time.time() - start_time) * 1000)
# 記錄失敗的 API 調用
if user_id:
self._record_api_usage(
user_id=user_id,
job_id=job_id,
endpoint=endpoint,
response_data={},
response_time_ms=response_time_ms,
success=False,
error_message=str(e)
)
logger.warning(f"Dify API request failed (attempt {attempt + 1}): {str(e)}")
# 如果是最後一次嘗試,拋出異常
if attempt == self.max_retries - 1:
break
# 指數退避
delay = self.retry_delay ** attempt
logger.debug(f"Retrying in {delay} seconds...")
time.sleep(delay)
# 所有重試都失敗了
error_msg = f"Dify API request failed after {self.max_retries} attempts: {str(last_exception)}"
logger.error(error_msg)
raise APIError(error_msg)
def _record_api_usage(self, user_id: int, job_id: Optional[int], endpoint: str,
response_data: Dict, response_time_ms: int, success: bool,
error_message: str = None):
"""記錄 API 使用統計"""
try:
# 從響應中提取使用量資訊
metadata = response_data.get('metadata', {})
APIUsageStats.record_api_call(
user_id=user_id,
job_id=job_id,
api_endpoint=endpoint,
metadata=metadata,
response_time_ms=response_time_ms,
success=success,
error_message=error_message
)
except Exception as e:
logger.warning(f"Failed to record API usage: {str(e)}")
def translate_text(self, text: str, source_language: str, target_language: str,
user_id: int = None, job_id: int = None) -> Dict[str, Any]:
"""翻譯文字"""
if not text.strip():
raise APIError("翻譯文字不能為空")
# 構建請求資料
request_data = {
'inputs': {
'text': text.strip(),
'source_language': source_language,
'target_language': target_language
},
'response_mode': 'blocking',
'user': f"user_{user_id}" if user_id else "anonymous"
}
try:
response = self._make_request(
method='POST',
endpoint='/chat-messages',
data=request_data,
user_id=user_id,
job_id=job_id
)
# 從響應中提取翻譯結果
answer = response.get('answer', '')
if not answer:
raise APIError("Dify API 返回空的翻譯結果")
return {
'success': True,
'translated_text': answer,
'source_text': text,
'source_language': source_language,
'target_language': target_language,
'metadata': response.get('metadata', {})
}
except APIError:
raise
except Exception as e:
error_msg = f"翻譯請求處理錯誤: {str(e)}"
logger.error(error_msg)
raise APIError(error_msg)
def test_connection(self) -> bool:
"""測試 Dify API 連接"""
try:
# 發送簡單的測試請求
test_data = {
'inputs': {'text': 'test'},
'response_mode': 'blocking',
'user': 'health_check'
}
response = self._make_request(
method='POST',
endpoint='/chat-messages',
data=test_data
)
return response is not None
except Exception as e:
logger.error(f"Dify API connection test failed: {str(e)}")
return False
def get_app_info(self) -> Dict[str, Any]:
"""取得 Dify 應用資訊"""
try:
response = self._make_request(
method='GET',
endpoint='/parameters'
)
return {
'success': True,
'app_info': response
}
except Exception as e:
logger.error(f"Failed to get Dify app info: {str(e)}")
return {
'success': False,
'error': str(e)
}
@classmethod
def load_config_from_file(cls, file_path: str = 'api.txt'):
"""從檔案載入 Dify API 配置"""
try:
import os
from pathlib import Path
config_file = Path(file_path)
if not config_file.exists():
logger.warning(f"Dify config file not found: {file_path}")
return
with open(config_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line.startswith('base_url:'):
base_url = line.split(':', 1)[1].strip()
current_app.config['DIFY_API_BASE_URL'] = base_url
elif line.startswith('api:'):
api_key = line.split(':', 1)[1].strip()
current_app.config['DIFY_API_KEY'] = api_key
logger.info("Dify API config loaded from file")
except Exception as e:
logger.error(f"Failed to load Dify config from file: {str(e)}")
def init_dify_config(app):
"""初始化 Dify 配置"""
with app.app_context():
# 從 api.txt 載入配置
DifyClient.load_config_from_file()
# 檢查配置完整性
base_url = app.config.get('DIFY_API_BASE_URL')
api_key = app.config.get('DIFY_API_KEY')
if base_url and api_key:
logger.info("Dify API configuration loaded successfully")
else:
logger.warning("Dify API configuration is incomplete")
logger.warning(f"Base URL: {'' if base_url else ''}")
logger.warning(f"API Key: {'' if api_key else ''}")

View File

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

View File

@@ -0,0 +1,424 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
翻譯服務
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
import hashlib
import time
from pathlib import Path
from typing import List, Dict, Any, Optional
from app.utils.logger import get_logger
from app.utils.exceptions import TranslationError, FileProcessingError
from app.services.dify_client import DifyClient
from app.models.cache import TranslationCache
from app.models.job import TranslationJob
from app.utils.helpers import generate_filename, create_job_directory
logger = get_logger(__name__)
class DocumentParser:
"""文件解析器基類"""
def __init__(self, file_path: str):
self.file_path = Path(file_path)
if not self.file_path.exists():
raise FileProcessingError(f"檔案不存在: {file_path}")
def extract_text_segments(self) -> List[str]:
"""提取文字片段"""
raise NotImplementedError
def generate_translated_document(self, translations: Dict[str, List[str]],
target_language: str, output_dir: Path) -> str:
"""生成翻譯後的文件"""
raise NotImplementedError
class DocxParser(DocumentParser):
"""DOCX 文件解析器"""
def extract_text_segments(self) -> List[str]:
"""提取 DOCX 文件的文字片段"""
try:
import docx
from docx.table import _Cell
doc = docx.Document(str(self.file_path))
text_segments = []
# 提取段落文字
for paragraph in doc.paragraphs:
text = paragraph.text.strip()
if text and len(text) > 3: # 過濾太短的文字
text_segments.append(text)
# 提取表格文字
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
text = cell.text.strip()
if text and len(text) > 3:
text_segments.append(text)
logger.info(f"Extracted {len(text_segments)} text segments from DOCX")
return text_segments
except Exception as e:
logger.error(f"Failed to extract text from DOCX: {str(e)}")
raise FileProcessingError(f"DOCX 文件解析失敗: {str(e)}")
def generate_translated_document(self, translations: Dict[str, List[str]],
target_language: str, output_dir: Path) -> str:
"""生成翻譯後的 DOCX 文件"""
try:
import docx
from docx.shared import Pt
# 開啟原始文件
doc = docx.Document(str(self.file_path))
# 取得對應的翻譯
translated_texts = translations.get(target_language, [])
text_index = 0
# 處理段落
for paragraph in doc.paragraphs:
if paragraph.text.strip() and len(paragraph.text.strip()) > 3:
if text_index < len(translated_texts):
# 保留原文,添加翻譯
original_text = paragraph.text
translated_text = translated_texts[text_index]
# 清空段落
paragraph.clear()
# 添加原文
run = paragraph.add_run(original_text)
# 添加翻譯(新行,較小字體)
paragraph.add_run('\n')
trans_run = paragraph.add_run(translated_text)
trans_run.font.size = Pt(10)
trans_run.italic = True
text_index += 1
# 處理表格(簡化版本)
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
if cell.text.strip() and len(cell.text.strip()) > 3:
if text_index < len(translated_texts):
original_text = cell.text
translated_text = translated_texts[text_index]
# 清空儲存格
cell.text = f"{original_text}\n{translated_text}"
text_index += 1
# 生成輸出檔名
output_filename = generate_filename(
self.file_path.name,
'translated',
'translated',
target_language
)
output_path = output_dir / output_filename
# 儲存文件
doc.save(str(output_path))
logger.info(f"Generated translated DOCX: {output_path}")
return str(output_path)
except Exception as e:
logger.error(f"Failed to generate translated DOCX: {str(e)}")
raise FileProcessingError(f"生成翻譯 DOCX 失敗: {str(e)}")
class PdfParser(DocumentParser):
"""PDF 文件解析器(只讀)"""
def extract_text_segments(self) -> List[str]:
"""提取 PDF 文件的文字片段"""
try:
from PyPDF2 import PdfReader
reader = PdfReader(str(self.file_path))
text_segments = []
for page in reader.pages:
text = page.extract_text()
# 簡單的句子分割
sentences = text.split('.')
for sentence in sentences:
sentence = sentence.strip()
if sentence and len(sentence) > 10:
text_segments.append(sentence)
logger.info(f"Extracted {len(text_segments)} text segments from PDF")
return text_segments
except Exception as e:
logger.error(f"Failed to extract text from PDF: {str(e)}")
raise FileProcessingError(f"PDF 文件解析失敗: {str(e)}")
def generate_translated_document(self, translations: Dict[str, List[str]],
target_language: str, output_dir: Path) -> str:
"""生成翻譯文字檔PDF 不支援直接編輯)"""
try:
translated_texts = translations.get(target_language, [])
# 生成純文字檔案
output_filename = f"{self.file_path.stem}_{target_language}_translated.txt"
output_path = output_dir / output_filename
with open(output_path, 'w', encoding='utf-8') as f:
f.write(f"翻譯結果 - {target_language}\n")
f.write("=" * 50 + "\n\n")
for i, text in enumerate(translated_texts):
f.write(f"{i+1}. {text}\n\n")
logger.info(f"Generated translated text file: {output_path}")
return str(output_path)
except Exception as e:
logger.error(f"Failed to generate translated text file: {str(e)}")
raise FileProcessingError(f"生成翻譯文字檔失敗: {str(e)}")
class TranslationService:
"""翻譯服務"""
def __init__(self):
self.dify_client = DifyClient()
# 文件解析器映射
self.parsers = {
'.docx': DocxParser,
'.doc': DocxParser, # 假設可以用 docx 處理
'.pdf': PdfParser,
# 其他格式可以稍後添加
}
def get_document_parser(self, file_path: str) -> DocumentParser:
"""取得文件解析器"""
file_ext = Path(file_path).suffix.lower()
parser_class = self.parsers.get(file_ext)
if not parser_class:
raise FileProcessingError(f"不支援的檔案格式: {file_ext}")
return parser_class(file_path)
def split_text_into_sentences(self, text: str, language: str = 'auto') -> List[str]:
"""將文字分割成句子"""
# 這裡可以使用更智能的句子分割
# 暫時使用簡單的分割方式
sentences = []
# 基本的句子分割符號
separators = ['. ', '', '', '', '!', '?']
current_text = text
for sep in separators:
parts = current_text.split(sep)
if len(parts) > 1:
sentences.extend([part.strip() + sep.rstrip() for part in parts[:-1] if part.strip()])
current_text = parts[-1]
# 添加最後一部分
if current_text.strip():
sentences.append(current_text.strip())
# 過濾太短的句子
sentences = [s for s in sentences if len(s.strip()) > 5]
return sentences
def translate_text_with_cache(self, text: str, source_language: str,
target_language: str, user_id: int = None,
job_id: int = None) -> str:
"""帶快取的文字翻譯"""
# 檢查快取
cached_translation = TranslationCache.get_translation(
text, source_language, target_language
)
if cached_translation:
logger.debug(f"Cache hit for translation: {text[:50]}...")
return cached_translation
# 呼叫 Dify API
try:
result = self.dify_client.translate_text(
text=text,
source_language=source_language,
target_language=target_language,
user_id=user_id,
job_id=job_id
)
translated_text = result['translated_text']
# 儲存到快取
TranslationCache.save_translation(
text, source_language, target_language, translated_text
)
return translated_text
except Exception as e:
logger.error(f"Translation failed for text: {text[:50]}... Error: {str(e)}")
raise TranslationError(f"翻譯失敗: {str(e)}")
def translate_document(self, job_uuid: str) -> Dict[str, Any]:
"""翻譯文件(主要入口點)"""
try:
# 取得任務資訊
job = TranslationJob.query.filter_by(job_uuid=job_uuid).first()
if not job:
raise TranslationError(f"找不到任務: {job_uuid}")
logger.info(f"Starting document translation: {job_uuid}")
# 更新任務狀態
job.update_status('PROCESSING', progress=0)
# 取得文件解析器
parser = self.get_document_parser(job.file_path)
# 提取文字片段
logger.info("Extracting text segments from document")
text_segments = parser.extract_text_segments()
if not text_segments:
raise TranslationError("文件中未找到可翻譯的文字")
# 分割成句子
logger.info("Splitting text into sentences")
all_sentences = []
for segment in text_segments:
sentences = self.split_text_into_sentences(segment, job.source_language)
all_sentences.extend(sentences)
# 去重複
unique_sentences = list(dict.fromkeys(all_sentences)) # 保持順序的去重
logger.info(f"Found {len(unique_sentences)} unique sentences to translate")
# 批次翻譯
translation_results = {}
total_sentences = len(unique_sentences)
for target_language in job.target_languages:
logger.info(f"Translating to {target_language}")
translated_sentences = []
for i, sentence in enumerate(unique_sentences):
try:
translated = self.translate_text_with_cache(
text=sentence,
source_language=job.source_language,
target_language=target_language,
user_id=job.user_id,
job_id=job.id
)
translated_sentences.append(translated)
# 更新進度
progress = (i + 1) / total_sentences * 100 / len(job.target_languages)
current_lang_index = job.target_languages.index(target_language)
total_progress = (current_lang_index * 100 + progress) / len(job.target_languages)
job.update_status('PROCESSING', progress=total_progress)
# 短暫延遲避免過快請求
time.sleep(0.1)
except Exception as e:
logger.error(f"Failed to translate sentence: {sentence[:50]}... Error: {str(e)}")
# 翻譯失敗時保留原文
translated_sentences.append(f"[翻譯失敗] {sentence}")
translation_results[target_language] = translated_sentences
# 生成翻譯文件
logger.info("Generating translated documents")
output_dir = Path(job.file_path).parent
output_files = {}
for target_language, translations in translation_results.items():
try:
# 重建翻譯映射
translation_mapping = {target_language: translations}
output_file = parser.generate_translated_document(
translations=translation_mapping,
target_language=target_language,
output_dir=output_dir
)
output_files[target_language] = output_file
# 記錄翻譯檔案到資料庫
file_size = Path(output_file).stat().st_size
job.add_translated_file(
language_code=target_language,
filename=Path(output_file).name,
file_path=output_file,
file_size=file_size
)
except Exception as e:
logger.error(f"Failed to generate translated document for {target_language}: {str(e)}")
raise TranslationError(f"生成 {target_language} 翻譯文件失敗: {str(e)}")
# 計算總成本(從 API 使用統計中取得)
total_cost = self._calculate_job_cost(job.id)
# 更新任務狀態為完成
job.update_status('COMPLETED', progress=100)
job.total_cost = total_cost
job.total_tokens = len(unique_sentences) # 簡化的 token 計算
from app import db
db.session.commit()
logger.info(f"Document translation completed: {job_uuid}")
return {
'success': True,
'job_uuid': job_uuid,
'output_files': output_files,
'total_sentences': len(unique_sentences),
'total_cost': float(total_cost),
'target_languages': job.target_languages
}
except TranslationError:
raise
except Exception as e:
logger.error(f"Document translation failed: {job_uuid}. Error: {str(e)}")
raise TranslationError(f"文件翻譯失敗: {str(e)}")
def _calculate_job_cost(self, job_id: int) -> float:
"""計算任務總成本"""
from app import db
from sqlalchemy import func
total_cost = db.session.query(
func.sum(APIUsageStats.cost)
).filter_by(job_id=job_id).scalar()
return float(total_cost) if total_cost else 0.0

16
app/tasks/__init__.py Normal file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Celery 任務模組
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from .translation import process_translation_job, cleanup_old_files
__all__ = [
'process_translation_job',
'cleanup_old_files'
]

323
app/tasks/translation.py Normal file
View File

@@ -0,0 +1,323 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
翻譯相關 Celery 任務
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
import os
import shutil
from datetime import datetime, timedelta
from pathlib import Path
from celery import current_task
from app import create_app, db, celery
from app.models.job import TranslationJob
from app.models.log import SystemLog
from app.services.translation_service import TranslationService
from app.services.notification_service import NotificationService
from app.utils.logger import get_logger
from app.utils.exceptions import TranslationError
logger = get_logger(__name__)
@celery.task(bind=True, max_retries=3)
def process_translation_job(self, job_id: int):
"""處理翻譯任務"""
app = create_app()
with app.app_context():
try:
# 取得任務資訊
job = TranslationJob.query.get(job_id)
if not job:
raise ValueError(f"Job {job_id} not found")
logger.info(f"Starting translation job processing: {job.job_uuid}")
# 記錄任務開始
SystemLog.info(
'tasks.translation',
f'Translation job started: {job.job_uuid}',
user_id=job.user_id,
job_id=job.id,
extra_data={
'filename': job.original_filename,
'target_languages': job.target_languages,
'retry_count': self.request.retries
}
)
# 建立翻譯服務
translation_service = TranslationService()
# 執行翻譯
result = translation_service.translate_document(job.job_uuid)
if result['success']:
logger.info(f"Translation job completed successfully: {job.job_uuid}")
# 發送完成通知
try:
notification_service = NotificationService()
notification_service.send_job_completion_notification(job)
except Exception as e:
logger.warning(f"Failed to send completion notification: {str(e)}")
# 記錄完成日誌
SystemLog.info(
'tasks.translation',
f'Translation job completed: {job.job_uuid}',
user_id=job.user_id,
job_id=job.id,
extra_data={
'total_cost': result.get('total_cost', 0),
'total_sentences': result.get('total_sentences', 0),
'output_files': list(result.get('output_files', {}).keys())
}
)
else:
raise TranslationError(result.get('error', 'Unknown translation error'))
except Exception as exc:
logger.error(f"Translation job failed: {job.job_uuid}. Error: {str(exc)}")
with app.app_context():
# 更新任務狀態
job = TranslationJob.query.get(job_id)
if job:
job.error_message = str(exc)
job.retry_count = self.request.retries + 1
if self.request.retries < self.max_retries:
# 準備重試
job.update_status('RETRY')
# 計算重試延遲30s, 60s, 120s
countdown = [30, 60, 120][self.request.retries]
SystemLog.warning(
'tasks.translation',
f'Translation job retry scheduled: {job.job_uuid} (attempt {self.request.retries + 2})',
user_id=job.user_id,
job_id=job.id,
extra_data={
'error': str(exc),
'retry_count': self.request.retries + 1,
'countdown': countdown
}
)
logger.info(f"Retrying translation job in {countdown}s: {job.job_uuid}")
raise self.retry(exc=exc, countdown=countdown)
else:
# 重試次數用盡,標記失敗
job.update_status('FAILED')
SystemLog.error(
'tasks.translation',
f'Translation job failed permanently: {job.job_uuid}',
user_id=job.user_id,
job_id=job.id,
extra_data={
'error': str(exc),
'total_retries': self.request.retries
}
)
# 發送失敗通知
try:
notification_service = NotificationService()
notification_service.send_job_failure_notification(job)
except Exception as e:
logger.warning(f"Failed to send failure notification: {str(e)}")
logger.error(f"Translation job failed permanently: {job.job_uuid}")
raise exc
@celery.task
def cleanup_old_files():
"""清理舊檔案(定期任務)"""
app = create_app()
with app.app_context():
try:
logger.info("Starting file cleanup task")
upload_folder = Path(app.config.get('UPLOAD_FOLDER'))
retention_days = app.config.get('FILE_RETENTION_DAYS', 7)
cutoff_date = datetime.utcnow() - timedelta(days=retention_days)
if not upload_folder.exists():
logger.warning(f"Upload folder does not exist: {upload_folder}")
return
deleted_files = 0
deleted_dirs = 0
total_size_freed = 0
# 遍歷上傳目錄中的所有 UUID 目錄
for item in upload_folder.iterdir():
if not item.is_dir():
continue
try:
# 檢查目錄的修改時間
dir_mtime = datetime.fromtimestamp(item.stat().st_mtime)
if dir_mtime < cutoff_date:
# 計算目錄大小
dir_size = sum(f.stat().st_size for f in item.rglob('*') if f.is_file())
# 檢查是否還有相關的資料庫記錄
job_uuid = item.name
job = TranslationJob.query.filter_by(job_uuid=job_uuid).first()
if job:
# 檢查任務是否已完成且超過保留期
if job.completed_at and job.completed_at < cutoff_date:
# 刪除目錄
shutil.rmtree(item)
deleted_dirs += 1
total_size_freed += dir_size
logger.info(f"Cleaned up job directory: {job_uuid}")
# 記錄清理日誌
SystemLog.info(
'tasks.cleanup',
f'Cleaned up files for completed job: {job_uuid}',
user_id=job.user_id,
job_id=job.id,
extra_data={
'files_size_mb': dir_size / (1024 * 1024),
'retention_days': retention_days
}
)
else:
# 沒有對應的資料庫記錄,直接刪除
shutil.rmtree(item)
deleted_dirs += 1
total_size_freed += dir_size
logger.info(f"Cleaned up orphaned directory: {job_uuid}")
except Exception as e:
logger.error(f"Failed to process directory {item}: {str(e)}")
continue
# 記錄清理結果
cleanup_result = {
'deleted_directories': deleted_dirs,
'total_size_freed_mb': total_size_freed / (1024 * 1024),
'retention_days': retention_days,
'cutoff_date': cutoff_date.isoformat()
}
SystemLog.info(
'tasks.cleanup',
f'File cleanup completed: {deleted_dirs} directories, {total_size_freed / (1024 * 1024):.2f} MB freed',
extra_data=cleanup_result
)
logger.info(f"File cleanup completed: {cleanup_result}")
return cleanup_result
except Exception as e:
logger.error(f"File cleanup task failed: {str(e)}")
SystemLog.error(
'tasks.cleanup',
f'File cleanup task failed: {str(e)}',
extra_data={'error': str(e)}
)
raise e
@celery.task
def send_daily_admin_report():
"""發送每日管理員報告"""
app = create_app()
with app.app_context():
try:
logger.info("Generating daily admin report")
from app.models.stats import APIUsageStats
from app.services.notification_service import NotificationService
# 取得昨日統計
yesterday = datetime.utcnow() - timedelta(days=1)
daily_stats = APIUsageStats.get_daily_statistics(days=1)
# 取得系統錯誤摘要
error_summary = SystemLog.get_error_summary(days=1)
# 準備報告內容
if daily_stats:
yesterday_data = daily_stats[0]
subject = f"每日系統報告 - {yesterday_data['date']}"
message = f"""
昨日系統使用狀況:
• 翻譯任務: {yesterday_data['total_calls']}
• 成功任務: {yesterday_data['successful_calls']}
• 失敗任務: {yesterday_data['failed_calls']}
• 總成本: ${yesterday_data['total_cost']:.4f}
• 總 Token 數: {yesterday_data['total_tokens']}
系統錯誤摘要:
• 錯誤數量: {error_summary['total_errors']}
請查看管理後台了解詳細資訊。
"""
else:
subject = f"每日系統報告 - {yesterday.strftime('%Y-%m-%d')}"
message = "昨日無翻譯任務記錄。"
# 發送管理員通知
notification_service = NotificationService()
result = notification_service.send_admin_notification(subject, message)
if result:
logger.info("Daily admin report sent successfully")
else:
logger.warning("Failed to send daily admin report")
return result
except Exception as e:
logger.error(f"Daily admin report task failed: {str(e)}")
raise e
# 定期任務設定
@celery.on_after_configure.connect
def setup_periodic_tasks(sender, **kwargs):
"""設定定期任務"""
# 每日凌晨 2 點執行檔案清理
sender.add_periodic_task(
crontab(hour=2, minute=0),
cleanup_old_files.s(),
name='cleanup-old-files-daily'
)
# 每日早上 8 點發送管理員報告
sender.add_periodic_task(
crontab(hour=8, minute=0),
send_daily_admin_report.s(),
name='daily-admin-report'
)
# 導入 crontab
from celery.schedules import crontab

34
app/utils/__init__.py Normal file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
工具模組
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from .decorators import login_required, admin_required
from .validators import validate_file, validate_languages
from .helpers import generate_filename, format_file_size
from .exceptions import (
DocumentTranslatorError,
AuthenticationError,
ValidationError,
TranslationError,
FileProcessingError
)
__all__ = [
'login_required',
'admin_required',
'validate_file',
'validate_languages',
'generate_filename',
'format_file_size',
'DocumentTranslatorError',
'AuthenticationError',
'ValidationError',
'TranslationError',
'FileProcessingError'
]

216
app/utils/decorators.py Normal file
View File

@@ -0,0 +1,216 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
裝飾器模組
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
from functools import wraps
from flask import session, jsonify, g, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
def login_required(f):
"""登入驗證裝飾器"""
@wraps(f)
def decorated_function(*args, **kwargs):
from app.utils.logger import get_logger
from flask import request
logger = get_logger(__name__)
user_id = session.get('user_id')
# 調試:記錄 session 檢查
logger.info(f"🔐 [Session Check] Endpoint: {request.endpoint}, Method: {request.method}, URL: {request.url}")
logger.info(f"🔐 [Session Data] UserID: {user_id}, SessionData: {dict(session)}, SessionID: {session.get('_id', 'unknown')}")
if not user_id:
logger.warning(f"❌ [Auth Failed] No user_id in session for {request.endpoint}")
return jsonify({
'success': False,
'error': 'AUTHENTICATION_REQUIRED',
'message': '請先登入'
}), 401
# 取得使用者資訊並設定到 g 物件
from app.models import User
user = User.query.get(user_id)
if not user:
# 清除無效的 session
session.clear()
return jsonify({
'success': False,
'error': 'USER_NOT_FOUND',
'message': '使用者不存在'
}), 401
g.current_user = user
g.current_user_id = user.id
g.is_admin = user.is_admin
return f(*args, **kwargs)
return decorated_function
def jwt_login_required(f):
"""JWT 登入驗證裝飾器"""
@wraps(f)
@jwt_required()
def decorated_function(*args, **kwargs):
from app.utils.logger import get_logger
from flask import request
logger = get_logger(__name__)
try:
username = get_jwt_identity()
claims = get_jwt()
# 設定到 g 物件供其他地方使用
g.current_user_username = username
g.current_user_id = claims.get('user_id')
g.is_admin = claims.get('is_admin', False)
logger.info(f"🔑 [JWT Auth] User: {username}, UserID: {claims.get('user_id')}, Admin: {claims.get('is_admin')}")
except Exception as e:
logger.error(f"❌ [JWT Auth] JWT validation failed: {str(e)}")
return jsonify({
'success': False,
'error': 'AUTHENTICATION_REQUIRED',
'message': '認證失效,請重新登入'
}), 401
return f(*args, **kwargs)
return decorated_function
def admin_required(f):
"""管理員權限裝飾器"""
@wraps(f)
def decorated_function(*args, **kwargs):
# 先檢查是否已登入
user_id = session.get('user_id')
if not user_id:
return jsonify({
'success': False,
'error': 'AUTHENTICATION_REQUIRED',
'message': '請先登入'
}), 401
# 取得使用者資訊
from app.models import User
user = User.query.get(user_id)
if not user:
session.clear()
return jsonify({
'success': False,
'error': 'USER_NOT_FOUND',
'message': '使用者不存在'
}), 401
# 檢查管理員權限
if not user.is_admin:
return jsonify({
'success': False,
'error': 'PERMISSION_DENIED',
'message': '權限不足,需要管理員權限'
}), 403
g.current_user = user
g.current_user_id = user.id
g.is_admin = True
return f(*args, **kwargs)
return decorated_function
def validate_json(required_fields=None):
"""JSON 驗證裝飾器"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
from flask import request
if not request.is_json:
return jsonify({
'success': False,
'error': 'INVALID_CONTENT_TYPE',
'message': '請求必須為 JSON 格式'
}), 400
data = request.get_json()
if not data:
return jsonify({
'success': False,
'error': 'INVALID_JSON',
'message': 'JSON 資料格式錯誤'
}), 400
# 檢查必要欄位
if required_fields:
missing_fields = [field for field in required_fields if field not in data]
if missing_fields:
return jsonify({
'success': False,
'error': 'MISSING_FIELDS',
'message': f'缺少必要欄位: {", ".join(missing_fields)}'
}), 400
return f(*args, **kwargs)
return decorated_function
return decorator
def rate_limit(max_requests=100, per_seconds=3600):
"""簡單的速率限制裝飾器"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
from flask import request
import redis
import time
try:
# 使用 Redis 進行速率限制
redis_client = redis.from_url(current_app.config['REDIS_URL'])
# 使用 IP 地址作為 key
client_id = request.remote_addr
key = f"rate_limit:{f.__name__}:{client_id}"
current_time = int(time.time())
window_start = current_time - per_seconds
# 清理過期的請求記錄
redis_client.zremrangebyscore(key, 0, window_start)
# 取得當前窗口內的請求數
current_requests = redis_client.zcard(key)
if current_requests >= max_requests:
return jsonify({
'success': False,
'error': 'RATE_LIMIT_EXCEEDED',
'message': '請求過於頻繁,請稍後再試'
}), 429
# 記錄當前請求
redis_client.zadd(key, {str(current_time): current_time})
redis_client.expire(key, per_seconds)
except Exception:
# 如果 Redis 不可用,不阻擋請求
pass
return f(*args, **kwargs)
return decorated_function
return decorator

52
app/utils/exceptions.py Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
自定義例外模組
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
class DocumentTranslatorError(Exception):
"""文件翻譯系統基礎例外"""
def __init__(self, message, error_code=None):
self.message = message
self.error_code = error_code
super().__init__(self.message)
class AuthenticationError(DocumentTranslatorError):
"""認證相關例外"""
pass
class ValidationError(DocumentTranslatorError):
"""驗證相關例外"""
pass
class TranslationError(DocumentTranslatorError):
"""翻譯相關例外"""
pass
class FileProcessingError(DocumentTranslatorError):
"""檔案處理相關例外"""
pass
class APIError(DocumentTranslatorError):
"""API 相關例外"""
pass
class ConfigurationError(DocumentTranslatorError):
"""配置相關例外"""
pass
class DatabaseError(DocumentTranslatorError):
"""資料庫相關例外"""
pass

280
app/utils/helpers.py Normal file
View File

@@ -0,0 +1,280 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
輔助工具模組
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
import os
import uuid
import shutil
from pathlib import Path
from datetime import datetime
from werkzeug.utils import secure_filename
from flask import current_app
def generate_filename(original_filename, job_uuid, file_type='original', language_code=None):
"""生成安全的檔案名稱"""
# 取得檔案副檔名
file_ext = Path(original_filename).suffix.lower()
# 清理原始檔名
clean_name = Path(original_filename).stem
clean_name = secure_filename(clean_name)[:50] # 限制長度
if file_type == 'original':
return f"original_{clean_name}_{job_uuid[:8]}{file_ext}"
elif file_type == 'translated':
return f"translated_{clean_name}_{language_code}_{job_uuid[:8]}{file_ext}"
else:
return f"{file_type}_{clean_name}_{job_uuid[:8]}{file_ext}"
def create_job_directory(job_uuid):
"""建立任務專用目錄"""
upload_folder = current_app.config.get('UPLOAD_FOLDER')
job_dir = Path(upload_folder) / job_uuid
# 建立目錄
job_dir.mkdir(parents=True, exist_ok=True)
return job_dir
def save_uploaded_file(file_obj, job_uuid):
"""儲存上傳的檔案"""
try:
# 建立任務目錄
job_dir = create_job_directory(job_uuid)
# 生成檔案名稱
filename = generate_filename(file_obj.filename, job_uuid, 'original')
file_path = job_dir / filename
# 儲存檔案
file_obj.save(str(file_path))
# 取得檔案大小
file_size = file_path.stat().st_size
return {
'success': True,
'filename': filename,
'file_path': str(file_path),
'file_size': file_size
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def cleanup_job_directory(job_uuid):
"""清理任務目錄"""
try:
upload_folder = current_app.config.get('UPLOAD_FOLDER')
job_dir = Path(upload_folder) / job_uuid
if job_dir.exists() and job_dir.is_dir():
shutil.rmtree(job_dir)
return True
return False
except Exception:
return False
def format_file_size(size_bytes):
"""格式化檔案大小"""
if size_bytes == 0:
return "0 B"
size_names = ["B", "KB", "MB", "GB", "TB"]
i = 0
while size_bytes >= 1024 and i < len(size_names) - 1:
size_bytes /= 1024.0
i += 1
return f"{size_bytes:.1f} {size_names[i]}"
def get_file_icon(file_extension):
"""根據副檔名取得檔案圖示"""
icon_map = {
'.docx': 'file-word',
'.doc': 'file-word',
'.pptx': 'file-powerpoint',
'.ppt': 'file-powerpoint',
'.xlsx': 'file-excel',
'.xls': 'file-excel',
'.pdf': 'file-pdf'
}
return icon_map.get(file_extension.lower(), 'file')
def calculate_processing_time(start_time, end_time=None):
"""計算處理時間"""
if not start_time:
return None
if not end_time:
end_time = datetime.utcnow()
if isinstance(start_time, str):
start_time = datetime.fromisoformat(start_time.replace('Z', '+00:00'))
if isinstance(end_time, str):
end_time = datetime.fromisoformat(end_time.replace('Z', '+00:00'))
duration = end_time - start_time
# 轉換為秒
total_seconds = int(duration.total_seconds())
if total_seconds < 60:
return f"{total_seconds}"
elif total_seconds < 3600:
minutes = total_seconds // 60
seconds = total_seconds % 60
return f"{minutes}{seconds}"
else:
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
return f"{hours}小時{minutes}"
def generate_download_token(job_uuid, language_code, user_id):
"""生成下載令牌"""
import hashlib
import time
# 組合資料
data = f"{job_uuid}:{language_code}:{user_id}:{int(time.time())}"
# 加上應用程式密鑰
secret_key = current_app.config.get('SECRET_KEY', 'default_secret')
data_with_secret = f"{data}:{secret_key}"
# 生成 hash
token = hashlib.sha256(data_with_secret.encode()).hexdigest()
return token
def verify_download_token(token, job_uuid, language_code, user_id, max_age=3600):
"""驗證下載令牌"""
import time
try:
# 取得當前時間戳
current_time = int(time.time())
# 在有效時間範圍內嘗試匹配令牌
for i in range(max_age):
timestamp = current_time - i
expected_token = generate_download_token_with_timestamp(
job_uuid, language_code, user_id, timestamp
)
if token == expected_token:
return True
return False
except Exception:
return False
def generate_download_token_with_timestamp(job_uuid, language_code, user_id, timestamp):
"""使用指定時間戳生成下載令牌"""
import hashlib
data = f"{job_uuid}:{language_code}:{user_id}:{timestamp}"
secret_key = current_app.config.get('SECRET_KEY', 'default_secret')
data_with_secret = f"{data}:{secret_key}"
return hashlib.sha256(data_with_secret.encode()).hexdigest()
def get_supported_languages():
"""取得支援的語言列表"""
return {
'auto': '自動偵測',
'zh-CN': '簡體中文',
'zh-TW': '繁體中文',
'en': '英文',
'ja': '日文',
'ko': '韓文',
'vi': '越南文',
'th': '泰文',
'id': '印尼文',
'ms': '馬來文',
'es': '西班牙文',
'fr': '法文',
'de': '德文',
'ru': '俄文'
}
def parse_json_field(json_str):
"""安全解析JSON欄位"""
import json
if not json_str:
return None
try:
if isinstance(json_str, str):
return json.loads(json_str)
return json_str
except (json.JSONDecodeError, TypeError):
return None
def format_datetime(dt, format_type='full'):
"""格式化日期時間"""
if not dt:
return None
if isinstance(dt, str):
try:
dt = datetime.fromisoformat(dt.replace('Z', '+00:00'))
except ValueError:
return dt
if format_type == 'date':
return dt.strftime('%Y-%m-%d')
elif format_type == 'time':
return dt.strftime('%H:%M:%S')
elif format_type == 'short':
return dt.strftime('%Y-%m-%d %H:%M')
else: # full
return dt.strftime('%Y-%m-%d %H:%M:%S')
def create_response(success=True, data=None, message=None, error=None, error_code=None):
"""建立統一的API回應格式"""
response = {
'success': success
}
if data is not None:
response['data'] = data
if message:
response['message'] = message
if error:
response['error'] = error_code or 'ERROR'
if not message:
response['message'] = error
return response

232
app/utils/ldap_auth.py Normal file
View File

@@ -0,0 +1,232 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
LDAP 認證服務
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
import time
from ldap3 import Server, Connection, SUBTREE, ALL_ATTRIBUTES
from flask import current_app
from .logger import get_logger
from .exceptions import AuthenticationError
logger = get_logger(__name__)
class LDAPAuthService:
"""LDAP 認證服務"""
def __init__(self):
self.config = current_app.config
self.server_url = self.config.get('LDAP_SERVER')
self.port = self.config.get('LDAP_PORT', 389)
self.use_ssl = self.config.get('LDAP_USE_SSL', False)
self.bind_user_dn = self.config.get('LDAP_BIND_USER_DN')
self.bind_password = self.config.get('LDAP_BIND_USER_PASSWORD')
self.search_base = self.config.get('LDAP_SEARCH_BASE')
self.login_attr = self.config.get('LDAP_USER_LOGIN_ATTR', 'userPrincipalName')
def create_connection(self, retries=3):
"""建立 LDAP 連線(帶重試機制)"""
for attempt in range(retries):
try:
server = Server(
self.server_url,
port=self.port,
use_ssl=self.use_ssl,
get_info=ALL_ATTRIBUTES
)
conn = Connection(
server,
user=self.bind_user_dn,
password=self.bind_password,
auto_bind=True,
raise_exceptions=True
)
logger.info("LDAP connection established successfully")
return conn
except Exception as e:
logger.error(f"LDAP connection attempt {attempt + 1} failed: {str(e)}")
if attempt == retries - 1:
raise AuthenticationError(f"LDAP connection failed: {str(e)}")
time.sleep(1)
return None
def authenticate_user(self, username, password):
"""驗證使用者憑證"""
try:
conn = self.create_connection()
if not conn:
raise AuthenticationError("Unable to connect to LDAP server")
# 搜尋使用者
search_filter = f"(&(objectClass=person)(objectCategory=person)({self.login_attr}={username}))"
conn.search(
self.search_base,
search_filter,
SUBTREE,
attributes=['displayName', 'mail', 'sAMAccountName', 'userPrincipalName', 'department']
)
if not conn.entries:
logger.warning(f"User not found: {username}")
raise AuthenticationError("帳號不存在")
user_entry = conn.entries[0]
user_dn = user_entry.entry_dn
# 驗證使用者密碼
try:
user_conn = Connection(
conn.server,
user=user_dn,
password=password,
auto_bind=True,
raise_exceptions=True
)
user_conn.unbind()
# 返回使用者資訊
user_info = {
'username': str(user_entry.sAMAccountName) if user_entry.sAMAccountName else username,
'display_name': str(user_entry.displayName) if user_entry.displayName else username,
'email': str(user_entry.mail) if user_entry.mail else f"{username}@panjit.com.tw",
'department': str(user_entry.department) if hasattr(user_entry, 'department') and user_entry.department else None,
'user_principal_name': str(user_entry.userPrincipalName) if user_entry.userPrincipalName else username
}
logger.info(f"User authenticated successfully: {username}")
return user_info
except Exception as e:
logger.warning(f"Authentication failed for user {username}: {str(e)}")
raise AuthenticationError("密碼錯誤")
except AuthenticationError:
raise
except Exception as e:
logger.error(f"LDAP authentication error: {str(e)}")
raise AuthenticationError(f"認證服務錯誤: {str(e)}")
finally:
if 'conn' in locals() and conn:
conn.unbind()
def search_users(self, search_term, limit=20):
"""搜尋使用者"""
try:
conn = self.create_connection()
if not conn:
return []
# 建構搜尋過濾器
search_filter = f"""(&
(objectClass=person)
(objectCategory=person)
(!(userAccountControl:1.2.840.113556.1.4.803:=2))
(|
(displayName=*{search_term}*)
(mail=*{search_term}*)
(sAMAccountName=*{search_term}*)
(userPrincipalName=*{search_term}*)
)
)"""
# 移除多餘空白
search_filter = ' '.join(search_filter.split())
conn.search(
self.search_base,
search_filter,
SUBTREE,
attributes=['sAMAccountName', 'displayName', 'mail', 'department'],
size_limit=limit
)
results = []
for entry in conn.entries:
results.append({
'username': str(entry.sAMAccountName) if entry.sAMAccountName else '',
'display_name': str(entry.displayName) if entry.displayName else '',
'email': str(entry.mail) if entry.mail else '',
'department': str(entry.department) if hasattr(entry, 'department') and entry.department else ''
})
logger.info(f"LDAP search found {len(results)} results for term: {search_term}")
return results
except Exception as e:
logger.error(f"LDAP search error: {str(e)}")
return []
finally:
if 'conn' in locals() and conn:
conn.unbind()
def get_user_info(self, username):
"""取得使用者詳細資訊"""
try:
conn = self.create_connection()
if not conn:
return None
# 支援 sAMAccountName 和 userPrincipalName 格式
if '@' in username:
search_filter = f"""(&
(objectClass=person)
(|
(userPrincipalName={username})
(mail={username})
)
)"""
else:
search_filter = f"(&(objectClass=person)(sAMAccountName={username}))"
# 移除多餘空白
search_filter = ' '.join(search_filter.split())
conn.search(
self.search_base,
search_filter,
SUBTREE,
attributes=['displayName', 'mail', 'sAMAccountName', 'userPrincipalName', 'department']
)
if not conn.entries:
return None
entry = conn.entries[0]
return {
'username': str(entry.sAMAccountName) if entry.sAMAccountName else username,
'display_name': str(entry.displayName) if entry.displayName else username,
'email': str(entry.mail) if entry.mail else f"{username}@panjit.com.tw",
'department': str(entry.department) if hasattr(entry, 'department') and entry.department else None,
'user_principal_name': str(entry.userPrincipalName) if entry.userPrincipalName else ''
}
except Exception as e:
logger.error(f"Error getting user info for {username}: {str(e)}")
return None
finally:
if 'conn' in locals() and conn:
conn.unbind()
def test_connection(self):
"""測試 LDAP 連線(健康檢查用)"""
try:
conn = self.create_connection(retries=1)
if conn:
conn.unbind()
return True
return False
except Exception as e:
logger.error(f"LDAP connection test failed: {str(e)}")
return False

126
app/utils/logger.py Normal file
View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
日誌管理模組
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
import logging
import os
from pathlib import Path
from logging.handlers import RotatingFileHandler
from flask import current_app, has_request_context, request, g
def get_logger(name):
"""取得指定名稱的日誌器"""
logger = logging.getLogger(name)
# 避免重複設定 handler
if not logger.handlers:
setup_logger(logger)
return logger
def setup_logger(logger):
"""設定日誌器"""
if has_request_context() and current_app:
log_level = current_app.config.get('LOG_LEVEL', 'INFO')
log_file = current_app.config.get('LOG_FILE', 'logs/app.log')
else:
log_level = os.environ.get('LOG_LEVEL', 'INFO')
log_file = os.environ.get('LOG_FILE', 'logs/app.log')
# 確保日誌目錄存在
log_path = Path(log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
# 設定日誌等級
logger.setLevel(getattr(logging, log_level.upper()))
# 建立格式化器
formatter = logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# 檔案處理器(使用輪轉)
file_handler = RotatingFileHandler(
log_file,
maxBytes=10*1024*1024, # 10MB
backupCount=5,
encoding='utf-8'
)
file_handler.setLevel(getattr(logging, log_level.upper()))
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# 控制台處理器
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
class DatabaseLogHandler(logging.Handler):
"""資料庫日誌處理器"""
def emit(self, record):
"""發送日誌記錄到資料庫"""
try:
from app.models.log import SystemLog
# 取得使用者和任務資訊(如果有的話)
user_id = None
job_id = None
extra_data = {}
if has_request_context():
user_id = g.get('current_user_id')
extra_data.update({
'method': request.method,
'endpoint': request.endpoint,
'url': request.url,
'ip_address': request.remote_addr,
'user_agent': request.headers.get('User-Agent')
})
# 儲存到資料庫
SystemLog.log(
level=record.levelname,
module=record.name,
message=record.getMessage(),
user_id=user_id,
job_id=job_id,
extra_data=extra_data if extra_data else None
)
except Exception:
# 避免日誌記錄失敗影響主程序
pass
def init_logging(app):
"""初始化應用程式日誌"""
# 設定根日誌器
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
# 添加資料庫日誌處理器(僅對重要日誌)
if app.config.get('SQLALCHEMY_DATABASE_URI'):
db_handler = DatabaseLogHandler()
db_handler.setLevel(logging.WARNING) # 只記錄警告以上等級到資料庫
root_logger.addHandler(db_handler)
# 設定 Flask 應用日誌
if not app.logger.handlers:
setup_logger(app.logger)
# 設定第三方庫日誌等級
logging.getLogger('werkzeug').setLevel(logging.WARNING)
logging.getLogger('urllib3').setLevel(logging.WARNING)
logging.getLogger('requests').setLevel(logging.WARNING)

203
app/utils/validators.py Normal file
View File

@@ -0,0 +1,203 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
驗證工具模組
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
import os
from pathlib import Path
from flask import current_app
from .exceptions import ValidationError
def validate_file(file_obj):
"""驗證上傳的檔案"""
if not file_obj:
raise ValidationError("未選擇檔案", "NO_FILE")
if not file_obj.filename:
raise ValidationError("檔案名稱為空", "NO_FILENAME")
# 檢查檔案副檔名
file_ext = Path(file_obj.filename).suffix.lower()
allowed_extensions = current_app.config.get('ALLOWED_EXTENSIONS', {'.docx', '.doc', '.pptx', '.xlsx', '.xls', '.pdf'})
if file_ext not in allowed_extensions:
raise ValidationError(
f"不支援的檔案類型: {file_ext},支援的格式: {', '.join(allowed_extensions)}",
"INVALID_FILE_TYPE"
)
# 檢查檔案大小
max_size = current_app.config.get('MAX_CONTENT_LENGTH', 26214400) # 25MB
# 取得檔案大小
file_obj.seek(0, os.SEEK_END)
file_size = file_obj.tell()
file_obj.seek(0)
if file_size > max_size:
raise ValidationError(
f"檔案大小超過限制 ({format_file_size(max_size)})",
"FILE_TOO_LARGE"
)
if file_size == 0:
raise ValidationError("檔案為空", "EMPTY_FILE")
return {
'filename': file_obj.filename,
'file_extension': file_ext,
'file_size': file_size,
'valid': True
}
def validate_languages(source_language, target_languages):
"""驗證語言設定"""
# 支援的語言列表
supported_languages = {
'auto': '自動偵測',
'zh-CN': '簡體中文',
'zh-TW': '繁體中文',
'en': '英文',
'ja': '日文',
'ko': '韓文',
'vi': '越南文',
'th': '泰文',
'id': '印尼文',
'ms': '馬來文',
'es': '西班牙文',
'fr': '法文',
'de': '德文',
'ru': '俄文'
}
# 驗證來源語言
if source_language and source_language not in supported_languages:
raise ValidationError(
f"不支援的來源語言: {source_language}",
"INVALID_SOURCE_LANGUAGE"
)
# 驗證目標語言
if not target_languages or not isinstance(target_languages, list):
raise ValidationError("必須指定至少一個目標語言", "NO_TARGET_LANGUAGES")
if len(target_languages) == 0:
raise ValidationError("必須指定至少一個目標語言", "NO_TARGET_LANGUAGES")
if len(target_languages) > 10: # 限制最多10個目標語言
raise ValidationError("目標語言數量過多最多支援10個", "TOO_MANY_TARGET_LANGUAGES")
invalid_languages = [lang for lang in target_languages if lang not in supported_languages]
if invalid_languages:
raise ValidationError(
f"不支援的目標語言: {', '.join(invalid_languages)}",
"INVALID_TARGET_LANGUAGE"
)
# 檢查來源語言和目標語言是否有重疊
if source_language and source_language != 'auto' and source_language in target_languages:
raise ValidationError(
"目標語言不能包含來源語言",
"SOURCE_TARGET_OVERLAP"
)
return {
'source_language': source_language or 'auto',
'target_languages': target_languages,
'supported_languages': supported_languages,
'valid': True
}
def validate_job_uuid(job_uuid):
"""驗證任務UUID格式"""
import uuid
if not job_uuid:
raise ValidationError("任務UUID不能為空", "INVALID_UUID")
try:
uuid.UUID(job_uuid)
return True
except ValueError:
raise ValidationError("任務UUID格式錯誤", "INVALID_UUID")
def validate_pagination(page, per_page):
"""驗證分頁參數"""
try:
page = int(page) if page else 1
per_page = int(per_page) if per_page else 20
except (ValueError, TypeError):
raise ValidationError("分頁參數必須為數字", "INVALID_PAGINATION")
if page < 1:
raise ValidationError("頁數必須大於0", "INVALID_PAGE")
if per_page < 1 or per_page > 100:
raise ValidationError("每頁項目數必須在1-100之間", "INVALID_PER_PAGE")
return page, per_page
def format_file_size(size_bytes):
"""格式化檔案大小顯示"""
if size_bytes == 0:
return "0 B"
size_names = ["B", "KB", "MB", "GB", "TB"]
i = 0
while size_bytes >= 1024 and i < len(size_names) - 1:
size_bytes /= 1024.0
i += 1
return f"{size_bytes:.1f} {size_names[i]}"
def sanitize_filename(filename):
"""清理檔案名稱,移除不安全字元"""
import re
# 保留檔案名稱和副檔名
name = Path(filename).stem
ext = Path(filename).suffix
# 移除或替換不安全字元
safe_name = re.sub(r'[^\w\s.-]', '_', name)
safe_name = re.sub(r'\s+', '_', safe_name) # 空白替換為底線
safe_name = safe_name.strip('._') # 移除開頭結尾的點和底線
# 限制長度
if len(safe_name) > 100:
safe_name = safe_name[:100]
return f"{safe_name}{ext}"
def validate_date_range(start_date, end_date):
"""驗證日期範圍"""
from datetime import datetime
if start_date:
try:
start_date = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
except ValueError:
raise ValidationError("開始日期格式錯誤", "INVALID_START_DATE")
if end_date:
try:
end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
except ValueError:
raise ValidationError("結束日期格式錯誤", "INVALID_END_DATE")
if start_date and end_date and start_date > end_date:
raise ValidationError("開始日期不能晚於結束日期", "INVALID_DATE_RANGE")
return start_date, end_date