NO docker
This commit is contained in:
30
app/models/__init__.py
Normal file
30
app/models/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/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
|
||||
from .notification import Notification, NotificationType
|
||||
from .sys_user import SysUser, LoginLog
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
'TranslationJob',
|
||||
'JobFile',
|
||||
'TranslationCache',
|
||||
'APIUsageStats',
|
||||
'SystemLog',
|
||||
'Notification',
|
||||
'NotificationType',
|
||||
'SysUser',
|
||||
'LoginLog'
|
||||
]
|
138
app/models/cache.py
Normal file
138
app/models/cache.py
Normal 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
|
327
app/models/job.py
Normal file
327
app/models/job.py
Normal file
@@ -0,0 +1,327 @@
|
||||
#!/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
|
||||
from app.utils.timezone import format_taiwan_time
|
||||
|
||||
|
||||
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='總成本')
|
||||
conversation_id = db.Column(db.String(100), comment='Dify對話ID,用於維持翻譯上下文')
|
||||
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='更新時間'
|
||||
)
|
||||
deleted_at = db.Column(db.DateTime, 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,
|
||||
'conversation_id': self.conversation_id,
|
||||
'processing_started_at': format_taiwan_time(self.processing_started_at, "%Y-%m-%d %H:%M:%S") if self.processing_started_at else None,
|
||||
'completed_at': format_taiwan_time(self.completed_at, "%Y-%m-%d %H:%M:%S") if self.completed_at else None,
|
||||
'created_at': format_taiwan_time(self.created_at, "%Y-%m-%d %H:%M:%S") if self.created_at else None,
|
||||
'updated_at': format_taiwan_time(self.updated_at, "%Y-%m-%d %H:%M:%S") if self.updated_at else None,
|
||||
'deleted_at': format_taiwan_time(self.deleted_at, "%Y-%m-%d %H:%M:%S") if self.deleted_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):
|
||||
"""新增原始檔案記錄"""
|
||||
from pathlib import Path
|
||||
stored_name = Path(file_path).name
|
||||
|
||||
original_file = JobFile(
|
||||
job_id=self.id,
|
||||
file_type='source',
|
||||
original_filename=filename,
|
||||
stored_filename=stored_name,
|
||||
file_path=file_path,
|
||||
file_size=file_size,
|
||||
mime_type=self._get_mime_type(filename)
|
||||
)
|
||||
db.session.add(original_file)
|
||||
db.session.commit()
|
||||
return original_file
|
||||
|
||||
def add_translated_file(self, language_code, filename, file_path, file_size):
|
||||
"""新增翻譯檔案記錄"""
|
||||
from pathlib import Path
|
||||
stored_name = Path(file_path).name
|
||||
|
||||
translated_file = JobFile(
|
||||
job_id=self.id,
|
||||
file_type='translated',
|
||||
language_code=language_code,
|
||||
original_filename=filename,
|
||||
stored_filename=stored_name,
|
||||
file_path=file_path,
|
||||
file_size=file_size,
|
||||
mime_type=self._get_mime_type(filename)
|
||||
)
|
||||
db.session.add(translated_file)
|
||||
db.session.commit()
|
||||
return translated_file
|
||||
|
||||
def _get_mime_type(self, filename):
|
||||
"""取得MIME類型"""
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
ext = Path(filename).suffix.lower()
|
||||
mime_map = {
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.pdf': 'application/pdf',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.txt': 'text/plain'
|
||||
}
|
||||
return mime_map.get(ext, mimetypes.guess_type(filename)[0] or 'application/octet-stream')
|
||||
|
||||
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='source').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()
|
||||
|
||||
def soft_delete(self):
|
||||
"""軟刪除任務(保留資料供報表使用)"""
|
||||
self.deleted_at = datetime.utcnow()
|
||||
self.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def restore(self):
|
||||
"""恢復已刪除的任務"""
|
||||
self.deleted_at = None
|
||||
self.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def is_deleted(self):
|
||||
"""檢查任務是否已被軟刪除"""
|
||||
return self.deleted_at is not None
|
||||
|
||||
@classmethod
|
||||
def get_queue_position(cls, job_uuid):
|
||||
"""取得任務在佇列中的位置"""
|
||||
job = cls.query.filter_by(job_uuid=job_uuid, deleted_at=None).first()
|
||||
if not job:
|
||||
return None
|
||||
|
||||
position = cls.query.filter(
|
||||
cls.status == 'PENDING',
|
||||
cls.deleted_at.is_(None),
|
||||
cls.created_at < job.created_at
|
||||
).count()
|
||||
|
||||
return position + 1
|
||||
|
||||
@classmethod
|
||||
def get_pending_jobs(cls):
|
||||
"""取得所有等待處理的任務"""
|
||||
return cls.query.filter_by(status='PENDING', deleted_at=None).order_by(cls.created_at.asc()).all()
|
||||
|
||||
@classmethod
|
||||
def get_processing_jobs(cls):
|
||||
"""取得所有處理中的任務"""
|
||||
return cls.query.filter_by(status='PROCESSING', deleted_at=None).all()
|
||||
|
||||
@classmethod
|
||||
def get_user_jobs(cls, user_id, status=None, limit=None, offset=None, include_deleted=False):
|
||||
"""取得使用者的任務列表"""
|
||||
query = cls.query.filter_by(user_id=user_id)
|
||||
|
||||
# 預設排除軟刪除的記錄,除非明確要求包含
|
||||
if not include_deleted:
|
||||
query = query.filter(cls.deleted_at.is_(None))
|
||||
|
||||
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, include_deleted=True):
|
||||
"""取得統計資料(預設包含所有記錄以確保報表完整性)"""
|
||||
query = cls.query
|
||||
|
||||
# 報表統計預設包含已刪除記錄以確保數據完整性
|
||||
if not include_deleted:
|
||||
query = query.filter(cls.deleted_at.is_(None))
|
||||
|
||||
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('source', 'translated', name='file_type'),
|
||||
nullable=False,
|
||||
comment='檔案類型'
|
||||
)
|
||||
language_code = db.Column(db.String(50), comment='語言代碼(翻譯檔案)')
|
||||
original_filename = db.Column(db.String(255), nullable=False, comment='原始檔名')
|
||||
stored_filename = db.Column(db.String(255), nullable=False, comment='儲存檔名')
|
||||
file_path = db.Column(db.String(500), nullable=False, comment='檔案路徑')
|
||||
file_size = db.Column(db.BigInteger, default=0, comment='檔案大小')
|
||||
mime_type = db.Column(db.String(100), comment='MIME 類型')
|
||||
created_at = db.Column(db.DateTime, default=func.now(), comment='建立時間')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<JobFile {self.original_filename}>'
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典格式"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'job_id': self.job_id,
|
||||
'file_type': self.file_type,
|
||||
'language_code': self.language_code,
|
||||
'original_filename': self.original_filename,
|
||||
'stored_filename': self.stored_filename,
|
||||
'file_path': self.file_path,
|
||||
'file_size': self.file_size,
|
||||
'mime_type': self.mime_type,
|
||||
'created_at': format_taiwan_time(self.created_at, "%Y-%m-%d %H:%M:%S") 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
211
app/models/log.py
Normal 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]]
|
||||
}
|
98
app/models/notification.py
Normal file
98
app/models/notification.py
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
通知系統資料模型
|
||||
|
||||
Author: PANJIT IT Team
|
||||
Created: 2024-01-28
|
||||
Modified: 2024-01-28
|
||||
"""
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app import db
|
||||
import uuid
|
||||
import json
|
||||
|
||||
|
||||
class NotificationType(str, Enum):
|
||||
"""通知類型枚舉"""
|
||||
SUCCESS = "success" # 成功
|
||||
ERROR = "error" # 錯誤
|
||||
WARNING = "warning" # 警告
|
||||
INFO = "info" # 資訊
|
||||
SYSTEM = "system" # 系統
|
||||
|
||||
|
||||
class Notification(db.Model):
|
||||
"""通知模型"""
|
||||
__tablename__ = 'dt_notifications'
|
||||
|
||||
# 主鍵
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
notification_uuid = db.Column(db.String(36), unique=True, nullable=False, index=True,
|
||||
default=lambda: str(uuid.uuid4()), comment='通知唯一識別碼')
|
||||
|
||||
# 基本資訊
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('dt_users.id'), nullable=False, comment='使用者ID')
|
||||
type = db.Column(db.Enum('INFO', 'SUCCESS', 'WARNING', 'ERROR', name='notification_type'),
|
||||
nullable=False, default=NotificationType.INFO.value, comment='通知類型')
|
||||
title = db.Column(db.String(255), nullable=False, comment='通知標題')
|
||||
message = db.Column(db.Text, nullable=False, comment='通知內容')
|
||||
|
||||
# 關聯資訊(可選)
|
||||
job_uuid = db.Column(db.String(36), nullable=True, comment='關聯任務UUID')
|
||||
link = db.Column(db.String(500), nullable=True, comment='相關連結')
|
||||
|
||||
# 狀態
|
||||
is_read = db.Column(db.Boolean, default=False, nullable=False, comment='是否已讀')
|
||||
read_at = db.Column(db.DateTime, nullable=True, comment='閱讀時間')
|
||||
|
||||
# 時間戳記
|
||||
created_at = db.Column(db.DateTime, default=func.now(), nullable=False, comment='建立時間')
|
||||
expires_at = db.Column(db.DateTime, nullable=True, comment='過期時間')
|
||||
|
||||
# 額外數據(JSON 格式儲存)
|
||||
extra_data = db.Column(db.JSON, nullable=True, comment='額外數據')
|
||||
|
||||
# 關聯
|
||||
user = db.relationship("User", backref="notifications")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Notification {self.notification_uuid}: {self.title}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典格式"""
|
||||
return {
|
||||
'id': self.notification_uuid, # 前端使用 UUID
|
||||
'user_id': self.user_id,
|
||||
'type': self.type,
|
||||
'title': self.title,
|
||||
'message': self.message,
|
||||
'job_uuid': self.job_uuid,
|
||||
'link': self.link,
|
||||
'is_read': self.is_read,
|
||||
'read': self.is_read, # 為了前端相容
|
||||
'read_at': self.read_at.isoformat() if self.read_at else None,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
|
||||
'extra_data': self.extra_data
|
||||
}
|
||||
|
||||
def mark_as_read(self):
|
||||
"""標記為已讀"""
|
||||
self.is_read = True
|
||||
self.read_at = datetime.now()
|
||||
|
||||
@classmethod
|
||||
def create_job_notification(cls, user_id, job_uuid, title, message, notification_type=NotificationType.INFO):
|
||||
"""創建任務相關通知"""
|
||||
return cls(
|
||||
user_id=user_id,
|
||||
job_uuid=job_uuid,
|
||||
type=notification_type.value,
|
||||
title=title,
|
||||
message=message,
|
||||
link=f"/job/{job_uuid}" # 連結到任務詳情頁
|
||||
)
|
233
app/models/stats.py
Normal file
233
app/models/stats.py
Normal file
@@ -0,0 +1,233 @@
|
||||
#!/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
|
||||
from app.utils.timezone import format_taiwan_time
|
||||
|
||||
|
||||
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': format_taiwan_time(self.created_at, "%Y-%m-%d %H:%M:%S") 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 解析使用量資訊
|
||||
usage_data = metadata.get('usage', {})
|
||||
|
||||
prompt_tokens = usage_data.get('prompt_tokens', 0)
|
||||
completion_tokens = usage_data.get('completion_tokens', 0)
|
||||
total_tokens = usage_data.get('total_tokens', prompt_tokens + completion_tokens)
|
||||
|
||||
# 計算成本 - 使用 Dify API 提供的總成本
|
||||
if 'total_price' in usage_data:
|
||||
# 直接使用 API 提供的總價格
|
||||
cost = float(usage_data.get('total_price', 0.0))
|
||||
else:
|
||||
# 備用計算方式
|
||||
prompt_price = float(usage_data.get('prompt_price', 0.0))
|
||||
completion_price = float(usage_data.get('completion_price', 0.0))
|
||||
cost = prompt_price + completion_price
|
||||
|
||||
# 單價資訊
|
||||
prompt_unit_price = usage_data.get('prompt_unit_price', 0.0)
|
||||
completion_unit_price = usage_data.get('completion_unit_price', 0.0)
|
||||
prompt_price_unit = usage_data.get('currency', 'USD')
|
||||
|
||||
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
|
||||
]
|
297
app/models/sys_user.py
Normal file
297
app/models/sys_user.py
Normal file
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
系統使用者模型
|
||||
專門用於記錄帳號密碼和登入相關資訊
|
||||
|
||||
Author: PANJIT IT Team
|
||||
Created: 2025-10-01
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON, Enum as SQLEnum, BigInteger
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from app import db
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SysUser(db.Model):
|
||||
"""系統使用者模型 - 專門處理帳號密碼和登入記錄"""
|
||||
__tablename__ = 'sys_user'
|
||||
|
||||
id = Column(BigInteger, primary_key=True)
|
||||
|
||||
# 帳號資訊
|
||||
username = Column(String(255), nullable=False, unique=True, comment='登入帳號')
|
||||
password_hash = Column(String(512), comment='密碼雜湊 (如果需要本地儲存)')
|
||||
email = Column(String(255), nullable=False, unique=True, comment='電子郵件')
|
||||
display_name = Column(String(255), comment='顯示名稱')
|
||||
|
||||
# API 認證資訊
|
||||
api_user_id = Column(String(255), comment='API 回傳的使用者 ID')
|
||||
api_access_token = Column(Text, comment='API 回傳的 access_token')
|
||||
api_token_expires_at = Column(DateTime, comment='API Token 過期時間')
|
||||
|
||||
# 登入相關
|
||||
auth_method = Column(SQLEnum('API', 'LDAP', name='sys_user_auth_method'),
|
||||
default='API', comment='認證方式')
|
||||
last_login_at = Column(DateTime, comment='最後登入時間')
|
||||
last_login_ip = Column(String(45), comment='最後登入 IP')
|
||||
login_count = Column(Integer, default=0, comment='登入次數')
|
||||
login_success_count = Column(Integer, default=0, comment='成功登入次數')
|
||||
login_fail_count = Column(Integer, default=0, comment='失敗登入次數')
|
||||
|
||||
# 帳號狀態
|
||||
is_active = Column(Boolean, default=True, comment='是否啟用')
|
||||
is_locked = Column(Boolean, default=False, comment='是否鎖定')
|
||||
locked_until = Column(DateTime, comment='鎖定至何時')
|
||||
|
||||
# 審計欄位
|
||||
created_at = Column(DateTime, default=datetime.utcnow, comment='建立時間')
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment='更新時間')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<SysUser {self.username}>'
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""轉換為字典格式"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'username': self.username,
|
||||
'email': self.email,
|
||||
'display_name': self.display_name,
|
||||
'api_user_id': self.api_user_id,
|
||||
'auth_method': self.auth_method,
|
||||
'last_login_at': self.last_login_at.isoformat() if self.last_login_at else None,
|
||||
'login_count': self.login_count,
|
||||
'login_success_count': self.login_success_count,
|
||||
'login_fail_count': self.login_fail_count,
|
||||
'is_active': self.is_active,
|
||||
'is_locked': self.is_locked,
|
||||
'api_token_expires_at': self.api_token_expires_at.isoformat() if self.api_token_expires_at else None,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_or_create(cls, email: str, **kwargs) -> 'SysUser':
|
||||
"""
|
||||
取得或建立系統使用者 (方案A: 使用 email 作為主要識別鍵)
|
||||
|
||||
Args:
|
||||
email: 電子郵件 (主要識別鍵)
|
||||
**kwargs: 其他欄位
|
||||
|
||||
Returns:
|
||||
SysUser: 系統使用者實例
|
||||
"""
|
||||
try:
|
||||
# 使用 email 作為主要識別 (專門用於登入記錄)
|
||||
sys_user = cls.query.filter_by(email=email).first()
|
||||
|
||||
if sys_user:
|
||||
# 更新現有記錄
|
||||
sys_user.username = kwargs.get('username', sys_user.username) # API name (姓名+email)
|
||||
sys_user.display_name = kwargs.get('display_name', sys_user.display_name) # API name (姓名+email)
|
||||
sys_user.api_user_id = kwargs.get('api_user_id', sys_user.api_user_id) # Azure Object ID
|
||||
sys_user.api_access_token = kwargs.get('api_access_token', sys_user.api_access_token)
|
||||
sys_user.api_token_expires_at = kwargs.get('api_token_expires_at', sys_user.api_token_expires_at)
|
||||
sys_user.auth_method = kwargs.get('auth_method', sys_user.auth_method)
|
||||
sys_user.updated_at = datetime.utcnow()
|
||||
|
||||
logger.info(f"更新現有系統使用者: {email}")
|
||||
else:
|
||||
# 建立新記錄
|
||||
sys_user = cls(
|
||||
username=kwargs.get('username', ''), # API name (姓名+email 格式)
|
||||
email=email, # 純 email,主要識別鍵
|
||||
display_name=kwargs.get('display_name', ''), # API name (姓名+email 格式)
|
||||
api_user_id=kwargs.get('api_user_id'), # Azure Object ID
|
||||
api_access_token=kwargs.get('api_access_token'),
|
||||
api_token_expires_at=kwargs.get('api_token_expires_at'),
|
||||
auth_method=kwargs.get('auth_method', 'API'),
|
||||
login_count=0,
|
||||
login_success_count=0,
|
||||
login_fail_count=0
|
||||
)
|
||||
db.session.add(sys_user)
|
||||
logger.info(f"建立新系統使用者: {email}")
|
||||
|
||||
db.session.commit()
|
||||
return sys_user
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"取得或建立系統使用者失敗: {str(e)}")
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def get_by_email(cls, email: str) -> Optional['SysUser']:
|
||||
"""根據 email 查找系統使用者"""
|
||||
return cls.query.filter_by(email=email).first()
|
||||
|
||||
def record_login_attempt(self, success: bool, ip_address: str = None, auth_method: str = None):
|
||||
"""
|
||||
記錄登入嘗試
|
||||
|
||||
Args:
|
||||
success: 是否成功
|
||||
ip_address: IP 地址
|
||||
auth_method: 認證方式
|
||||
"""
|
||||
try:
|
||||
self.login_count = (self.login_count or 0) + 1
|
||||
|
||||
if success:
|
||||
self.login_success_count = (self.login_success_count or 0) + 1
|
||||
self.last_login_at = datetime.utcnow()
|
||||
self.last_login_ip = ip_address
|
||||
if auth_method:
|
||||
self.auth_method = auth_method
|
||||
|
||||
# 成功登入時解除鎖定
|
||||
if self.is_locked:
|
||||
self.is_locked = False
|
||||
self.locked_until = None
|
||||
|
||||
else:
|
||||
self.login_fail_count = (self.login_fail_count or 0) + 1
|
||||
|
||||
# 檢查是否需要鎖定帳號 (連續失敗5次)
|
||||
if self.login_fail_count >= 5:
|
||||
self.is_locked = True
|
||||
self.locked_until = datetime.utcnow() + timedelta(minutes=30) # 鎖定30分鐘
|
||||
|
||||
self.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"記錄登入嘗試失敗: {str(e)}")
|
||||
|
||||
def is_account_locked(self) -> bool:
|
||||
"""檢查帳號是否被鎖定"""
|
||||
if not self.is_locked:
|
||||
return False
|
||||
|
||||
# 檢查鎖定時間是否已過
|
||||
if self.locked_until and datetime.utcnow() > self.locked_until:
|
||||
self.is_locked = False
|
||||
self.locked_until = None
|
||||
db.session.commit()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def set_password(self, password: str):
|
||||
"""設置密碼雜湊 (如果需要本地儲存密碼)"""
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
"""檢查密碼 (如果有本地儲存密碼)"""
|
||||
if not self.password_hash:
|
||||
return False
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def update_api_token(self, access_token: str, expires_at: datetime = None):
|
||||
"""更新 API Token"""
|
||||
self.api_access_token = access_token
|
||||
self.api_token_expires_at = expires_at
|
||||
self.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def is_api_token_valid(self) -> bool:
|
||||
"""檢查 API Token 是否有效"""
|
||||
if not self.api_access_token or not self.api_token_expires_at:
|
||||
return False
|
||||
return datetime.utcnow() < self.api_token_expires_at
|
||||
|
||||
|
||||
class LoginLog(db.Model):
|
||||
"""登入記錄模型"""
|
||||
__tablename__ = 'login_logs'
|
||||
|
||||
id = Column(BigInteger, primary_key=True)
|
||||
|
||||
# 基本資訊
|
||||
username = Column(String(255), nullable=False, comment='登入帳號')
|
||||
auth_method = Column(SQLEnum('API', 'LDAP', name='login_log_auth_method'),
|
||||
nullable=False, comment='認證方式')
|
||||
|
||||
# 登入結果
|
||||
login_success = Column(Boolean, nullable=False, comment='是否成功')
|
||||
error_message = Column(Text, comment='錯誤訊息(失敗時)')
|
||||
|
||||
# 環境資訊
|
||||
ip_address = Column(String(45), comment='IP 地址')
|
||||
user_agent = Column(Text, comment='瀏覽器資訊')
|
||||
|
||||
# API 回應 (可選,用於除錯)
|
||||
api_response_summary = Column(JSON, comment='API 回應摘要')
|
||||
|
||||
# 時間
|
||||
login_at = Column(DateTime, default=datetime.utcnow, comment='登入時間')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<LoginLog {self.username}:{self.auth_method}:{self.login_success}>'
|
||||
|
||||
@classmethod
|
||||
def create_log(cls, username: str, auth_method: str, login_success: bool,
|
||||
error_message: str = None, ip_address: str = None,
|
||||
user_agent: str = None, api_response_summary: Dict = None) -> 'LoginLog':
|
||||
"""
|
||||
建立登入記錄
|
||||
|
||||
Args:
|
||||
username: 使用者帳號
|
||||
auth_method: 認證方式
|
||||
login_success: 是否成功
|
||||
error_message: 錯誤訊息
|
||||
ip_address: IP 地址
|
||||
user_agent: 瀏覽器資訊
|
||||
api_response_summary: API 回應摘要
|
||||
|
||||
Returns:
|
||||
LoginLog: 登入記錄
|
||||
"""
|
||||
try:
|
||||
log = cls(
|
||||
username=username,
|
||||
auth_method=auth_method,
|
||||
login_success=login_success,
|
||||
error_message=error_message,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
api_response_summary=api_response_summary
|
||||
)
|
||||
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
return log
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"建立登入記錄失敗: {str(e)}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_recent_failed_attempts(cls, username: str, minutes: int = 15) -> int:
|
||||
"""
|
||||
取得最近失敗的登入嘗試次數
|
||||
|
||||
Args:
|
||||
username: 使用者帳號
|
||||
minutes: 時間範圍(分鐘)
|
||||
|
||||
Returns:
|
||||
int: 失敗次數
|
||||
"""
|
||||
since = datetime.utcnow() - timedelta(minutes=minutes)
|
||||
return cls.query.filter(
|
||||
cls.username == username,
|
||||
cls.login_success == False,
|
||||
cls.login_at >= since
|
||||
).count()
|
124
app/models/user.py
Normal file
124
app/models/user.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/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
|
||||
from app.utils.timezone import format_taiwan_time
|
||||
|
||||
|
||||
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': format_taiwan_time(self.last_login, "%Y-%m-%d %H:%M:%S") if self.last_login else None,
|
||||
'created_at': format_taiwan_time(self.created_at, "%Y-%m-%d %H:%M:%S") if self.created_at else None,
|
||||
'updated_at': format_taiwan_time(self.updated_at, "%Y-%m-%d %H:%M:%S") 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):
|
||||
"""計算使用者總成本"""
|
||||
try:
|
||||
from app.models.stats import APIUsageStats
|
||||
return db.session.query(
|
||||
func.sum(APIUsageStats.cost)
|
||||
).filter(APIUsageStats.user_id == self.id).scalar() or 0.0
|
||||
except Exception:
|
||||
return 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):
|
||||
"""取得或建立使用者 (方案A: 使用 email 作為主要識別鍵)"""
|
||||
# 先嘗試用 email 查找 (因為 email 是唯一且穩定的識別碼)
|
||||
user = cls.query.filter_by(email=email).first()
|
||||
|
||||
if user:
|
||||
# 更新使用者資訊 (API name 格式: 姓名+email)
|
||||
user.username = username # API 的 name (姓名+email 格式)
|
||||
user.display_name = display_name # API 的 name (姓名+email 格式)
|
||||
if department:
|
||||
user.department = department
|
||||
user.updated_at = datetime.utcnow()
|
||||
else:
|
||||
# 建立新使用者
|
||||
user = cls(
|
||||
username=username, # API 的 name (姓名+email 格式)
|
||||
display_name=display_name, # API 的 name (姓名+email 格式)
|
||||
email=email, # 純 email,唯一識別鍵
|
||||
department=department,
|
||||
is_admin=(email.lower() == 'ymirliu@panjit.com.tw') # 硬編碼管理員
|
||||
)
|
||||
db.session.add(user)
|
||||
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
@classmethod
|
||||
def get_by_email(cls, email):
|
||||
"""根據 email 查找使用者"""
|
||||
return cls.query.filter_by(email=email).first()
|
||||
|
||||
@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()
|
Reference in New Issue
Block a user