2284 lines
64 KiB
Markdown
2284 lines
64 KiB
Markdown
# 技術設計文件 (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 技術堆疊
|
||
```yaml
|
||
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)
|
||
```sql
|
||
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)
|
||
```sql
|
||
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)
|
||
```sql
|
||
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)
|
||
```sql
|
||
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)
|
||
```sql
|
||
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)
|
||
```sql
|
||
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 基本規範
|
||
```yaml
|
||
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 使用者登入
|
||
```http
|
||
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 使用者登出
|
||
```http
|
||
POST /api/v1/auth/logout
|
||
|
||
Response 200:
|
||
{
|
||
"success": true,
|
||
"message": "登出成功"
|
||
}
|
||
```
|
||
|
||
#### 3.2.3 取得當前使用者
|
||
```http
|
||
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 上傳檔案
|
||
```http
|
||
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 取得使用者任務列表
|
||
```http
|
||
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 取得任務詳細資訊
|
||
```http
|
||
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 重試失敗任務
|
||
```http
|
||
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 下載翻譯檔案
|
||
```http
|
||
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 取得系統統計
|
||
```http
|
||
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 取得所有使用者任務
|
||
```http
|
||
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
|
||
```javascript
|
||
// 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 任務生命週期
|
||
```mermaid
|
||
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 翻譯服務核心邏輯
|
||
```python
|
||
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 認證服務
|
||
```python
|
||
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 檔案管理服務
|
||
```python
|
||
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 檔案上傳元件
|
||
```vue
|
||
<!-- 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 任務狀態管理
|
||
```javascript
|
||
// 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 服務
|
||
```javascript
|
||
// 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 翻譯任務
|
||
```python
|
||
# 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 處理
|
||
```python
|
||
# 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 檔案結構
|
||
```bash
|
||
# 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 開發環境啟動腳本
|
||
```bash
|
||
#!/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 配置
|
||
```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 配置
|
||
```python
|
||
# 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 認證測試
|
||
```python
|
||
# 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 檔案上傳測試
|
||
```python
|
||
# 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 配置
|
||
```python
|
||
# 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 結構化日誌配置
|
||
```python
|
||
# 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 效能中介軟體
|
||
```python
|
||
# 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
|
||
```python
|
||
# 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)
|
||
```python
|
||
# 檔案頭部註釋模板
|
||
#!/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)
|
||
```javascript
|
||
// 使用 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 分支管理策略
|
||
```bash
|
||
# 主要分支
|
||
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 檢查清單
|
||
```markdown
|
||
## Code Review Checklist
|
||
|
||
### 功能性檢查
|
||
- [ ] 功能是否符合需求規格
|
||
- [ ] 是否有適當的錯誤處理
|
||
- [ ] 是否有足夠的測試覆蓋
|
||
- [ ] API 介面是否符合設計規範
|
||
|
||
### 程式碼品質
|
||
- [ ] 程式碼是否清晰易讀
|
||
- [ ] 變數和函數命名是否有意義
|
||
- [ ] 是否遵循專案的程式碼規範
|
||
- [ ] 是否有適當的註釋
|
||
|
||
### 安全性檢查
|
||
- [ ] 是否有 SQL 注入風險
|
||
- [ ] 使用者輸入是否有適當驗證
|
||
- [ ] 敏感資料是否有適當保護
|
||
- [ ] 權限控制是否正確實作
|
||
|
||
### 效能考量
|
||
- [ ] 是否有不必要的資料庫查詢
|
||
- [ ] 是否有記憶體洩漏風險
|
||
- [ ] 檔案處理是否高效
|
||
- [ ] API 回應時間是否合理
|
||
|
||
### 相容性
|
||
- [ ] 是否考慮瀏覽器相容性
|
||
- [ ] 是否考慮不同檔案格式
|
||
- [ ] 錯誤處理是否友善
|
||
```
|
||
|
||
## 11. 結論與下一步
|
||
|
||
### 11.1 技術設計總結
|
||
|
||
本技術設計文件 (TDD) 基於 PRD 需求,設計了一個完整的企業級文件翻譯 Web 系統,主要特點包括:
|
||
|
||
1. **模組化架構**: 清楚分離前後端,使用現代化的技術堆疊
|
||
2. **安全性**: LDAP 整合認證,工作隔離,權限控制完善
|
||
3. **可擴展性**: Celery 非同步任務處理,支援水平擴展
|
||
4. **可維護性**: 結構化程式碼,完整的測試策略,詳細的文件
|
||
5. **企業整合**: 整合現有的 MySQL、LDAP、SMTP 環境
|
||
|
||
### 11.2 技術亮點
|
||
|
||
- 整合現有 `document_translator_gui_with_backend.py` 核心翻譯邏輯
|
||
- 使用 WebSocket 提供即時任務狀態更新
|
||
- 完善的錯誤處理與重試機制
|
||
- 自動檔案清理與成本追蹤
|
||
- 管理員專用的統計報表功能
|
||
|
||
### 11.3 開發里程碑
|
||
|
||
1. **Phase 1**: 基礎架構與認證系統 (2週)
|
||
2. **Phase 2**: 核心翻譯功能與任務佇列 (2週)
|
||
3. **Phase 3**: 前端介面與即時更新 (2週)
|
||
4. **Phase 4**: 管理功能與統計報表 (1週)
|
||
5. **Phase 5**: 測試、優化與部署 (1週)
|
||
|
||
總計開發時程: **8週**
|
||
|
||
### 11.4 風險緩解
|
||
|
||
- **Dify API 不穩定**: 實作完善重試機制與錯誤通知
|
||
- **檔案處理效能**: 非同步處理,合理檔案大小限制
|
||
- **系統安全性**: 多層次權限驗證,敏感資料保護
|
||
- **擴展性**: 微服務化設計,支援未來功能擴展
|
||
|
||
---
|
||
|
||
**文件狀態**: ✅ 已完成
|
||
**審核狀態**: 待審核
|
||
**下一步**: 開始後端與前端並行開發
|
||
|
||
此 TDD 文件將作為開發團隊的技術指南,確保系統開發符合設計規範並滿足業務需求。 |