This commit is contained in:
beabigegg
2025-10-03 08:19:40 +08:00
commit 6599716481
99 changed files with 28184 additions and 0 deletions

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

327
app/models/job.py Normal file
View 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
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]]
}

View 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
View 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
View 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
View 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()