64 KiB
64 KiB
技術設計文件 (TDD) - PANJIT Document Translator Web System
1. 系統架構概述
1.1 整體架構
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ External │
│ (Vue 3) │◄──►│ (Flask 3) │◄──►│ Services │
└─────────────────┘ └─────────────────┘ └─────────────────┘
▲
│
┌─────────────────┐
│ Infrastructure │
│ MySQL + Redis │
│ + File Storage │
└─────────────────┘
1.2 核心元件
- 前端: Vue 3 + Element Plus + Vite (SPA)
- 後端: Flask 3 + SQLAlchemy + Celery + Redis
- 資料庫: MySQL 8.0+ (現有環境)
- 任務佇列: Celery + Redis (非同步任務處理)
- 檔案儲存: 本地檔案系統 + UUID 目錄結構
- 認證: LDAP3 (panjit.com.tw AD整合)
- 通知: SMTP (mail.panjit.com.tw)
- 翻譯引擎: Dify API (theaken.com)
1.3 技術堆疊
Backend:
- Python 3.8+
- Flask 3.0+
- SQLAlchemy 2.0+
- Celery 5.0+
- Redis 6.0+
- LDAP3
- Requests
Frontend:
- Vue 3.3+
- Vite 4.0+
- Element Plus 2.3+
- Axios 1.0+
- Vue Router 4.0+
- Pinia 2.0+
Infrastructure:
- MySQL 8.0+
- Redis 6.0+
- Nginx (生產環境)
- Gunicorn (生產環境)
2. 資料庫設計
2.1 資料表結構
2.1.1 使用者資訊表 (dt_users)
CREATE TABLE dt_users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(100) NOT NULL UNIQUE COMMENT 'AD帳號',
display_name VARCHAR(200) NOT NULL COMMENT '顯示名稱',
email VARCHAR(255) NOT NULL COMMENT '電子郵件',
department VARCHAR(100) COMMENT '部門',
is_admin BOOLEAN DEFAULT FALSE COMMENT '是否為管理員',
last_login TIMESTAMP NULL COMMENT '最後登入時間',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_username (username),
INDEX idx_email (email)
) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
2.1.2 翻譯任務表 (dt_translation_jobs)
CREATE TABLE dt_translation_jobs (
id INT PRIMARY KEY AUTO_INCREMENT,
job_uuid VARCHAR(36) NOT NULL UNIQUE COMMENT '任務唯一識別碼',
user_id INT NOT NULL COMMENT '使用者ID',
original_filename VARCHAR(500) NOT NULL COMMENT '原始檔名',
file_extension VARCHAR(10) NOT NULL COMMENT '檔案副檔名',
file_size BIGINT NOT NULL COMMENT '檔案大小(bytes)',
file_path VARCHAR(1000) NOT NULL COMMENT '檔案路徑',
source_language VARCHAR(50) DEFAULT NULL COMMENT '來源語言',
target_languages JSON NOT NULL COMMENT '目標語言陣列',
status ENUM('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'RETRY') DEFAULT 'PENDING',
progress DECIMAL(5,2) DEFAULT 0.00 COMMENT '處理進度(%)',
retry_count INT DEFAULT 0 COMMENT '重試次數',
error_message TEXT NULL COMMENT '錯誤訊息',
total_tokens INT DEFAULT 0 COMMENT '總token數',
total_cost DECIMAL(10,4) DEFAULT 0.0000 COMMENT '總成本',
processing_started_at TIMESTAMP NULL COMMENT '開始處理時間',
completed_at TIMESTAMP NULL COMMENT '完成時間',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_job_uuid (job_uuid),
INDEX idx_status (status),
INDEX idx_created_at (created_at),
FOREIGN KEY (user_id) REFERENCES dt_users(id) ON DELETE CASCADE
) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
2.1.3 檔案記錄表 (dt_job_files)
CREATE TABLE dt_job_files (
id INT PRIMARY KEY AUTO_INCREMENT,
job_id INT NOT NULL COMMENT '任務ID',
file_type ENUM('ORIGINAL', 'TRANSLATED') NOT NULL COMMENT '檔案類型',
language_code VARCHAR(50) NULL COMMENT '語言代碼(翻譯檔案)',
filename VARCHAR(500) NOT NULL COMMENT '檔案名稱',
file_path VARCHAR(1000) NOT NULL COMMENT '檔案路徑',
file_size BIGINT NOT NULL COMMENT '檔案大小',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_job_id (job_id),
INDEX idx_file_type (file_type),
FOREIGN KEY (job_id) REFERENCES dt_translation_jobs(id) ON DELETE CASCADE
) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
2.1.4 翻譯快取表 (dt_translation_cache)
CREATE TABLE dt_translation_cache (
id INT PRIMARY KEY AUTO_INCREMENT,
source_text_hash VARCHAR(64) NOT NULL COMMENT '來源文字hash',
source_language VARCHAR(50) NOT NULL COMMENT '來源語言',
target_language VARCHAR(50) NOT NULL COMMENT '目標語言',
source_text TEXT NOT NULL COMMENT '來源文字',
translated_text TEXT NOT NULL COMMENT '翻譯文字',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_cache (source_text_hash, source_language, target_language),
INDEX idx_languages (source_language, target_language)
) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
2.1.5 API使用統計表 (dt_api_usage_stats)
CREATE TABLE dt_api_usage_stats (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL COMMENT '使用者ID',
job_id INT NULL COMMENT '任務ID',
api_endpoint VARCHAR(200) NOT NULL COMMENT 'API端點',
prompt_tokens INT DEFAULT 0 COMMENT 'Prompt token數',
completion_tokens INT DEFAULT 0 COMMENT 'Completion token數',
total_tokens INT DEFAULT 0 COMMENT '總token數',
prompt_unit_price DECIMAL(10,8) DEFAULT 0.00000000 COMMENT '單價',
prompt_price_unit VARCHAR(20) DEFAULT 'USD' COMMENT '價格單位',
cost DECIMAL(10,4) DEFAULT 0.0000 COMMENT '成本',
response_time_ms INT DEFAULT 0 COMMENT '回應時間(毫秒)',
success BOOLEAN DEFAULT TRUE COMMENT '是否成功',
error_message TEXT NULL COMMENT '錯誤訊息',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_job_id (job_id),
INDEX idx_created_at (created_at),
FOREIGN KEY (user_id) REFERENCES dt_users(id) ON DELETE CASCADE,
FOREIGN KEY (job_id) REFERENCES dt_translation_jobs(id) ON DELETE SET NULL
) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
2.1.6 系統日誌表 (dt_system_logs)
CREATE TABLE dt_system_logs (
id INT PRIMARY KEY AUTO_INCREMENT,
level ENUM('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL') NOT NULL,
module VARCHAR(100) NOT NULL COMMENT '模組名稱',
user_id INT NULL COMMENT '使用者ID',
job_id INT NULL COMMENT '任務ID',
message TEXT NOT NULL COMMENT '日誌訊息',
extra_data JSON NULL COMMENT '額外資料',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_level (level),
INDEX idx_module (module),
INDEX idx_user_id (user_id),
INDEX idx_created_at (created_at),
FOREIGN KEY (user_id) REFERENCES dt_users(id) ON DELETE SET NULL,
FOREIGN KEY (job_id) REFERENCES dt_translation_jobs(id) ON DELETE SET NULL
) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
2.2 索引優化策略
- 主鍵使用自增 INT,提升查詢效率
- 為常用查詢欄位建立複合索引
- 使用 JSON 型別儲存結構化資料(目標語言、額外資料)
- 適當的外鍵約束確保資料完整性
3. API 設計規格
3.1 API 基本規範
Base URL: http://localhost:5000/api/v1
Authentication: Session-based (Flask-Session)
Content-Type: application/json
Charset: UTF-8
3.2 認證相關 API
3.2.1 使用者登入
POST /api/v1/auth/login
Content-Type: application/json
{
"username": "user@panjit.com.tw",
"password": "password"
}
Response 200:
{
"success": true,
"data": {
"user": {
"id": 1,
"username": "user@panjit.com.tw",
"display_name": "User Name",
"email": "user@panjit.com.tw",
"department": "IT",
"is_admin": false
},
"session_id": "session_token"
},
"message": "登入成功"
}
Response 401:
{
"success": false,
"error": "INVALID_CREDENTIALS",
"message": "帳號或密碼錯誤"
}
3.2.2 使用者登出
POST /api/v1/auth/logout
Response 200:
{
"success": true,
"message": "登出成功"
}
3.2.3 取得當前使用者
GET /api/v1/auth/me
Response 200:
{
"success": true,
"data": {
"user": {
"id": 1,
"username": "user@panjit.com.tw",
"display_name": "User Name",
"email": "user@panjit.com.tw",
"department": "IT",
"is_admin": false
}
}
}
3.3 檔案上傳 API
3.3.1 上傳檔案
POST /api/v1/files/upload
Content-Type: multipart/form-data
Form Data:
- file: [File] (必填)
- source_language: string (選填,預設 "auto")
- target_languages: ["en", "vi", "zh-TW"] (必填)
Response 200:
{
"success": true,
"data": {
"job_uuid": "550e8400-e29b-41d4-a716-446655440000",
"original_filename": "document.docx",
"file_size": 1024000,
"source_language": "auto",
"target_languages": ["en", "vi", "zh-TW"],
"status": "PENDING",
"queue_position": 3
},
"message": "檔案上傳成功,已加入翻譯佇列"
}
Response 400:
{
"success": false,
"error": "INVALID_FILE_TYPE",
"message": "不支援的檔案類型"
}
Response 413:
{
"success": false,
"error": "FILE_TOO_LARGE",
"message": "檔案大小超過限制 (25MB)"
}
3.4 任務管理 API
3.4.1 取得使用者任務列表
GET /api/v1/jobs?page=1&per_page=20&status=all
Response 200:
{
"success": true,
"data": {
"jobs": [
{
"id": 1,
"job_uuid": "550e8400-e29b-41d4-a716-446655440000",
"original_filename": "document.docx",
"file_size": 1024000,
"source_language": "auto",
"target_languages": ["en", "vi", "zh-TW"],
"status": "COMPLETED",
"progress": 100.00,
"retry_count": 0,
"total_cost": 0.0250,
"created_at": "2024-01-28T10:30:00Z",
"completed_at": "2024-01-28T10:35:00Z"
}
],
"pagination": {
"page": 1,
"per_page": 20,
"total": 50,
"pages": 3
}
}
}
3.4.2 取得任務詳細資訊
GET /api/v1/jobs/{job_uuid}
Response 200:
{
"success": true,
"data": {
"job": {
"id": 1,
"job_uuid": "550e8400-e29b-41d4-a716-446655440000",
"original_filename": "document.docx",
"file_size": 1024000,
"source_language": "zh-CN",
"target_languages": ["en", "vi", "zh-TW"],
"status": "COMPLETED",
"progress": 100.00,
"retry_count": 0,
"total_tokens": 1500,
"total_cost": 0.0250,
"processing_started_at": "2024-01-28T10:30:30Z",
"completed_at": "2024-01-28T10:35:00Z",
"created_at": "2024-01-28T10:30:00Z"
},
"files": [
{
"file_type": "ORIGINAL",
"filename": "document.docx",
"file_size": 1024000
},
{
"file_type": "TRANSLATED",
"language_code": "en",
"filename": "document_en_translated.docx",
"file_size": 1156000
},
{
"file_type": "TRANSLATED",
"language_code": "vi",
"filename": "document_vi_translated.docx",
"file_size": 1203000
}
]
}
}
3.4.3 重試失敗任務
POST /api/v1/jobs/{job_uuid}/retry
Response 200:
{
"success": true,
"data": {
"job_uuid": "550e8400-e29b-41d4-a716-446655440000",
"status": "PENDING",
"retry_count": 1
},
"message": "任務已重新加入佇列"
}
3.5 檔案下載 API
3.5.1 下載翻譯檔案
GET /api/v1/files/{job_uuid}/download/{language_code}
Response 200:
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document
Content-Disposition: attachment; filename="document_en_translated.docx"
[Binary File Content]
Response 404:
{
"success": false,
"error": "FILE_NOT_FOUND",
"message": "檔案不存在或無權限存取"
}
3.6 管理員專用 API
3.6.1 取得系統統計
GET /api/v1/admin/stats?period=month
Response 200:
{
"success": true,
"data": {
"overview": {
"total_jobs": 150,
"completed_jobs": 142,
"failed_jobs": 5,
"total_users": 25,
"active_users_today": 8,
"total_cost": 12.5600
},
"daily_stats": [
{
"date": "2024-01-28",
"jobs": 12,
"completed": 11,
"failed": 1,
"cost": 0.8500
}
],
"user_rankings": [
{
"user_id": 1,
"display_name": "User A",
"job_count": 25,
"total_cost": 3.2100
}
]
}
}
3.6.2 取得所有使用者任務
GET /api/v1/admin/jobs?page=1&per_page=50&user_id=all&status=all
Response 200:
{
"success": true,
"data": {
"jobs": [...],
"pagination": {...}
}
}
3.7 即時更新 API
3.7.1 任務狀態 WebSocket
// WebSocket 連接
ws://localhost:5000/api/v1/ws/job-status
// 訂閱任務狀態更新
{
"action": "subscribe",
"job_uuid": "550e8400-e29b-41d4-a716-446655440000"
}
// 收到狀態更新
{
"type": "job_status",
"data": {
"job_uuid": "550e8400-e29b-41d4-a716-446655440000",
"status": "PROCESSING",
"progress": 45.50
}
}
4. 核心業務邏輯
4.1 翻譯任務處理流程
4.1.1 任務生命週期
graph TD
A[檔案上傳] --> B[建立任務記錄]
B --> C[檔案驗證]
C --> D{驗證通過?}
D -->|是| E[加入佇列 PENDING]
D -->|否| F[返回錯誤]
E --> G[Celery 取出任務]
G --> H[更新狀態 PROCESSING]
H --> I[解析檔案內容]
I --> J[分割文字片段]
J --> K[批次翻譯]
K --> L{翻譯成功?}
L -->|是| M[生成翻譯檔案]
L -->|否| N{重試次數 < 3?}
N -->|是| O[延遲重試]
N -->|否| P[標記失敗 FAILED]
O --> H
M --> Q[更新狀態 COMPLETED]
Q --> R[發送通知郵件]
P --> S[發送失敗通知]
4.1.2 翻譯服務核心邏輯
class TranslationService:
def __init__(self, dify_client, cache_service):
self.dify_client = dify_client
self.cache_service = cache_service
self.sentence_splitter = SentenceSplitter()
def translate_document(self, job_id: str, file_path: str,
source_lang: str, target_langs: List[str]) -> Dict:
"""翻譯文件主流程"""
try:
# 1. 解析文件內容
document_parser = self._get_document_parser(file_path)
text_segments = document_parser.extract_text_segments()
# 2. 分割句子並去重
sentences = self._split_and_deduplicate(text_segments, source_lang)
# 3. 批次翻譯
translation_results = {}
for target_lang in target_langs:
translated_sentences = self._batch_translate(
sentences, source_lang, target_lang, job_id
)
translation_results[target_lang] = translated_sentences
# 4. 生成翻譯文件
output_files = {}
for target_lang, translations in translation_results.items():
output_file = document_parser.generate_translated_document(
translations, target_lang
)
output_files[target_lang] = output_file
return {
"success": True,
"output_files": output_files,
"total_tokens": self._calculate_total_tokens(sentences),
"total_cost": self._calculate_total_cost(job_id)
}
except Exception as e:
logger.error(f"Translation failed for job {job_id}: {str(e)}")
return {
"success": False,
"error": str(e)
}
def _batch_translate(self, sentences: List[str], source_lang: str,
target_lang: str, job_id: str) -> List[str]:
"""批次翻譯句子"""
translations = []
for sentence in sentences:
# 檢查快取
cached_translation = self.cache_service.get_translation(
sentence, source_lang, target_lang
)
if cached_translation:
translations.append(cached_translation)
continue
# 呼叫 Dify API
try:
response = self.dify_client.translate(
text=sentence,
source_language=source_lang,
target_language=target_lang
)
translation = response['data']['text']
translations.append(translation)
# 儲存至快取
self.cache_service.save_translation(
sentence, source_lang, target_lang, translation
)
# 記錄 API 使用統計
self._record_api_usage(job_id, response['metadata'])
except Exception as e:
logger.error(f"Translation API error: {str(e)}")
raise TranslationAPIError(f"翻譯失敗: {str(e)}")
return translations
4.2 使用者認證與授權
4.2.1 LDAP 認證服務
class LDAPAuthService:
def __init__(self, server_url: str, bind_user: str, bind_password: str):
self.server_url = server_url
self.bind_user = bind_user
self.bind_password = bind_password
self.search_base = "OU=PANJIT,DC=panjit,DC=com,DC=tw"
def authenticate_user(self, username: str, password: str) -> Dict:
"""驗證使用者憑證"""
try:
server = Server(self.server_url, get_info=ALL)
# 建立服務帳號連線
service_conn = Connection(
server,
user=self.bind_user,
password=self.bind_password,
auto_bind=True
)
# 搜尋使用者
user_dn = self._find_user_dn(service_conn, username)
if not user_dn:
return {"success": False, "error": "USER_NOT_FOUND"}
# 驗證使用者密碼
user_conn = Connection(server, user=user_dn, password=password)
if not user_conn.bind():
return {"success": False, "error": "INVALID_PASSWORD"}
# 取得使用者詳細資訊
user_info = self._get_user_info(service_conn, user_dn)
service_conn.unbind()
user_conn.unbind()
return {
"success": True,
"user_info": {
"username": username,
"display_name": user_info.get("displayName", username),
"email": user_info.get("mail", f"{username}@panjit.com.tw"),
"department": user_info.get("department", ""),
"is_admin": username == "ymirliu@panjit.com.tw"
}
}
except Exception as e:
logger.error(f"LDAP authentication error: {str(e)}")
return {"success": False, "error": "LDAP_ERROR"}
def _find_user_dn(self, connection: Connection, username: str) -> str:
"""查找使用者 DN"""
search_filter = f"(userPrincipalName={username})"
connection.search(
search_base=self.search_base,
search_filter=search_filter,
attributes=["distinguishedName"]
)
if connection.entries:
return str(connection.entries[0].distinguishedName)
return None
4.3 檔案處理與儲存
4.3.1 檔案管理服務
class FileManagementService:
def __init__(self, base_storage_path: str):
self.base_path = Path(base_storage_path)
self.max_file_size = 25 * 1024 * 1024 # 25MB
self.allowed_extensions = {'.docx', '.doc', '.pptx', '.xlsx', '.xls', '.pdf'}
def save_uploaded_file(self, file_obj, job_uuid: str) -> Dict:
"""儲存上傳檔案"""
try:
# 驗證檔案
validation_result = self._validate_file(file_obj)
if not validation_result["valid"]:
return validation_result
# 建立專用目錄
job_dir = self.base_path / job_uuid
job_dir.mkdir(parents=True, exist_ok=True)
# 儲存原檔
original_filename = secure_filename(file_obj.filename)
original_path = job_dir / f"original_{original_filename}"
file_obj.save(str(original_path))
return {
"success": True,
"file_path": str(original_path),
"file_size": original_path.stat().st_size,
"original_filename": original_filename
}
except Exception as e:
logger.error(f"File save error: {str(e)}")
return {"success": False, "error": str(e)}
def _validate_file(self, file_obj) -> Dict:
"""驗證檔案"""
if not file_obj:
return {"valid": False, "error": "NO_FILE"}
if not file_obj.filename:
return {"valid": False, "error": "NO_FILENAME"}
# 檢查副檔名
ext = Path(file_obj.filename).suffix.lower()
if ext not in self.allowed_extensions:
return {"valid": False, "error": "INVALID_FILE_TYPE"}
# 檢查檔案大小
file_obj.seek(0, os.SEEK_END)
size = file_obj.tell()
file_obj.seek(0)
if size > self.max_file_size:
return {"valid": False, "error": "FILE_TOO_LARGE"}
return {"valid": True}
def cleanup_old_files(self, days_to_keep: int = 7):
"""清理舊檔案"""
cutoff_time = datetime.now() - timedelta(days=days_to_keep)
for job_dir in self.base_path.iterdir():
if job_dir.is_dir():
# 檢查目錄修改時間
dir_mtime = datetime.fromtimestamp(job_dir.stat().st_mtime)
if dir_mtime < cutoff_time:
try:
shutil.rmtree(job_dir)
logger.info(f"Cleaned up old directory: {job_dir}")
except Exception as e:
logger.error(f"Failed to cleanup {job_dir}: {str(e)}")
5. 前端架構設計
5.1 Vue 3 應用結構
frontend/
├── src/
│ ├── components/ # 共用元件
│ │ ├── FileUploader.vue
│ │ ├── JobStatusCard.vue
│ │ ├── LanguageSelector.vue
│ │ └── ProgressBar.vue
│ ├── views/ # 頁面元件
│ │ ├── LoginView.vue
│ │ ├── HomeView.vue
│ │ ├── JobListView.vue
│ │ ├── HistoryView.vue
│ │ └── AdminView.vue
│ ├── stores/ # Pinia 狀態管理
│ │ ├── auth.js
│ │ ├── jobs.js
│ │ └── admin.js
│ ├── services/ # API 服務
│ │ ├── api.js
│ │ ├── auth.js
│ │ ├── jobs.js
│ │ └── files.js
│ ├── utils/ # 工具函數
│ │ ├── constants.js
│ │ ├── helpers.js
│ │ └── websocket.js
│ ├── router/ # 路由配置
│ │ └── index.js
│ ├── App.vue
│ └── main.js
├── public/
├── package.json
└── vite.config.js
5.2 核心元件設計
5.2.1 檔案上傳元件
<!-- FileUploader.vue -->
<template>
<div class="file-uploader">
<el-upload
ref="uploadRef"
:action="uploadUrl"
:headers="uploadHeaders"
:data="uploadData"
:before-upload="beforeUpload"
:on-success="onSuccess"
:on-error="onError"
:show-file-list="false"
drag
multiple
>
<el-icon class="el-icon--upload">
<upload-filled />
</el-icon>
<div class="el-upload__text">
拖拽檔案至此或 <em>點擊上傳</em>
</div>
<template #tip>
<div class="el-upload__tip">
支援 .docx, .doc, .pptx, .xlsx, .xls, .pdf 格式,單檔最大 25MB
</div>
</template>
</el-upload>
<div class="translation-settings" v-if="showSettings">
<el-form :model="translationForm" label-width="100px">
<el-form-item label="來源語言">
<el-select v-model="translationForm.sourceLanguage">
<el-option label="自動偵測" value="auto"></el-option>
<el-option label="繁體中文" value="zh-TW"></el-option>
<el-option label="簡體中文" value="zh-CN"></el-option>
<el-option label="英文" value="en"></el-option>
</el-select>
</el-form-item>
<el-form-item label="目標語言">
<el-select
v-model="translationForm.targetLanguages"
multiple
placeholder="請選擇目標語言"
>
<el-option label="英文" value="en"></el-option>
<el-option label="越南文" value="vi"></el-option>
<el-option label="繁體中文" value="zh-TW"></el-option>
<el-option label="簡體中文" value="zh-CN"></el-option>
<el-option label="日文" value="ja"></el-option>
<el-option label="韓文" value="ko"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="startTranslation">
開始翻譯
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useJobsStore } from '@/stores/jobs'
import { ElMessage } from 'element-plus'
const jobsStore = useJobsStore()
const uploadRef = ref()
const showSettings = ref(false)
const currentFile = ref(null)
const translationForm = ref({
sourceLanguage: 'auto',
targetLanguages: ['en']
})
const uploadUrl = computed(() => '/api/v1/files/upload')
const uploadHeaders = computed(() => ({
'X-Requested-With': 'XMLHttpRequest'
}))
const uploadData = computed(() => ({
source_language: translationForm.value.sourceLanguage,
target_languages: translationForm.value.targetLanguages
}))
const beforeUpload = (file) => {
const isValidType = [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'application/pdf'
].includes(file.type)
if (!isValidType) {
ElMessage.error('不支援的檔案類型')
return false
}
const isLt25M = file.size / 1024 / 1024 < 25
if (!isLt25M) {
ElMessage.error('檔案大小不能超過 25MB')
return false
}
currentFile.value = file
showSettings.value = true
return false // 阻止自動上傳
}
const startTranslation = async () => {
if (!currentFile.value) return
if (translationForm.value.targetLanguages.length === 0) {
ElMessage.error('請選擇至少一個目標語言')
return
}
try {
const formData = new FormData()
formData.append('file', currentFile.value)
formData.append('source_language', translationForm.value.sourceLanguage)
formData.append('target_languages', JSON.stringify(translationForm.value.targetLanguages))
await jobsStore.uploadFile(formData)
ElMessage.success('檔案上傳成功,已加入翻譯佇列')
// 重置表單
showSettings.value = false
currentFile.value = null
translationForm.value.targetLanguages = ['en']
} catch (error) {
ElMessage.error(error.message || '上傳失敗')
}
}
const onSuccess = (response) => {
// 處理成功回調(如果需要)
}
const onError = (error) => {
ElMessage.error('上傳失敗')
console.error(error)
}
</script>
5.3 狀態管理 (Pinia)
5.3.1 任務狀態管理
// stores/jobs.js
import { defineStore } from 'pinia'
import { jobsAPI } from '@/services/jobs'
import { websocketService } from '@/utils/websocket'
export const useJobsStore = defineStore('jobs', {
state: () => ({
jobs: [],
currentJob: null,
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0
},
loading: false,
error: null
}),
getters: {
pendingJobs: (state) => state.jobs.filter(job => job.status === 'PENDING'),
processingJobs: (state) => state.jobs.filter(job => job.status === 'PROCESSING'),
completedJobs: (state) => state.jobs.filter(job => job.status === 'COMPLETED'),
failedJobs: (state) => state.jobs.filter(job => job.status === 'FAILED'),
getJobByUuid: (state) => (uuid) => {
return state.jobs.find(job => job.job_uuid === uuid)
}
},
actions: {
async fetchJobs(page = 1, status = 'all') {
try {
this.loading = true
this.error = null
const response = await jobsAPI.getJobs({ page, status, per_page: this.pagination.per_page })
this.jobs = response.data.jobs
this.pagination = response.data.pagination
} catch (error) {
this.error = error.message
throw error
} finally {
this.loading = false
}
},
async uploadFile(formData) {
try {
const response = await jobsAPI.uploadFile(formData)
// 新增任務到列表頭部
const newJob = response.data
this.jobs.unshift(newJob)
// 訂閱 WebSocket 狀態更新
this.subscribeToJobUpdates(newJob.job_uuid)
return response.data
} catch (error) {
throw error
}
},
async retryJob(jobUuid) {
try {
const response = await jobsAPI.retryJob(jobUuid)
// 更新本地狀態
const jobIndex = this.jobs.findIndex(job => job.job_uuid === jobUuid)
if (jobIndex !== -1) {
this.jobs[jobIndex] = { ...this.jobs[jobIndex], ...response.data }
}
return response.data
} catch (error) {
throw error
}
},
async fetchJobDetail(jobUuid) {
try {
const response = await jobsAPI.getJobDetail(jobUuid)
this.currentJob = response.data
return response.data
} catch (error) {
throw error
}
},
subscribeToJobUpdates(jobUuid) {
websocketService.subscribeToJob(jobUuid, (update) => {
// 更新本地任務狀態
const jobIndex = this.jobs.findIndex(job => job.job_uuid === update.job_uuid)
if (jobIndex !== -1) {
this.jobs[jobIndex] = { ...this.jobs[jobIndex], ...update }
}
// 如果是當前查看的任務詳情,也要更新
if (this.currentJob && this.currentJob.job_uuid === update.job_uuid) {
this.currentJob = { ...this.currentJob, ...update }
}
})
},
updateJobStatus(jobUuid, statusUpdate) {
const jobIndex = this.jobs.findIndex(job => job.job_uuid === jobUuid)
if (jobIndex !== -1) {
this.jobs[jobIndex] = { ...this.jobs[jobIndex], ...statusUpdate }
}
}
}
})
5.4 WebSocket 即時更新
5.4.1 WebSocket 服務
// utils/websocket.js
class WebSocketService {
constructor() {
this.ws = null
this.subscribers = new Map()
this.reconnectInterval = 5000
this.maxReconnectAttempts = 5
this.reconnectAttempts = 0
this.isConnected = false
}
connect() {
try {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${location.host}/api/v1/ws/job-status`
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
console.log('WebSocket connected')
this.isConnected = true
this.reconnectAttempts = 0
}
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data)
this.handleMessage(data)
}
this.ws.onclose = () => {
console.log('WebSocket disconnected')
this.isConnected = false
this.attemptReconnect()
}
this.ws.onerror = (error) => {
console.error('WebSocket error:', error)
}
} catch (error) {
console.error('WebSocket connection failed:', error)
this.attemptReconnect()
}
}
disconnect() {
if (this.ws) {
this.ws.close()
this.ws = null
this.isConnected = false
}
this.subscribers.clear()
}
subscribeToJob(jobUuid, callback) {
// 訂閱任務狀態更新
if (!this.subscribers.has(jobUuid)) {
this.subscribers.set(jobUuid, [])
}
this.subscribers.get(jobUuid).push(callback)
// 如果 WebSocket 已連接,發送訂閱請求
if (this.isConnected) {
this.sendMessage({
action: 'subscribe',
job_uuid: jobUuid
})
}
}
unsubscribeFromJob(jobUuid) {
this.subscribers.delete(jobUuid)
if (this.isConnected) {
this.sendMessage({
action: 'unsubscribe',
job_uuid: jobUuid
})
}
}
sendMessage(message) {
if (this.isConnected && this.ws) {
this.ws.send(JSON.stringify(message))
}
}
handleMessage(data) {
if (data.type === 'job_status' && data.data) {
const { job_uuid } = data.data
const callbacks = this.subscribers.get(job_uuid)
if (callbacks) {
callbacks.forEach(callback => callback(data.data))
}
}
}
attemptReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++
console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
setTimeout(() => {
this.connect()
}, this.reconnectInterval)
}
}
}
export const websocketService = new WebSocketService()
6. 後端實現架構
6.1 Flask 應用結構
backend/
├── app/
│ ├── __init__.py
│ ├── models/ # 資料模型
│ │ ├── __init__.py
│ │ ├── user.py
│ │ ├── job.py
│ │ └── usage_stats.py
│ ├── services/ # 業務邏輯服務
│ │ ├── __init__.py
│ │ ├── auth_service.py
│ │ ├── translation_service.py
│ │ ├── file_service.py
│ │ ├── notification_service.py
│ │ └── admin_service.py
│ ├── api/ # API 路由
│ │ ├── __init__.py
│ │ ├── auth.py
│ │ ├── jobs.py
│ │ ├── files.py
│ │ ├── admin.py
│ │ └── websocket.py
│ ├── tasks/ # Celery 任務
│ │ ├── __init__.py
│ │ ├── translation.py
│ │ └── cleanup.py
│ ├── utils/ # 工具函數
│ │ ├── __init__.py
│ │ ├── decorators.py
│ │ ├── validators.py
│ │ ├── helpers.py
│ │ └── exceptions.py
│ └── config.py # 配置
├── migrations/ # 資料庫遷移
├── tests/ # 測試檔案
├── requirements.txt
├── celery_worker.py # Celery Worker
├── celery_beat.py # Celery 排程
└── app.py # Flask 應用入口
6.2 Celery 任務佇列
6.2.1 翻譯任務
# tasks/translation.py
from celery import current_task
from app.services.translation_service import TranslationService
from app.services.notification_service import NotificationService
from app.models.job import TranslationJob
from app.utils.exceptions import TranslationError
@celery.task(bind=True, max_retries=3)
def process_translation_job(self, job_id: int):
"""處理翻譯任務"""
try:
# 取得任務資訊
job = TranslationJob.query.get(job_id)
if not job:
raise ValueError(f"Job {job_id} not found")
# 更新任務狀態
job.update_status('PROCESSING')
# 建立翻譯服務
translation_service = TranslationService()
notification_service = NotificationService()
# 執行翻譯
result = translation_service.translate_document(
job_uuid=job.job_uuid,
file_path=job.file_path,
source_language=job.source_language,
target_languages=job.target_languages
)
if result['success']:
# 翻譯成功
job.update_status('COMPLETED')
job.total_tokens = result.get('total_tokens', 0)
job.total_cost = result.get('total_cost', 0.0)
job.completed_at = datetime.utcnow()
# 儲存翻譯檔案記錄
for lang, file_path in result['output_files'].items():
job.add_translated_file(lang, file_path)
# 發送完成通知
notification_service.send_job_completion_notification(job)
else:
# 翻譯失敗
raise TranslationError(result.get('error', 'Unknown error'))
except Exception as exc:
# 錯誤處理與重試
job.error_message = str(exc)
job.retry_count += 1
if self.request.retries < self.max_retries:
# 重試
job.update_status('RETRY')
# 計算重試延遲:30s, 60s, 120s
countdown = [30, 60, 120][self.request.retries]
raise self.retry(exc=exc, countdown=countdown)
else:
# 重試次數用盡,標記失敗
job.update_status('FAILED')
# 發送失敗通知
notification_service = NotificationService()
notification_service.send_job_failure_notification(job)
raise exc
@celery.task
def cleanup_old_files():
"""定期清理舊檔案"""
from app.services.file_service import FileManagementService
file_service = FileManagementService()
file_service.cleanup_old_files(days_to_keep=7)
# 定期任務設定
celery.conf.beat_schedule = {
'cleanup-old-files': {
'task': 'tasks.translation.cleanup_old_files',
'schedule': crontab(hour=2, minute=0), # 每日凌晨2點執行
},
}
6.3 WebSocket 即時更新
6.3.1 WebSocket 處理
# api/websocket.py
from flask import request
from flask_socketio import SocketIO, emit, join_room, leave_room, disconnect
from app.utils.decorators import login_required
from app.models.job import TranslationJob
socketio = SocketIO(cors_allowed_origins="*")
@socketio.on('connect')
@login_required
def handle_connect():
"""WebSocket 連接"""
user_id = session.get('user_id')
join_room(f"user_{user_id}")
emit('connected', {'status': 'connected'})
@socketio.on('disconnect')
@login_required
def handle_disconnect():
"""WebSocket 斷線"""
user_id = session.get('user_id')
leave_room(f"user_{user_id}")
@socketio.on('subscribe_job')
@login_required
def handle_subscribe_job(data):
"""訂閱任務狀態更新"""
user_id = session.get('user_id')
job_uuid = data.get('job_uuid')
if not job_uuid:
emit('error', {'message': 'job_uuid is required'})
return
# 驗證使用者是否有權限查看此任務
job = TranslationJob.query.filter_by(
job_uuid=job_uuid,
user_id=user_id
).first()
if not job and not session.get('is_admin'):
emit('error', {'message': 'Access denied'})
return
# 加入任務房間
join_room(f"job_{job_uuid}")
emit('subscribed', {'job_uuid': job_uuid})
@socketio.on('unsubscribe_job')
@login_required
def handle_unsubscribe_job(data):
"""取消訂閱任務狀態更新"""
job_uuid = data.get('job_uuid')
if job_uuid:
leave_room(f"job_{job_uuid}")
emit('unsubscribed', {'job_uuid': job_uuid})
def broadcast_job_update(job_uuid: str, status_data: dict):
"""廣播任務狀態更新"""
socketio.emit('job_status', {
'type': 'job_status',
'data': {
'job_uuid': job_uuid,
**status_data
}
}, room=f"job_{job_uuid}")
7. 部署與配置
7.1 環境配置
7.1.1 .env 檔案結構
# Flask 配置
FLASK_ENV=development
FLASK_DEBUG=true
SECRET_KEY=your-secret-key-here
# 資料庫配置
DATABASE_URL=mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060
MYSQL_HOST=mysql.theaken.com
MYSQL_PORT=33306
MYSQL_USER=A060
MYSQL_PASSWORD=WLeSCi0yhtc7
MYSQL_DATABASE=db_A060
MYSQL_CHARSET=utf8mb4
# Redis 配置
REDIS_URL=redis://localhost:6379/0
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
# LDAP 配置
LDAP_SERVER=panjit.com.tw
LDAP_PORT=389
LDAP_USE_SSL=false
LDAP_BIND_USER_DN=CN=LdapBind,CN=Users,DC=PANJIT,DC=COM,DC=TW
LDAP_BIND_USER_PASSWORD=panjit2481
LDAP_SEARCH_BASE=OU=PANJIT,DC=panjit,DC=com,DC=tw
LDAP_USER_LOGIN_ATTR=userPrincipalName
# SMTP 配置
SMTP_SERVER=mail.panjit.com.tw
SMTP_PORT=25
SMTP_USE_TLS=false
SMTP_USE_SSL=false
SMTP_AUTH_REQUIRED=false
SMTP_SENDER_EMAIL=todo-system@panjit.com.tw
SMTP_SENDER_PASSWORD=
# 檔案儲存
UPLOAD_FOLDER=uploads
MAX_CONTENT_LENGTH=26214400 # 25MB in bytes
# Dify API (從 api.txt 讀取)
DIFY_API_BASE_URL=
DIFY_API_KEY=
# 日誌配置
LOG_LEVEL=INFO
LOG_FILE=logs/app.log
# 管理員帳號
ADMIN_EMAIL=ymirliu@panjit.com.tw
7.1.2 開發環境啟動腳本
#!/bin/bash
# start_dev.sh
echo "啟動開發環境..."
# 啟動 Redis (如果尚未啟動)
if ! pgrep -x "redis-server" > /dev/null; then
echo "啟動 Redis..."
redis-server --daemonize yes
fi
# 啟動 Celery Worker
echo "啟動 Celery Worker..."
celery -A app.celery worker --loglevel=info --detach
# 啟動 Celery Beat (排程任務)
echo "啟動 Celery Beat..."
celery -A app.celery beat --loglevel=info --detach
# 啟動 Flask 應用
echo "啟動 Flask 應用..."
python app.py &
# 啟動前端開發伺服器
echo "啟動前端開發伺服器..."
cd frontend && npm run dev
echo "所有服務已啟動完成"
echo "Backend: http://localhost:5000"
echo "Frontend: http://localhost:3000"
7.2 生產環境部署
7.2.1 Nginx 配置
server {
listen 80;
server_name your-domain.com;
# 前端靜態檔案
location / {
root /path/to/frontend/dist;
try_files $uri $uri/ /index.html;
}
# API 請求代理到 Flask
location /api/ {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 支援
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# 檔案下載
location /downloads/ {
internal;
alias /path/to/uploads/;
add_header Content-Disposition attachment;
}
# 上傳檔案大小限制
client_max_body_size 25M;
}
7.2.2 Gunicorn 配置
# gunicorn.conf.py
import multiprocessing
bind = "127.0.0.1:5000"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "eventlet"
worker_connections = 1000
timeout = 120
keepalive = 5
# 日誌
accesslog = "logs/gunicorn_access.log"
errorlog = "logs/gunicorn_error.log"
loglevel = "info"
# 進程管理
preload_app = True
max_requests = 1000
max_requests_jitter = 100
# SSL (如果需要)
# keyfile = "path/to/keyfile"
# certfile = "path/to/certfile"
8. 測試策略
8.1 測試架構
tests/
├── unit/ # 單元測試
│ ├── test_auth_service.py
│ ├── test_translation_service.py
│ ├── test_file_service.py
│ └── test_models.py
├── integration/ # 整合測試
│ ├── test_api_auth.py
│ ├── test_api_jobs.py
│ ├── test_celery_tasks.py
│ └── test_ldap_auth.py
├── e2e/ # 端到端測試
│ ├── test_upload_flow.py
│ ├── test_translation_flow.py
│ └── test_admin_flow.py
├── fixtures/ # 測試資料
│ ├── sample_documents/
│ └── mock_data.py
├── conftest.py # pytest 配置
└── requirements.txt # 測試依賴
8.2 核心測試案例
8.2.1 API 認證測試
# tests/integration/test_api_auth.py
import pytest
from app import create_app
from app.models import User
class TestAuthAPI:
def test_login_success(self, client, mock_ldap_auth):
"""測試成功登入"""
mock_ldap_auth.return_value = {
"success": True,
"user_info": {
"username": "test@panjit.com.tw",
"display_name": "Test User",
"email": "test@panjit.com.tw",
"department": "IT",
"is_admin": False
}
}
response = client.post('/api/v1/auth/login', json={
"username": "test@panjit.com.tw",
"password": "password"
})
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
assert 'user' in data['data']
assert data['data']['user']['username'] == 'test@panjit.com.tw'
def test_login_invalid_credentials(self, client, mock_ldap_auth):
"""測試無效憑證登入"""
mock_ldap_auth.return_value = {
"success": False,
"error": "INVALID_PASSWORD"
}
response = client.post('/api/v1/auth/login', json={
"username": "test@panjit.com.tw",
"password": "wrong_password"
})
assert response.status_code == 401
data = response.get_json()
assert data['success'] is False
assert data['error'] == 'INVALID_CREDENTIALS'
def test_protected_route_without_auth(self, client):
"""測試未認證存取受保護路由"""
response = client.get('/api/v1/jobs')
assert response.status_code == 401
8.2.2 檔案上傳測試
# tests/integration/test_api_jobs.py
import pytest
import io
from app.models import TranslationJob
class TestJobsAPI:
def test_upload_file_success(self, client, auth_user, sample_docx):
"""測試成功上傳檔案"""
with client.session_transaction() as sess:
sess['user_id'] = auth_user.id
sess['is_admin'] = auth_user.is_admin
data = {
'file': (sample_docx, 'test.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
'source_language': 'auto',
'target_languages': '["en", "vi"]'
}
response = client.post('/api/v1/files/upload',
data=data,
content_type='multipart/form-data')
assert response.status_code == 200
result = response.get_json()
assert result['success'] is True
assert 'job_uuid' in result['data']
# 驗證資料庫記錄
job = TranslationJob.query.filter_by(
job_uuid=result['data']['job_uuid']
).first()
assert job is not None
assert job.user_id == auth_user.id
assert job.status == 'PENDING'
def test_upload_invalid_file_type(self, client, auth_user):
"""測試上傳無效檔案類型"""
with client.session_transaction() as sess:
sess['user_id'] = auth_user.id
data = {
'file': (io.BytesIO(b'test'), 'test.txt', 'text/plain'),
'target_languages': '["en"]'
}
response = client.post('/api/v1/files/upload',
data=data,
content_type='multipart/form-data')
assert response.status_code == 400
result = response.get_json()
assert result['success'] is False
assert result['error'] == 'INVALID_FILE_TYPE'
def test_get_user_jobs(self, client, auth_user, sample_jobs):
"""測試取得使用者任務列表"""
with client.session_transaction() as sess:
sess['user_id'] = auth_user.id
response = client.get('/api/v1/jobs')
assert response.status_code == 200
result = response.get_json()
assert result['success'] is True
assert len(result['data']['jobs']) == len(sample_jobs)
# 驗證只能看到自己的任務
for job in result['data']['jobs']:
db_job = TranslationJob.query.filter_by(
job_uuid=job['job_uuid']
).first()
assert db_job.user_id == auth_user.id
8.3 測試執行與 CI/CD
8.3.1 pytest 配置
# conftest.py
import pytest
import tempfile
import os
from app import create_app, db
from app.models import User, TranslationJob
@pytest.fixture(scope='session')
def app():
"""建立測試應用"""
db_fd, db_path = tempfile.mkstemp()
app = create_app({
'TESTING': True,
'DATABASE_URL': f'sqlite:///{db_path}',
'WTF_CSRF_ENABLED': False
})
with app.app_context():
db.create_all()
yield app
db.drop_all()
os.close(db_fd)
os.unlink(db_path)
@pytest.fixture
def client(app):
"""建立測試客戶端"""
return app.test_client()
@pytest.fixture
def auth_user(app):
"""建立測試使用者"""
with app.app_context():
user = User(
username='test@panjit.com.tw',
display_name='Test User',
email='test@panjit.com.tw',
department='IT',
is_admin=False
)
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def sample_docx():
"""提供測試用 DOCX 檔案"""
with open('tests/fixtures/sample_documents/test.docx', 'rb') as f:
return io.BytesIO(f.read())
9. 監控與維護
9.1 日誌管理
9.1.1 結構化日誌配置
# app/utils/logger.py
import logging
import json
from datetime import datetime
from flask import request, g
from app.models import SystemLog
class StructuredLogger:
def __init__(self, app=None):
self.app = app
if app:
self.init_app(app)
def init_app(self, app):
"""初始化日誌系統"""
log_level = app.config.get('LOG_LEVEL', 'INFO')
log_file = app.config.get('LOG_FILE', 'logs/app.log')
# 建立日誌目錄
os.makedirs(os.path.dirname(log_file), exist_ok=True)
# 配置日誌格式
formatter = logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s: %(message)s'
)
# 檔案處理器
file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setFormatter(formatter)
file_handler.setLevel(getattr(logging, log_level))
# 控制台處理器
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
console_handler.setLevel(logging.INFO)
# 配置根日誌器
app.logger.addHandler(file_handler)
app.logger.addHandler(console_handler)
app.logger.setLevel(getattr(logging, log_level))
def log_api_request(self, response_status=None, error=None):
"""記錄 API 請求"""
try:
log_data = {
'timestamp': datetime.utcnow().isoformat(),
'method': request.method,
'endpoint': request.endpoint,
'url': request.url,
'user_id': g.get('current_user_id'),
'ip_address': request.remote_addr,
'user_agent': request.headers.get('User-Agent'),
'response_status': response_status,
'error': error
}
# 寫入資料庫
system_log = SystemLog(
level='ERROR' if error else 'INFO',
module='api_request',
user_id=g.get('current_user_id'),
message=f"{request.method} {request.endpoint}",
extra_data=log_data
)
db.session.add(system_log)
db.session.commit()
except Exception as e:
current_app.logger.error(f"Failed to log API request: {str(e)}")
9.2 效能監控
9.2.1 API 效能中介軟體
# app/utils/middleware.py
import time
from flask import request, g
from functools import wraps
def monitor_performance(f):
"""監控 API 效能的裝飾器"""
@wraps(f)
def decorated_function(*args, **kwargs):
start_time = time.time()
try:
result = f(*args, **kwargs)
# 計算執行時間
execution_time = (time.time() - start_time) * 1000
# 記錄效能資料
if execution_time > 2000: # 超過 2 秒的請求
current_app.logger.warning(
f"Slow API request: {request.endpoint} took {execution_time:.2f}ms"
)
# 可以將效能資料存入監控系統
# metrics.record_api_performance(request.endpoint, execution_time)
return result
except Exception as e:
execution_time = (time.time() - start_time) * 1000
current_app.logger.error(
f"API error: {request.endpoint} failed after {execution_time:.2f}ms: {str(e)}"
)
raise
return decorated_function
class PerformanceMiddleware:
def __init__(self, app=None):
if app:
self.init_app(app)
def init_app(self, app):
app.before_request(self.before_request)
app.after_request(self.after_request)
def before_request(self):
g.start_time = time.time()
def after_request(self, response):
if hasattr(g, 'start_time'):
execution_time = (time.time() - g.start_time) * 1000
response.headers['X-Response-Time'] = f"{execution_time:.2f}ms"
# 記錄慢查詢
if execution_time > 1000: # 超過 1 秒
current_app.logger.warning(
f"Slow request: {request.method} {request.path} "
f"took {execution_time:.2f}ms"
)
return response
9.3 健康檢查
9.3.1 系統健康檢查 API
# app/api/health.py
from flask import Blueprint, jsonify
from app import db, redis_client
from app.services.dify_client import DifyClient
from datetime import datetime
health_bp = Blueprint('health', __name__)
@health_bp.route('/health', methods=['GET'])
def health_check():
"""系統健康檢查"""
status = {
'timestamp': datetime.utcnow().isoformat(),
'status': 'healthy',
'services': {}
}
# 資料庫檢查
try:
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:
redis_client.ping()
status['services']['redis'] = {'status': 'healthy'}
except Exception as e:
status['services']['redis'] = {
'status': 'unhealthy',
'error': str(e)
}
status['status'] = 'unhealthy'
# Dify API 檢查
try:
dify_client = DifyClient()
# 簡單的 API 測試呼叫
dify_client.test_connection()
status['services']['dify_api'] = {'status': 'healthy'}
except Exception as e:
status['services']['dify_api'] = {
'status': 'unhealthy',
'error': str(e)
}
# Dify API 暫時異常不影響整體狀態
# Celery 檢查
try:
from app import celery
inspect = celery.control.inspect()
stats = inspect.stats()
if stats:
status['services']['celery'] = {'status': 'healthy'}
else:
status['services']['celery'] = {
'status': 'unhealthy',
'error': 'No active workers'
}
except Exception as e:
status['services']['celery'] = {
'status': 'unhealthy',
'error': str(e)
}
status['status'] = 'unhealthy'
return jsonify(status), 200 if status['status'] == 'healthy' else 503
@health_bp.route('/metrics', methods=['GET'])
def metrics():
"""系統指標"""
from app.models import TranslationJob
# 統計任務狀態
job_stats = db.session.query(
TranslationJob.status,
db.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),
'total': sum(job_counts.values())
}
}
return jsonify(metrics_data)
10. 開發規範與標準
10.1 程式碼規範
10.1.1 Python 程式碼規範 (PEP 8)
# 檔案頭部註釋模板
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
模組描述
Author: PANJIT IT Team
Created: 2024-01-28
Modified: 2024-01-28
"""
# 匯入順序:標準庫 > 第三方庫 > 本地模組
import os
import sys
from datetime import datetime
from typing import List, Dict, Optional
from flask import Flask, request, jsonify
from sqlalchemy import Column, Integer, String
from app.models import User
from app.services import AuthService
# 常數使用大寫
MAX_FILE_SIZE = 25 * 1024 * 1024
SUPPORTED_EXTENSIONS = {'.docx', '.doc', '.pptx', '.xlsx', '.xls', '.pdf'}
# 類別命名使用 CamelCase
class TranslationService:
"""翻譯服務類別
負責處理文件翻譯相關的業務邏輯
"""
def __init__(self, api_key: str, base_url: str):
"""初始化翻譯服務
Args:
api_key: Dify API 金鑰
base_url: API 基礎 URL
"""
self.api_key = api_key
self.base_url = base_url
def translate_text(self, text: str, source_lang: str,
target_lang: str) -> Dict[str, Any]:
"""翻譯文字
Args:
text: 待翻譯文字
source_lang: 來源語言代碼
target_lang: 目標語言代碼
Returns:
Dict: 包含翻譯結果的字典
Raises:
TranslationError: 翻譯失敗時拋出
"""
# 實作邏輯
pass
# 函數命名使用 snake_case
def validate_file_extension(filename: str) -> bool:
"""驗證檔案副檔名是否支援
Args:
filename: 檔案名稱
Returns:
bool: 是否為支援的檔案類型
"""
return Path(filename).suffix.lower() in SUPPORTED_EXTENSIONS
10.1.2 前端程式碼規範 (JavaScript/Vue)
// 使用 ESLint + Prettier 配置
// .eslintrc.js
module.exports = {
extends: [
'@vue/eslint-config-standard',
'@vue/eslint-config-prettier'
],
rules: {
// 自定義規則
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
'vue/no-unused-vars': 'error'
}
}
// Vue 元件命名規範
// 使用 PascalCase
<template>
<div class="file-uploader">
<!-- 使用 kebab-case 屬性 -->
<UploadButton
:max-file-size="maxFileSize"
:allowed-types="allowedTypes"
@file-selected="handleFileSelected"
/>
</div>
</template>
<script setup>
// 使用 camelCase 變數命名
const maxFileSize = ref(25 * 1024 * 1024)
const allowedTypes = ref(['.docx', '.pdf'])
// 函數使用 camelCase
const handleFileSelected = (file) => {
// 實作邏輯
}
// 常數使用 UPPER_SNAKE_CASE
const MAX_RETRY_ATTEMPTS = 3
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
</script>
<style scoped>
/* CSS 類別使用 kebab-case */
.file-uploader {
padding: 20px;
}
.upload-button {
background-color: #409eff;
}
</style>
10.2 Git 工作流程
10.2.1 分支管理策略
# 主要分支
main # 生產版本
develop # 開發整合分支
# 功能分支命名規範
feature/auth-system # 新功能
bugfix/file-upload-error # 錯誤修復
hotfix/security-patch # 緊急修復
release/v1.0.0 # 版本發布
# 提交訊息規範
feat: 新增檔案上傳功能
fix: 修復翻譯任務佇列問題
docs: 更新 API 文件
style: 調整程式碼格式
refactor: 重構認證服務
test: 新增單元測試
chore: 更新依賴套件
10.2.2 Code Review 檢查清單
## Code Review Checklist
### 功能性檢查
- [ ] 功能是否符合需求規格
- [ ] 是否有適當的錯誤處理
- [ ] 是否有足夠的測試覆蓋
- [ ] API 介面是否符合設計規範
### 程式碼品質
- [ ] 程式碼是否清晰易讀
- [ ] 變數和函數命名是否有意義
- [ ] 是否遵循專案的程式碼規範
- [ ] 是否有適當的註釋
### 安全性檢查
- [ ] 是否有 SQL 注入風險
- [ ] 使用者輸入是否有適當驗證
- [ ] 敏感資料是否有適當保護
- [ ] 權限控制是否正確實作
### 效能考量
- [ ] 是否有不必要的資料庫查詢
- [ ] 是否有記憶體洩漏風險
- [ ] 檔案處理是否高效
- [ ] API 回應時間是否合理
### 相容性
- [ ] 是否考慮瀏覽器相容性
- [ ] 是否考慮不同檔案格式
- [ ] 錯誤處理是否友善
11. 結論與下一步
11.1 技術設計總結
本技術設計文件 (TDD) 基於 PRD 需求,設計了一個完整的企業級文件翻譯 Web 系統,主要特點包括:
- 模組化架構: 清楚分離前後端,使用現代化的技術堆疊
- 安全性: LDAP 整合認證,工作隔離,權限控制完善
- 可擴展性: Celery 非同步任務處理,支援水平擴展
- 可維護性: 結構化程式碼,完整的測試策略,詳細的文件
- 企業整合: 整合現有的 MySQL、LDAP、SMTP 環境
11.2 技術亮點
- 整合現有
document_translator_gui_with_backend.py
核心翻譯邏輯 - 使用 WebSocket 提供即時任務狀態更新
- 完善的錯誤處理與重試機制
- 自動檔案清理與成本追蹤
- 管理員專用的統計報表功能
11.3 開發里程碑
- Phase 1: 基礎架構與認證系統 (2週)
- Phase 2: 核心翻譯功能與任務佇列 (2週)
- Phase 3: 前端介面與即時更新 (2週)
- Phase 4: 管理功能與統計報表 (1週)
- Phase 5: 測試、優化與部署 (1週)
總計開發時程: 8週
11.4 風險緩解
- Dify API 不穩定: 實作完善重試機制與錯誤通知
- 檔案處理效能: 非同步處理,合理檔案大小限制
- 系統安全性: 多層次權限驗證,敏感資料保護
- 擴展性: 微服務化設計,支援未來功能擴展
文件狀態: ✅ 已完成
審核狀態: 待審核
下一步: 開始後端與前端並行開發
此 TDD 文件將作為開發團隊的技術指南,確保系統開發符合設計規範並滿足業務需求。