diff --git a/DATABASE-FAILOVER-SETUP.md b/DATABASE-FAILOVER-SETUP.md new file mode 100644 index 0000000..bfa0ac7 --- /dev/null +++ b/DATABASE-FAILOVER-SETUP.md @@ -0,0 +1,198 @@ +# 資料庫備援系統設置指南 + +## 🎯 系統概述 + +您的 AI 展示平台現在已經具備完整的資料庫備援功能!當主機資料庫出現 "Too many connections" 或其他問題時,系統會自動切換到備機資料庫,確保服務不中斷。 + +## ✅ 已完成的功能 + +1. **自動故障檢測** - 每30秒檢查資料庫健康狀態 +2. **自動切換** - 主機故障時自動切換到備機 +3. **手動切換** - 支援手動切換資料庫 +4. **資料同步** - 可將主機資料同步到備機 +5. **監控面板** - 實時監控資料庫狀態 +6. **健康檢查** - 定期檢查連接狀態 + +## 🚀 快速開始 + +### 1. 啟用備援功能 + +在您的 `.env` 文件中添加以下配置: + +```env +# ===== 主機資料庫配置 ===== +DB_HOST=mysql.theaken.com +DB_PORT=33306 +DB_NAME=db_AI_Platform +DB_USER=AI_Platform +DB_PASSWORD=Aa123456 + +# ===== 備機資料庫配置 ===== +SLAVE_DB_HOST=122.100.99.161 +SLAVE_DB_PORT=43306 +SLAVE_DB_NAME=db_nighttime_care_record +SLAVE_DB_USER=A999 +SLAVE_DB_PASSWORD=1023 + +# ===== 資料庫備援配置 ===== +DB_FAILOVER_ENABLED=true +DB_HEALTH_CHECK_INTERVAL=30000 +DB_CONNECTION_TIMEOUT=5000 +DB_RETRY_ATTEMPTS=3 +DB_RETRY_DELAY=2000 +``` + +### 2. 初始化備機資料庫 + +```bash +# 初始化備機資料庫結構 +pnpm run db:init-slave + +# 同步主機資料到備機 +pnpm run db:sync +``` + +### 3. 檢查系統狀態 + +```bash +# 檢查資料庫健康狀態 +pnpm run db:health + +# 測試備援系統 +pnpm run db:test-simple +``` + +## 📊 監控和管理 + +### 健康檢查結果 + +根據最新測試結果: + +- ❌ **主機資料庫**: 異常 (Too many connections) +- ✅ **備機資料庫**: 正常 (響應時間: 209ms) +- 🔄 **當前狀態**: 已自動切換到備機 + +### 可用命令 + +| 命令 | 功能 | 狀態 | +|------|------|------| +| `pnpm run db:health` | 檢查資料庫健康狀態 | ✅ 可用 | +| `pnpm run db:init-slave` | 初始化備機資料庫 | ✅ 已完成 | +| `pnpm run db:sync` | 同步資料 | ✅ 可用 | +| `pnpm run db:test-simple` | 測試備援系統 | ✅ 通過 | +| `pnpm run db:monitor` | 監控資料庫狀態 | ✅ 可用 | + +## 🔧 程式碼使用 + +### 基本使用 + +```typescript +import { db } from '@/lib/database'; + +// 查詢資料 (自動使用備援) +const users = await db.query('SELECT * FROM users'); + +// 插入資料 (自動使用備援) +await db.insert('INSERT INTO users (name, email) VALUES (?, ?)', ['John', 'john@example.com']); + +// 獲取備援狀態 +const status = db.getFailoverStatus(); +console.log('當前使用資料庫:', status?.currentDatabase); + +// 手動切換資料庫 +await db.switchDatabase('slave'); // 切換到備機 +await db.switchDatabase('master'); // 切換到主機 +``` + +### 監控面板 + +在管理頁面中添加監控組件: + +```typescript +import { DatabaseMonitor } from '@/components/admin/database-monitor'; + +// 在管理頁面中使用 + +``` + +## 📈 系統狀態 + +### 當前配置 + +- **備援功能**: ✅ 已啟用 +- **健康檢查間隔**: 30秒 +- **連接超時**: 5秒 +- **重試次數**: 3次 +- **重試延遲**: 2秒 + +### 測試結果 + +``` +🎉 備援系統測試完成! +當前使用資料庫: slave +⚠️ 注意:目前使用備機資料庫,建議檢查主機問題 +``` + +## 🚨 故障處理 + +### 主機資料庫問題 + +**問題**: `Too many connections` +**解決方案**: +1. 系統已自動切換到備機 +2. 檢查主機資料庫連接數限制 +3. 優化連接池配置 +4. 重啟主機資料庫服務 + +### 備機資料庫問題 + +**問題**: 備機連接失敗 +**解決方案**: +1. 檢查網路連接 +2. 驗證備機資料庫配置 +3. 確認用戶權限 +4. 檢查備機資料庫服務狀態 + +## 📋 維護建議 + +### 定期維護 + +1. **每日檢查**: 執行 `pnpm run db:health` +2. **每週同步**: 執行 `pnpm run db:sync` +3. **每月測試**: 執行 `pnpm run db:test-simple` + +### 監控指標 + +- 資料庫連接狀態 +- 響應時間 +- 錯誤率 +- 切換次數 + +## 🔄 恢復主機 + +當主機資料庫恢復後: + +1. 檢查主機狀態: `pnpm run db:health` +2. 手動切換回主機: `await db.switchDatabase('master')` +3. 重新同步資料: `pnpm run db:sync` + +## 📞 支援 + +如有問題,請檢查: + +1. 環境變數配置 +2. 網路連接狀態 +3. 資料庫服務狀態 +4. 系統日誌 +5. 監控面板狀態 + +## 🎉 總結 + +您的資料庫備援系統已經成功設置並運行!系統現在可以: + +- ✅ 自動檢測主機資料庫問題 +- ✅ 自動切換到備機資料庫 +- ✅ 提供監控和管理功能 +- ✅ 確保服務連續性 + +即使主機資料庫出現 "Too many connections" 問題,您的應用程式仍然可以正常運行! diff --git a/README-DATABASE-FAILOVER.md b/README-DATABASE-FAILOVER.md new file mode 100644 index 0000000..16fac26 --- /dev/null +++ b/README-DATABASE-FAILOVER.md @@ -0,0 +1,275 @@ +# 資料庫備援系統說明 + +## 概述 + +本系統實現了主機和備機資料庫的自動備援機制,當主機資料庫出現問題時,系統會自動切換到備機資料庫,確保服務的連續性和穩定性。 + +## 功能特點 + +- ✅ 自動故障檢測和切換 +- ✅ 健康檢查機制 +- ✅ 手動切換功能 +- ✅ 資料同步功能 +- ✅ 監控面板 +- ✅ 連接池管理 +- ✅ 重試機制 + +## 環境變數配置 + +在 `.env` 文件中添加以下配置: + +```env +# ===== 主機資料庫配置 ===== +DB_HOST=mysql.theaken.com +DB_PORT=33306 +DB_NAME=db_AI_Platform +DB_USER=AI_Platform +DB_PASSWORD=Aa123456 + +# ===== 備機資料庫配置 ===== +SLAVE_DB_HOST=122.100.99.161 +SLAVE_DB_PORT=43306 +SLAVE_DB_NAME=db_nighttime_care_record +SLAVE_DB_USER=A999 +SLAVE_DB_PASSWORD=1023 + +# ===== 資料庫備援配置 ===== +DB_FAILOVER_ENABLED=true +DB_HEALTH_CHECK_INTERVAL=30000 +DB_CONNECTION_TIMEOUT=5000 +DB_RETRY_ATTEMPTS=3 +DB_RETRY_DELAY=2000 +``` + +## 初始化步驟 + +### 1. 初始化備機資料庫 + +首先在備機上創建資料庫結構: + +```bash +# 初始化備機資料庫結構 +pnpm run db:init-slave +``` + +### 2. 同步資料 + +將主機的資料同步到備機: + +```bash +# 同步所有資料 +pnpm run db:sync +``` + +### 3. 檢查健康狀態 + +檢查主機和備機的連接狀態: + +```bash +# 檢查資料庫健康狀態 +pnpm run db:health +``` + +## 使用方法 + +### 程式碼中使用 + +系統會自動使用備援功能,無需修改現有程式碼: + +```typescript +import { db } from '@/lib/database'; + +// 查詢資料 +const users = await db.query('SELECT * FROM users'); + +// 插入資料 +await db.insert('INSERT INTO users (name, email) VALUES (?, ?)', ['John', 'john@example.com']); + +// 獲取備援狀態 +const status = db.getFailoverStatus(); +console.log('當前使用資料庫:', status?.currentDatabase); + +// 手動切換資料庫 +await db.switchDatabase('slave'); // 切換到備機 +await db.switchDatabase('master'); // 切換到主機 +``` + +### 監控面板 + +訪問管理後台查看資料庫狀態: + +```typescript +import { DatabaseMonitor } from '@/components/admin/database-monitor'; + +// 在管理頁面中使用 + +``` + +### API 端點 + +#### 獲取資料庫狀態 + +```http +GET /api/admin/database-status +``` + +回應: +```json +{ + "success": true, + "data": { + "isEnabled": true, + "currentDatabase": "master", + "masterHealthy": true, + "slaveHealthy": true, + "lastHealthCheck": "2024-01-01T12:00:00.000Z", + "consecutiveFailures": 0, + "uptime": 3600, + "timestamp": "2024-01-01T12:00:00.000Z" + } +} +``` + +#### 切換資料庫 + +```http +POST /api/admin/database-status +Content-Type: application/json + +{ + "action": "switch", + "database": "slave" +} +``` + +## 腳本命令 + +| 命令 | 說明 | +|------|------| +| `pnpm run db:init-slave` | 初始化備機資料庫結構 | +| `pnpm run db:sync` | 同步主機資料到備機 | +| `pnpm run db:health` | 檢查資料庫健康狀態 | +| `pnpm run db:monitor` | 執行健康檢查並顯示結果 | + +## 故障處理 + +### 自動切換 + +當主機資料庫出現以下問題時,系統會自動切換到備機: + +- 連接超時 +- 連接重置 +- 協議錯誤 +- 其他連接相關錯誤 + +### 手動切換 + +如果需要手動切換資料庫: + +1. 通過監控面板切換 +2. 通過 API 切換 +3. 通過程式碼切換 + +### 故障恢復 + +當主機資料庫恢復後: + +1. 系統會自動檢測到主機恢復 +2. 可以手動切換回主機 +3. 建議重新同步資料 + +## 監控和日誌 + +### 健康檢查 + +- 每 30 秒自動檢查一次 +- 檢查連接狀態和響應時間 +- 記錄連續失敗次數 + +### 日誌記錄 + +系統會記錄以下事件: + +- 資料庫切換事件 +- 連接失敗事件 +- 健康檢查結果 +- 同步操作結果 + +### 監控指標 + +- 當前使用的資料庫 +- 主機和備機的健康狀態 +- 最後檢查時間 +- 連續失敗次數 +- 系統運行時間 + +## 注意事項 + +1. **資料一致性**:備機資料可能不是實時同步的,建議定期同步 +2. **性能影響**:備援機制會增加少量性能開銷 +3. **網路依賴**:需要確保主機和備機之間的網路連接穩定 +4. **權限配置**:確保備機資料庫有足夠的權限進行操作 + +## 故障排除 + +### 常見問題 + +1. **備機連接失敗** + - 檢查網路連接 + - 驗證資料庫配置 + - 確認用戶權限 + +2. **同步失敗** + - 檢查表結構是否一致 + - 確認資料庫權限 + - 查看錯誤日誌 + +3. **自動切換不工作** + - 確認 `DB_FAILOVER_ENABLED=true` + - 檢查健康檢查間隔設定 + - 查看系統日誌 + +### 日誌位置 + +- 應用程式日誌:控制台輸出 +- 資料庫日誌:MySQL 錯誤日誌 +- 系統日誌:系統日誌文件 + +## 最佳實踐 + +1. **定期備份**:即使有備援機制,仍建議定期備份資料 +2. **監控告警**:設定監控告警,及時發現問題 +3. **測試切換**:定期測試備援切換功能 +4. **性能監控**:監控資料庫性能指標 +5. **文檔更新**:保持配置文檔的更新 + +## 技術架構 + +``` +┌─────────────────┐ ┌─────────────────┐ +│ 應用程式 │ │ 應用程式 │ +└─────────┬───────┘ └─────────┬───────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ 資料庫服務 │ │ 備援服務 │ +│ (主機) │ │ (備機) │ +└─────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ MySQL 主機 │ │ MySQL 備機 │ +│ mysql.theaken │ │ 122.100.99.161│ +│ .com:33306 │ │ :43306 │ +└─────────────────┘ └─────────────────┘ +``` + +## 支援 + +如有問題,請檢查: + +1. 環境變數配置 +2. 網路連接狀態 +3. 資料庫服務狀態 +4. 系統日誌 +5. 監控面板狀態 diff --git a/app/api/admin/database-status/route.ts b/app/api/admin/database-status/route.ts new file mode 100644 index 0000000..e715244 --- /dev/null +++ b/app/api/admin/database-status/route.ts @@ -0,0 +1,92 @@ +// ===================================================== +// 資料庫狀態監控 API +// ===================================================== + +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/database'; + +export async function GET(request: NextRequest) { + try { + // 獲取備援狀態 + const failoverStatus = db.getFailoverStatus(); + + if (!failoverStatus) { + return NextResponse.json({ + success: false, + message: '備援功能未啟用', + data: null + }); + } + + // 獲取詳細狀態信息 + const status = { + isEnabled: failoverStatus.isEnabled, + currentDatabase: failoverStatus.currentDatabase, + masterHealthy: failoverStatus.masterHealthy, + slaveHealthy: failoverStatus.slaveHealthy, + lastHealthCheck: failoverStatus.lastHealthCheck && failoverStatus.lastHealthCheck > 0 + ? new Date(failoverStatus.lastHealthCheck).toISOString() + : new Date().toISOString(), // 如果沒有健康檢查記錄,使用當前時間 + consecutiveFailures: failoverStatus.consecutiveFailures, + uptime: process.uptime(), + timestamp: new Date().toISOString() + }; + + return NextResponse.json({ + success: true, + message: '資料庫狀態獲取成功', + data: status + }); + + } catch (error) { + console.error('獲取資料庫狀態失敗:', error); + return NextResponse.json({ + success: false, + message: '獲取資料庫狀態失敗', + error: error instanceof Error ? error.message : '未知錯誤' + }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { action, database } = body; + + if (action === 'switch') { + if (!database || !['master', 'slave'].includes(database)) { + return NextResponse.json({ + success: false, + message: '無效的資料庫參數' + }, { status: 400 }); + } + + const success = await db.switchDatabase(database as 'master' | 'slave'); + + if (success) { + return NextResponse.json({ + success: true, + message: `已切換到 ${database} 資料庫` + }); + } else { + return NextResponse.json({ + success: false, + message: `切換到 ${database} 資料庫失敗` + }, { status: 400 }); + } + } + + return NextResponse.json({ + success: false, + message: '無效的操作' + }, { status: 400 }); + + } catch (error) { + console.error('執行資料庫操作失敗:', error); + return NextResponse.json({ + success: false, + message: '執行資料庫操作失敗', + error: error instanceof Error ? error.message : '未知錯誤' + }, { status: 500 }); + } +} diff --git a/app/api/admin/database-sync/route.ts b/app/api/admin/database-sync/route.ts new file mode 100644 index 0000000..b3e3d09 --- /dev/null +++ b/app/api/admin/database-sync/route.ts @@ -0,0 +1,73 @@ +// ===================================================== +// 資料庫同步管理 API +// ===================================================== + +import { NextRequest, NextResponse } from 'next/server'; +import { dbSync } from '@/lib/database-sync'; + +// 獲取同步狀態 +export async function GET(request: NextRequest) { + try { + const status = await dbSync.getSyncStatus(); + + return NextResponse.json({ + success: true, + message: '同步狀態獲取成功', + data: status + }); + + } catch (error) { + console.error('獲取同步狀態失敗:', error); + return NextResponse.json({ + success: false, + message: '獲取同步狀態失敗', + error: error instanceof Error ? error.message : '未知錯誤' + }, { status: 500 }); + } +} + +// 執行同步操作 +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { action, tableName, condition } = body; + + switch (action) { + case 'sync_table': + if (!tableName) { + return NextResponse.json({ + success: false, + message: '缺少表名參數' + }, { status: 400 }); + } + + const syncResult = await dbSync.syncFromMasterToSlave(tableName, condition); + + if (syncResult) { + return NextResponse.json({ + success: true, + message: `成功同步表 ${tableName} 到備機` + }); + } else { + return NextResponse.json({ + success: false, + message: `同步表 ${tableName} 失敗` + }, { status: 500 }); + } + + default: + return NextResponse.json({ + success: false, + message: '無效的操作' + }, { status: 400 }); + } + + } catch (error) { + console.error('執行同步操作失敗:', error); + return NextResponse.json({ + success: false, + message: '執行同步操作失敗', + error: error instanceof Error ? error.message : '未知錯誤' + }, { status: 500 }); + } +} diff --git a/app/api/admin/users/list/route.ts b/app/api/admin/users/list/route.ts index 7214bd2..b25b17c 100644 --- a/app/api/admin/users/list/route.ts +++ b/app/api/admin/users/list/route.ts @@ -14,24 +14,19 @@ export async function GET(request: NextRequest) { const users = await userService.query(sql); - // 為每個用戶獲取統計數據 - const usersWithStats = await Promise.all( - users.map(async (user) => { - const stats = await userService.getUserAppAndReviewStats(user.id); - return { - id: user.id, - name: user.name, - email: user.email, - department: user.department, - role: user.role, - status: user.status, - createdAt: user.created_at, - lastLogin: user.last_login, - appCount: stats.appCount, - reviewCount: stats.reviewCount - }; - }) - ); + // 格式化用戶數據 + const usersWithStats = users.map((user) => { + return { + id: user.id, + name: user.name, + email: user.email, + department: user.department, + role: user.role, + status: user.status, + createdAt: user.created_at, + lastLogin: user.last_login + }; + }); return NextResponse.json({ success: true, diff --git a/app/api/apps/[id]/stats/route.ts b/app/api/apps/[id]/stats/route.ts index 66e6a25..f411729 100644 --- a/app/api/apps/[id]/stats/route.ts +++ b/app/api/apps/[id]/stats/route.ts @@ -8,9 +8,10 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri const { id: appId } = await params const { searchParams } = new URL(request.url) - // 獲取日期範圍參數 + // 獲取日期範圍和部門過濾參數 const startDate = searchParams.get('startDate') const endDate = searchParams.get('endDate') + const department = searchParams.get('department') // 獲取應用基本統計 const app = await appService.getAppById(appId) @@ -24,8 +25,8 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri // 獲取評分統計 const ratingStats = await appService.getAppRatingStats(appId) - // 獲取使用趨勢數據(支援日期範圍) - const usageStats = await appService.getAppUsageStats(appId, startDate || undefined, endDate || undefined) + // 獲取使用趨勢數據(支援日期範圍和部門過濾) + const usageStats = await appService.getAppUsageStats(appId, startDate || undefined, endDate || undefined, department || undefined) // 獲取收藏數量 const favoritesCount = await appService.getAppFavoritesCount(appId) diff --git a/components/admin/database-monitor.tsx b/components/admin/database-monitor.tsx new file mode 100644 index 0000000..fe18b33 --- /dev/null +++ b/components/admin/database-monitor.tsx @@ -0,0 +1,303 @@ +'use client'; + +// ===================================================== +// 資料庫監控組件 +// ===================================================== + +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { RefreshCw, Database, Server, AlertTriangle, CheckCircle } from 'lucide-react'; + +interface DatabaseStatus { + isEnabled: boolean; + currentDatabase: 'master' | 'slave'; + masterHealthy: boolean; + slaveHealthy: boolean; + lastHealthCheck: string; + consecutiveFailures: number; + uptime: number; + timestamp: string; +} + +interface DatabaseMonitorProps { + className?: string; +} + +export function DatabaseMonitor({ className }: DatabaseMonitorProps) { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [switching, setSwitching] = useState(false); + + // 獲取資料庫狀態 + const fetchStatus = async () => { + try { + setLoading(true); + setError(null); + + const response = await fetch('/api/admin/database-status'); + const data = await response.json(); + + if (data.success) { + setStatus(data.data); + } else { + setError(data.message); + } + } catch (err) { + setError('無法連接到監控服務'); + console.error('獲取資料庫狀態失敗:', err); + } finally { + setLoading(false); + } + }; + + // 切換資料庫 + const switchDatabase = async (database: 'master' | 'slave') => { + try { + setSwitching(true); + + const response = await fetch('/api/admin/database-status', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'switch', + database: database + }) + }); + + const data = await response.json(); + + if (data.success) { + // 重新獲取狀態 + await fetchStatus(); + } else { + setError(data.message); + } + } catch (err) { + setError('切換資料庫失敗'); + console.error('切換資料庫失敗:', err); + } finally { + setSwitching(false); + } + }; + + // 格式化時間 + const formatTime = (timestamp: string) => { + return new Date(timestamp).toLocaleString('zh-TW'); + }; + + // 格式化運行時間 + const formatUptime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + return `${hours}時${minutes}分${secs}秒`; + }; + + // 組件掛載時獲取狀態 + useEffect(() => { + fetchStatus(); + + // 每30秒自動刷新 + const interval = setInterval(fetchStatus, 30000); + + return () => clearInterval(interval); + }, []); + + if (loading) { + return ( + + + + + 資料庫監控 + + + +
+ + 載入中... +
+
+
+ ); + } + + if (error) { + return ( + + + + + 資料庫監控 + + + + + + {error} + + + + + ); + } + + if (!status) { + return ( + + + + + 資料庫監控 + + + +

無法獲取資料庫狀態

+
+
+ ); + } + + return ( + + + +
+ + 資料庫監控 +
+ +
+ + 監控主機和備機資料庫的連接狀態 + +
+ + {/* 整體狀態 */} +
+ 備援狀態 + + {status.isEnabled ? "啟用" : "停用"} + +
+ + {/* 當前資料庫 */} +
+ 當前資料庫 + + {status.currentDatabase === 'master' ? "主機" : "備機"} + +
+ + {/* 主機狀態 */} +
+
+
+ + 主機資料庫 +
+
+ {status.masterHealthy ? ( + + ) : ( + + )} + + {status.masterHealthy ? "正常" : "異常"} + +
+
+ {status.masterHealthy && ( + + )} +
+ + {/* 備機狀態 */} +
+
+
+ + 備機資料庫 +
+
+ {status.slaveHealthy ? ( + + ) : ( + + )} + + {status.slaveHealthy ? "正常" : "異常"} + +
+
+ {status.slaveHealthy && ( + + )} +
+ + {/* 統計信息 */} +
+
+ 連續失敗次數 + {status.consecutiveFailures} +
+
+ 最後檢查時間 + {formatTime(status.lastHealthCheck)} +
+
+ 系統運行時間 + {formatUptime(status.uptime)} +
+
+ + {/* 警告信息 */} + {status.consecutiveFailures > 0 && ( + + + + 資料庫已連續失敗 {status.consecutiveFailures} 次,請檢查連接狀態 + + + )} +
+
+ ); +} diff --git a/components/admin/system-settings.tsx b/components/admin/system-settings.tsx index fecf3dc..b7b251b 100644 --- a/components/admin/system-settings.tsx +++ b/components/admin/system-settings.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import React, { useState } from "react" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -25,6 +25,11 @@ import { CheckCircle, HardDrive, Clock, + Database, + RefreshCw, + AlertCircle, + CheckCircle2, + XCircle, Globe, } from "lucide-react" @@ -71,6 +76,12 @@ export function SystemSettings() { const [activeTab, setActiveTab] = useState("general") const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle") const [showSmtpPassword, setShowSmtpPassword] = useState(false) + + // 資料庫狀態 + const [databaseStatus, setDatabaseStatus] = useState(null) + const [isLoadingStatus, setIsLoadingStatus] = useState(false) + const [syncStatus, setSyncStatus] = useState(null) + const [isLoadingSync, setIsLoadingSync] = useState(false) const handleSave = async () => { setSaveStatus("saving") @@ -90,6 +101,99 @@ export function SystemSettings() { setSettings((prev) => ({ ...prev, [key]: value })) } + // 獲取資料庫狀態 + const fetchDatabaseStatus = async () => { + setIsLoadingStatus(true) + try { + const response = await fetch('/api/admin/database-status') + const data = await response.json() + if (data.success) { + setDatabaseStatus(data.data) + } else { + console.error('獲取資料庫狀態失敗:', data.error) + } + } catch (error) { + console.error('獲取資料庫狀態失敗:', error) + } finally { + setIsLoadingStatus(false) + } + } + + // 切換資料庫 + const switchDatabase = async (database: 'master' | 'slave') => { + try { + const response = await fetch('/api/admin/database-status', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ action: 'switch', database }), + }) + const data = await response.json() + if (data.success) { + // 重新獲取狀態 + await fetchDatabaseStatus() + alert(data.message) + } else { + alert(`切換失敗: ${data.message}`) + } + } catch (error) { + console.error('切換資料庫失敗:', error) + alert('切換資料庫失敗') + } + } + + // 獲取同步狀態 + const fetchSyncStatus = async () => { + setIsLoadingSync(true) + try { + const response = await fetch('/api/admin/database-sync') + const data = await response.json() + if (data.success) { + setSyncStatus(data.data) + } else { + console.error('獲取同步狀態失敗:', data.error) + } + } catch (error) { + console.error('獲取同步狀態失敗:', error) + } finally { + setIsLoadingSync(false) + } + } + + // 同步表資料 + const syncTable = async (tableName: string) => { + try { + const response = await fetch('/api/admin/database-sync', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'sync_table', + tableName: tableName + }) + }) + const data = await response.json() + if (data.success) { + alert(`成功同步表 ${tableName} 到備機`) + } else { + alert(`同步表 ${tableName} 失敗: ${data.message}`) + } + } catch (error) { + console.error('同步表失敗:', error) + alert('同步表失敗') + } + } + + // 組件載入時獲取資料庫狀態 + React.useEffect(() => { + if (activeTab === 'performance') { + fetchDatabaseStatus() + fetchSyncStatus() + } + }, [activeTab]) + return (
@@ -383,6 +487,243 @@ export function SystemSettings() {
+ + {/* 資料庫狀態監控 */} + + +
+ + + 資料庫狀態監控 + + +
+
+ + {databaseStatus ? ( + <> + {/* 備援狀態概覽 */} +
+
+ 備援系統狀態 + + {databaseStatus.isEnabled ? "已啟用" : "已停用"} + +
+
+ 當前使用: {databaseStatus.currentDatabase === 'master' ? '主機' : '備機'} | + 連續失敗: {databaseStatus.consecutiveFailures} 次 +
+
+ 最後檢查: {new Date(databaseStatus.lastHealthCheck).toLocaleString()} +
+
+ + {/* 主機資料庫 */} +
+
+
+ + 主機資料庫 + {databaseStatus.currentDatabase === 'master' && ( + + 當前使用 + + )} +
+
+ {databaseStatus.masterHealthy ? ( + + ) : ( + + )} + + {databaseStatus.masterHealthy ? '正常' : '故障'} + +
+
+
+
+ 主機: + mysql.theaken.com:33306 +
+
+ 資料庫: + db_AI_Platform +
+
+ {databaseStatus.currentDatabase !== 'master' && databaseStatus.masterHealthy && ( + + )} +
+ + {/* 備機資料庫 */} +
+
+
+ + 備機資料庫 + {databaseStatus.currentDatabase === 'slave' && ( + + 當前使用 + + )} +
+
+ {databaseStatus.slaveHealthy ? ( + + ) : ( + + )} + + {databaseStatus.slaveHealthy ? '正常' : '故障'} + +
+
+
+
+ 主機: + 122.100.99.161:43306 +
+
+ 資料庫: + db_AI_Platform +
+
+ {databaseStatus.currentDatabase !== 'slave' && databaseStatus.slaveHealthy && ( + + )} +
+ + {/* 狀態說明 */} +
+
+ +
+

注意事項:

+
    +
  • • 系統會自動在健康檢查時切換資料庫
  • +
  • • 手動切換僅在目標資料庫健康時有效
  • +
  • • 建議在維護時手動切換到備機
  • +
+
+
+
+ + ) : ( +
+ +

+ {isLoadingStatus ? '載入資料庫狀態中...' : '點擊重新整理按鈕載入資料庫狀態'} +

+
+ )} +
+
+ + {/* 資料庫同步管理 */} + + +
+ + + 資料庫同步管理 + + +
+
+ + {syncStatus ? ( + <> + {/* 同步狀態概覽 */} +
+
+ 雙寫同步狀態 + + {syncStatus.enabled ? "已啟用" : "未啟用"} + +
+
+

主機健康: {syncStatus.masterHealthy ? "正常" : "異常"}

+

備機健康: {syncStatus.slaveHealthy ? "正常" : "異常"}

+ {syncStatus.lastSyncTime && ( +

最後同步: {new Date(syncStatus.lastSyncTime).toLocaleString()}

+ )} +
+
+ + {/* 手動同步操作 */} +
+

手動同步表資料

+
+ {['users', 'apps', 'teams', 'competitions', 'proposals', 'activity_logs'].map((tableName) => ( + + ))} +
+
+ + {/* 同步說明 */} +
+
+ +
+

同步說明:

+
    +
  • • 手動同步會將主機資料複製到備機
  • +
  • • 建議在維護期間進行同步操作
  • +
  • • 同步過程中請避免對資料進行修改
  • +
+
+
+
+ + ) : ( +
+ +

+ {isLoadingSync ? '載入同步狀態中...' : '點擊重新整理按鈕載入同步狀態'} +

+
+ )} +
+
{/* 用戶管理 */} diff --git a/components/admin/user-management.tsx b/components/admin/user-management.tsx index a6889eb..d0a50db 100644 --- a/components/admin/user-management.tsx +++ b/components/admin/user-management.tsx @@ -858,7 +858,6 @@ export function UserManagement() { 狀態 加入日期 最後登入 - 統計 操作 @@ -901,12 +900,6 @@ export function UserManagement() { {user.joinDate || "-"} {user.lastLogin || "-"} - -
-

{user.appCount || 0} 應用

-

{user.reviewCount || 0} 評價

-
-
diff --git a/components/app-detail-dialog.tsx b/components/app-detail-dialog.tsx index 0634a70..edf27b3 100644 --- a/components/app-detail-dialog.tsx +++ b/components/app-detail-dialog.tsx @@ -42,6 +42,7 @@ import { } from "lucide-react" import { FavoriteButton } from "./favorite-button" import { ReviewSystem } from "./reviews/review-system" +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts" interface AppDetailDialogProps { open: boolean @@ -93,6 +94,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp } }) const [isLoadingStats, setIsLoadingStats] = useState(false) + const [selectedDepartment, setSelectedDepartment] = useState(null) // Date range for usage trends const [startDate, setStartDate] = useState(() => { @@ -104,6 +106,9 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp return new Date().toISOString().split("T")[0] }) + // 圓餅圖顏色配置 + const COLORS = ['#3b82f6', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#84cc16', '#f97316'] + // 圖標映射函數 const getIconComponent = (iconName: string) => { const iconMap: { [key: string]: any } = { @@ -152,7 +157,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp } // 載入應用統計數據 - const loadAppStats = useCallback(async (customStartDate?: string, customEndDate?: string) => { + const loadAppStats = useCallback(async (customStartDate?: string, customEndDate?: string, department?: string) => { if (!app.id) return setIsLoadingStats(true) @@ -161,6 +166,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp const params = new URLSearchParams() if (customStartDate) params.append('startDate', customStartDate) if (customEndDate) params.append('endDate', customEndDate) + if (department) params.append('department', department) const url = `/api/apps/${app.id}/stats${params.toString() ? `?${params.toString()}` : ''}` const response = await fetch(url) @@ -189,7 +195,22 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp // 處理日期範圍變更 const handleDateRangeChange = useCallback(async () => { if (startDate && endDate) { - await loadAppStats(startDate, endDate) + await loadAppStats(startDate, endDate, selectedDepartment || undefined) + } + }, [startDate, endDate, selectedDepartment, loadAppStats]) + + // 處理日期變更時重置部門選擇 + const handleDateChange = useCallback((newStartDate: string, newEndDate: string) => { + setStartDate(newStartDate) + setEndDate(newEndDate) + setSelectedDepartment(null) // 重置部門選擇 + }, []) + + // 處理部門選擇 + const handleDepartmentSelect = useCallback(async (department: string | null) => { + setSelectedDepartment(department) + if (startDate && endDate) { + await loadAppStats(startDate, endDate, department || undefined) } }, [startDate, endDate, loadAppStats]) @@ -543,211 +564,315 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
- {/* Usage Trends with Date Range */} + {/* Date Range Filter */} -
-
- 使用趨勢 - 查看指定時間範圍內的使用者活躍度 -
-
+ 數據篩選 + 選擇日期範圍查看部門使用分布和使用趨勢 + + +
+
-
-
-
+
- - -
+ + + + {/* Analytics Layout */} +
+ {/* Department Usage Pie Chart */} + + + 部門使用分布 + 點擊部門查看該部門的使用趨勢 + + {isLoadingStats ? (
-

載入使用趨勢數據中...

+

載入部門數據中...

- ) : usageStats.trendData && usageStats.trendData.length > 0 ? ( - <> - {/* Chart Container with Horizontal Scroll */} -
-
- {/* Month/Year Section Headers */} -
- {(() => { - const sections = getDateSections(usageStats.trendData) - const totalBars = usageStats.trendData.length - - return Object.entries(sections).map(([key, section]) => { - const width = ((section.endIndex - section.startIndex + 1) / totalBars) * 100 - const left = (section.startIndex / totalBars) * 100 - - return ( -
- {section.label} -
- ) - }) - })()} -
- - {/* Chart Bars */} -
- {usageStats.trendData.map((day: any, index: number) => { - const maxUsers = Math.max(...usageStats.trendData.map((d: any) => d.users)) - const minUsers = Math.min(...usageStats.trendData.map((d: any) => d.users)) - const range = maxUsers - minUsers - const normalizedHeight = range > 0 ? ((day.users - minUsers) / range) * 70 + 15 : 40 - - const currentDate = new Date(day.date) - const prevDate = index > 0 ? new Date((usageStats.trendData[index - 1] as any).date) : null - - // Check if this is the start of a new month/year for divider - const isNewMonth = - !prevDate || - currentDate.getMonth() !== prevDate.getMonth() || - currentDate.getFullYear() !== prevDate.getFullYear() - - return ( -
- {/* Month divider line */} - {isNewMonth && index > 0 && ( -
- )} - -
-
- {/* Value label */} -
- {day.users} -
-
-
- - {/* Consistent day-only labels */} -
{currentDate.getDate()}日
-
- ) - })} -
-
+ ) : usageStats.topDepartments && usageStats.topDepartments.length > 0 ? ( +
+ + + ({ + name: dept.department || '未知部門', + value: dept.count, + color: COLORS[index % COLORS.length] + }))} + cx="50%" + cy="50%" + labelLine={false} + label={({ name, percent }) => `${name} ${((percent || 0) * 100).toFixed(0)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + onClick={(data) => { + const department = data.name === '未知部門' ? null : data.name + handleDepartmentSelect(department) + }} + className="cursor-pointer" + > + {usageStats.topDepartments.map((dept: any, index: number) => ( + + ))} + + [`${value} 人`, '使用人數']} /> + + + + {/* Department Legend */} +
+ {usageStats.topDepartments.map((dept: any, index: number) => { + const totalUsers = usageStats.topDepartments.reduce((sum: number, d: any) => sum + d.count, 0) + const percentage = totalUsers > 0 ? Math.round((dept.count / totalUsers) * 100) : 0 + const isSelected = selectedDepartment === dept.department + + return ( +
{ + const department = dept.department === '未知部門' ? null : dept.department + handleDepartmentSelect(department) + }} + > +
+
+ {dept.department || '未知部門'} +
+
+ {dept.count} 人 + {percentage}% +
+
+ ) + })}
- - {/* Scroll Hint */} - {usageStats.trendData && usageStats.trendData.length > 20 && ( -
💡 提示:圖表可左右滑動查看更多數據
- )} - +
) : (
- -

在選定的日期範圍內暫無使用數據

-

請嘗試選擇其他日期範圍

+ +

暫無部門使用數據

)} -
- - + + - {/* Department Usage */} - - - 部門使用分布 - 各部門使用者比例 - - -
- {usageStats.topDepartments && usageStats.topDepartments.length > 0 ? ( - usageStats.topDepartments.map((dept: any, index: number) => { - const totalUsers = usageStats.topDepartments.reduce((sum: number, d: any) => sum + d.count, 0) - const percentage = totalUsers > 0 ? Math.round((dept.count / totalUsers) * 100) : 0 - - return ( -
-
-
- {dept.department || '未知部門'} -
-
- {dept.count} 人 - {percentage}% + {/* Usage Trends */} + + + 使用趨勢 + + {selectedDepartment ? `${selectedDepartment} 部門的使用趨勢` : '查看指定時間範圍內的使用者活躍度'} + + + +
+ {isLoadingStats ? ( +
+
+
+

載入使用趨勢數據中...

+
+
+ ) : usageStats.trendData && usageStats.trendData.length > 0 ? ( + <> + {/* Chart Container with Horizontal Scroll */} +
+
+ {/* Month/Year Section Headers - Full Width */} +
+ {(() => { + const sections = getDateSections(usageStats.trendData) + const totalBars = usageStats.trendData.length + const barWidth = 60 // 每個柱子寬度 + const barGap = 12 // 柱子間距 + const chartLeft = 20 // paddingLeft + const totalChartWidth = totalBars * barWidth + (totalBars - 1) * barGap + + return Object.entries(sections).map(([key, section]) => { + // 計算該月份在柱狀圖中的實際位置 + const sectionStartBar = section.startIndex + const sectionEndBar = section.endIndex + const sectionBarCount = sectionEndBar - sectionStartBar + 1 + + // 計算該月份標籤的起始位置(相對於圖表區域) + const sectionLeft = sectionStartBar * (barWidth + barGap) + const sectionWidth = sectionBarCount * barWidth + (sectionBarCount - 1) * barGap + + // 轉換為相對於整個容器的百分比(從左邊界開始) + const containerWidth = chartLeft + totalChartWidth + const leftPercent = ((sectionLeft + chartLeft) / containerWidth) * 100 + const widthPercent = (sectionWidth / containerWidth) * 100 + + return ( +
+ {section.label} +
+ ) + }) + })()} +
+ + {/* Y-axis labels and grid lines */} +
+ {Math.max(...usageStats.trendData.map((d: any) => d.users))} + {Math.round(Math.max(...usageStats.trendData.map((d: any) => d.users)) * 0.75)} + {Math.round(Math.max(...usageStats.trendData.map((d: any) => d.users)) * 0.5)} + {Math.round(Math.max(...usageStats.trendData.map((d: any) => d.users)) * 0.25)} + 0 +
+ + {/* Grid lines */} +
+ {[0, 0.25, 0.5, 0.75, 1].map((ratio, index) => ( +
+ ))} +
+ + {/* Chart Bars */} +
+ {usageStats.trendData.map((day: any, index: number) => { + const maxUsers = Math.max(...usageStats.trendData.map((d: any) => d.users)) + const minUsers = Math.min(...usageStats.trendData.map((d: any) => d.users)) + const range = maxUsers - minUsers + const normalizedHeight = range > 0 ? ((day.users - minUsers) / range) * 70 + 15 : 40 + + const currentDate = new Date(day.date) + const prevDate = index > 0 ? new Date((usageStats.trendData[index - 1] as any).date) : null + + // Check if this is the start of a new month/year for divider + const isNewMonth = + !prevDate || + currentDate.getMonth() !== prevDate.getMonth() || + currentDate.getFullYear() !== prevDate.getFullYear() + + return ( +
+ {/* Month divider line */} + {isNewMonth && index > 0 && ( +
+ )} + +
+
+ {/* Value label */} +
+ {day.users} +
+
+
+ + {/* Consistent day-only labels */} +
{currentDate.getDate()}日
+
+ ) + })} +
- ) - }) - ) : ( -
- -

暫無部門使用數據

-
- )} -
- - + + {/* Scroll Hint */} + {usageStats.trendData && usageStats.trendData.length > 20 && ( +
💡 提示:圖表可左右滑動查看更多數據
+ )} + + ) : ( +
+
+ +

在選定的日期範圍內暫無使用數據

+

請嘗試選擇其他日期範圍

+
+
+ )} +
+
+
+
diff --git a/database-password-reset.sql b/database-password-reset.sql deleted file mode 100644 index 45a299d..0000000 --- a/database-password-reset.sql +++ /dev/null @@ -1,21 +0,0 @@ --- ===================================================== --- 密碼重設功能資料表 --- ===================================================== - -USE `db_AI_Platform`; - --- 密碼重設 tokens 表 -CREATE TABLE IF NOT EXISTS `password_reset_tokens` ( - `id` VARCHAR(36) PRIMARY KEY, - `user_id` VARCHAR(36) NOT NULL, - `token` VARCHAR(255) NOT NULL UNIQUE, - `expires_at` TIMESTAMP NOT NULL, - `used_at` TIMESTAMP NULL, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - INDEX `idx_user_id` (`user_id`), - INDEX `idx_token` (`token`), - INDEX `idx_expires_at` (`expires_at`), - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database-tables-only.sql b/database-tables-only.sql deleted file mode 100644 index de67956..0000000 --- a/database-tables-only.sql +++ /dev/null @@ -1,441 +0,0 @@ --- AI 展示平台資料庫表結構 --- 資料庫: MySQL --- 主機: mysql.theaken.com --- 端口: 33306 --- 資料庫名: db_AI_Platform --- 用戶: AI_Platform --- 密碼: Aa123456 - --- 創建資料庫 -CREATE DATABASE IF NOT EXISTS `db_AI_Platform` -CHARACTER SET utf8mb4 -COLLATE utf8mb4_unicode_ci; - -USE `db_AI_Platform`; - --- 1. 用戶表 -CREATE TABLE `users` ( - `id` VARCHAR(36) PRIMARY KEY, - `name` VARCHAR(100) NOT NULL, - `email` VARCHAR(255) UNIQUE NOT NULL, - `password_hash` VARCHAR(255) NOT NULL, - `avatar` VARCHAR(500) NULL, - `department` VARCHAR(100) NOT NULL, - `role` ENUM('user', 'developer', 'admin') DEFAULT 'user', - `join_date` DATE NOT NULL, - `total_likes` INT DEFAULT 0, - `total_views` INT DEFAULT 0, - `is_active` BOOLEAN DEFAULT TRUE, - `last_login` TIMESTAMP NULL, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX `idx_email` (`email`), - INDEX `idx_department` (`department`), - INDEX `idx_role` (`role`), - INDEX `idx_join_date` (`join_date`) -); - --- 2. 評審表 -CREATE TABLE `judges` ( - `id` VARCHAR(36) PRIMARY KEY, - `name` VARCHAR(100) NOT NULL, - `title` VARCHAR(100) NOT NULL, - `department` VARCHAR(100) NOT NULL, - `expertise` JSON NOT NULL, - `avatar` VARCHAR(500) NULL, - `is_active` BOOLEAN DEFAULT TRUE, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX `idx_department` (`department`), - INDEX `idx_is_active` (`is_active`) -); - --- 3. 團隊表 -CREATE TABLE `teams` ( - `id` VARCHAR(36) PRIMARY KEY, - `name` VARCHAR(200) NOT NULL, - `leader_id` VARCHAR(36) NOT NULL, - `department` VARCHAR(100) NOT NULL, - `contact_email` VARCHAR(255) NOT NULL, - `total_likes` INT DEFAULT 0, - `is_active` BOOLEAN DEFAULT TRUE, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (`leader_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - INDEX `idx_leader` (`leader_id`), - INDEX `idx_department` (`department`), - INDEX `idx_is_active` (`is_active`) -); - --- 4. 團隊成員表 -CREATE TABLE `team_members` ( - `id` VARCHAR(36) PRIMARY KEY, - `team_id` VARCHAR(36) NOT NULL, - `user_id` VARCHAR(36) NOT NULL, - `role` VARCHAR(50) NOT NULL DEFAULT 'member', - `joined_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`team_id`) REFERENCES `teams`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - UNIQUE KEY `unique_team_user` (`team_id`, `user_id`), - INDEX `idx_team` (`team_id`), - INDEX `idx_user` (`user_id`) -); - --- 5. 競賽表 -CREATE TABLE `competitions` ( - `id` VARCHAR(36) PRIMARY KEY, - `name` VARCHAR(200) NOT NULL, - `year` INT NOT NULL, - `month` INT NOT NULL, - `start_date` DATE NOT NULL, - `end_date` DATE NOT NULL, - `status` ENUM('upcoming', 'active', 'judging', 'completed') DEFAULT 'upcoming', - `description` TEXT, - `type` ENUM('individual', 'team', 'mixed', 'proposal') NOT NULL, - `evaluation_focus` TEXT, - `max_team_size` INT NULL, - `is_active` BOOLEAN DEFAULT TRUE, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX `idx_year_month` (`year`, `month`), - INDEX `idx_status` (`status`), - INDEX `idx_type` (`type`), - INDEX `idx_dates` (`start_date`, `end_date`) -); - --- 6. 競賽評審關聯表 -CREATE TABLE `competition_judges` ( - `id` VARCHAR(36) PRIMARY KEY, - `competition_id` VARCHAR(36) NOT NULL, - `judge_id` VARCHAR(36) NOT NULL, - `assigned_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`competition_id`) REFERENCES `competitions`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`judge_id`) REFERENCES `judges`(`id`) ON DELETE CASCADE, - UNIQUE KEY `unique_competition_judge` (`competition_id`, `judge_id`), - INDEX `idx_competition` (`competition_id`), - INDEX `idx_judge` (`judge_id`) -); - --- 7. 競賽規則表 -CREATE TABLE `competition_rules` ( - `id` VARCHAR(36) PRIMARY KEY, - `competition_id` VARCHAR(36) NOT NULL, - `name` VARCHAR(200) NOT NULL, - `description` TEXT, - `weight` DECIMAL(5,2) NOT NULL DEFAULT 0.00, - `order_index` INT DEFAULT 0, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`competition_id`) REFERENCES `competitions`(`id`) ON DELETE CASCADE, - INDEX `idx_competition` (`competition_id`), - INDEX `idx_order` (`order_index`) -); - --- 8. 競賽獎項類型表 -CREATE TABLE `competition_award_types` ( - `id` VARCHAR(36) PRIMARY KEY, - `competition_id` VARCHAR(36) NOT NULL, - `name` VARCHAR(200) NOT NULL, - `description` TEXT, - `icon` VARCHAR(50) NOT NULL, - `color` VARCHAR(20) NOT NULL, - `is_active` BOOLEAN DEFAULT TRUE, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`competition_id`) REFERENCES `competitions`(`id`) ON DELETE CASCADE, - INDEX `idx_competition` (`competition_id`), - INDEX `idx_is_active` (`is_active`) -); - --- 9. 應用表 -CREATE TABLE `apps` ( - `id` VARCHAR(36) PRIMARY KEY, - `name` VARCHAR(200) NOT NULL, - `description` TEXT, - `creator_id` VARCHAR(36) NOT NULL, - `team_id` VARCHAR(36) NULL, - `category` VARCHAR(100) NOT NULL, - `type` VARCHAR(100) NOT NULL, - `likes_count` INT DEFAULT 0, - `views_count` INT DEFAULT 0, - `rating` DECIMAL(3,2) DEFAULT 0.00, - `is_active` BOOLEAN DEFAULT TRUE, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`team_id`) REFERENCES `teams`(`id`) ON DELETE SET NULL, - INDEX `idx_creator` (`creator_id`), - INDEX `idx_team` (`team_id`), - INDEX `idx_category` (`category`), - INDEX `idx_type` (`type`), - INDEX `idx_likes` (`likes_count`), - INDEX `idx_views` (`views_count`) -); - --- 10. 提案表 -CREATE TABLE `proposals` ( - `id` VARCHAR(36) PRIMARY KEY, - `title` VARCHAR(300) NOT NULL, - `description` TEXT, - `problem_statement` TEXT NOT NULL, - `solution` TEXT NOT NULL, - `expected_impact` TEXT NOT NULL, - `team_id` VARCHAR(36) NOT NULL, - `attachments` JSON NULL, - `status` ENUM('draft', 'submitted', 'under_review', 'approved', 'rejected') DEFAULT 'draft', - `submitted_at` TIMESTAMP NULL, - `is_active` BOOLEAN DEFAULT TRUE, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (`team_id`) REFERENCES `teams`(`id`) ON DELETE CASCADE, - INDEX `idx_team` (`team_id`), - INDEX `idx_status` (`status`), - INDEX `idx_submitted` (`submitted_at`) -); - --- 11. 競賽參與應用表 -CREATE TABLE `competition_apps` ( - `id` VARCHAR(36) PRIMARY KEY, - `competition_id` VARCHAR(36) NOT NULL, - `app_id` VARCHAR(36) NOT NULL, - `submitted_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`competition_id`) REFERENCES `competitions`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON DELETE CASCADE, - UNIQUE KEY `unique_competition_app` (`competition_id`, `app_id`), - INDEX `idx_competition` (`competition_id`), - INDEX `idx_app` (`app_id`) -); - --- 12. 競賽參與團隊表 -CREATE TABLE `competition_teams` ( - `id` VARCHAR(36) PRIMARY KEY, - `competition_id` VARCHAR(36) NOT NULL, - `team_id` VARCHAR(36) NOT NULL, - `submitted_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`competition_id`) REFERENCES `competitions`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`team_id`) REFERENCES `teams`(`id`) ON DELETE CASCADE, - UNIQUE KEY `unique_competition_team` (`competition_id`, `team_id`), - INDEX `idx_competition` (`competition_id`), - INDEX `idx_team` (`team_id`) -); - --- 13. 競賽參與提案表 -CREATE TABLE `competition_proposals` ( - `id` VARCHAR(36) PRIMARY KEY, - `competition_id` VARCHAR(36) NOT NULL, - `proposal_id` VARCHAR(36) NOT NULL, - `submitted_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`competition_id`) REFERENCES `competitions`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`proposal_id`) REFERENCES `proposals`(`id`) ON DELETE CASCADE, - UNIQUE KEY `unique_competition_proposal` (`competition_id`, `proposal_id`), - INDEX `idx_competition` (`competition_id`), - INDEX `idx_proposal` (`proposal_id`) -); - --- 14. 應用評分表 -CREATE TABLE `app_judge_scores` ( - `id` VARCHAR(36) PRIMARY KEY, - `judge_id` VARCHAR(36) NOT NULL, - `app_id` VARCHAR(36) NOT NULL, - `innovation_score` INT NOT NULL CHECK (`innovation_score` >= 1 AND `innovation_score` <= 10), - `technical_score` INT NOT NULL CHECK (`technical_score` >= 1 AND `technical_score` <= 10), - `usability_score` INT NOT NULL CHECK (`usability_score` >= 1 AND `usability_score` <= 10), - `presentation_score` INT NOT NULL CHECK (`presentation_score` >= 1 AND `presentation_score` <= 10), - `impact_score` INT NOT NULL CHECK (`impact_score` >= 1 AND `impact_score` <= 10), - `total_score` DECIMAL(5,2) NOT NULL, - `comments` TEXT, - `submitted_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`judge_id`) REFERENCES `judges`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON DELETE CASCADE, - UNIQUE KEY `unique_judge_app` (`judge_id`, `app_id`), - INDEX `idx_judge` (`judge_id`), - INDEX `idx_app` (`app_id`), - INDEX `idx_total_score` (`total_score`) -); - --- 15. 提案評分表 -CREATE TABLE `proposal_judge_scores` ( - `id` VARCHAR(36) PRIMARY KEY, - `judge_id` VARCHAR(36) NOT NULL, - `proposal_id` VARCHAR(36) NOT NULL, - `problem_identification_score` INT NOT NULL CHECK (`problem_identification_score` >= 1 AND `problem_identification_score` <= 10), - `solution_feasibility_score` INT NOT NULL CHECK (`solution_feasibility_score` >= 1 AND `solution_feasibility_score` <= 10), - `innovation_score` INT NOT NULL CHECK (`innovation_score` >= 1 AND `innovation_score` <= 10), - `impact_score` INT NOT NULL CHECK (`impact_score` >= 1 AND `impact_score` <= 10), - `presentation_score` INT NOT NULL CHECK (`presentation_score` >= 1 AND `presentation_score` <= 10), - `total_score` DECIMAL(5,2) NOT NULL, - `comments` TEXT, - `submitted_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`judge_id`) REFERENCES `judges`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`proposal_id`) REFERENCES `proposals`(`id`) ON DELETE CASCADE, - UNIQUE KEY `unique_judge_proposal` (`judge_id`, `proposal_id`), - INDEX `idx_judge` (`judge_id`), - INDEX `idx_proposal` (`proposal_id`), - INDEX `idx_total_score` (`total_score`) -); - --- 16. 獎項表 -CREATE TABLE `awards` ( - `id` VARCHAR(36) PRIMARY KEY, - `competition_id` VARCHAR(36) NOT NULL, - `app_id` VARCHAR(36) NULL, - `team_id` VARCHAR(36) NULL, - `proposal_id` VARCHAR(36) NULL, - `app_name` VARCHAR(200) NULL, - `team_name` VARCHAR(200) NULL, - `proposal_title` VARCHAR(300) NULL, - `creator` VARCHAR(100) NOT NULL, - `award_type` ENUM('gold', 'silver', 'bronze', 'popular', 'innovation', 'technical', 'custom') NOT NULL, - `award_name` VARCHAR(200) NOT NULL, - `score` DECIMAL(5,2) NOT NULL, - `year` INT NOT NULL, - `month` INT NOT NULL, - `icon` VARCHAR(50) NOT NULL, - `custom_award_type_id` VARCHAR(36) NULL, - `competition_type` ENUM('individual', 'team', 'proposal') NOT NULL, - `rank` INT DEFAULT 0, - `category` ENUM('innovation', 'technical', 'practical', 'popular', 'teamwork', 'solution', 'creativity') NOT NULL, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`competition_id`) REFERENCES `competitions`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON DELETE SET NULL, - FOREIGN KEY (`team_id`) REFERENCES `teams`(`id`) ON DELETE SET NULL, - FOREIGN KEY (`proposal_id`) REFERENCES `proposals`(`id`) ON DELETE SET NULL, - INDEX `idx_competition` (`competition_id`), - INDEX `idx_year_month` (`year`, `month`), - INDEX `idx_award_type` (`award_type`), - INDEX `idx_rank` (`rank`), - INDEX `idx_category` (`category`) -); - --- 17. 用戶收藏表 -CREATE TABLE `user_favorites` ( - `id` VARCHAR(36) PRIMARY KEY, - `user_id` VARCHAR(36) NOT NULL, - `app_id` VARCHAR(36) NOT NULL, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON DELETE CASCADE, - UNIQUE KEY `unique_user_app` (`user_id`, `app_id`), - INDEX `idx_user` (`user_id`), - INDEX `idx_app` (`app_id`) -); - --- 18. 用戶按讚表 -CREATE TABLE `user_likes` ( - `id` VARCHAR(36) PRIMARY KEY, - `user_id` VARCHAR(36) NOT NULL, - `app_id` VARCHAR(36) NOT NULL, - `liked_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON DELETE CASCADE, - UNIQUE KEY `unique_user_app_date` (`user_id`, `app_id`, `liked_at`), - INDEX `idx_user` (`user_id`), - INDEX `idx_app` (`app_id`), - INDEX `idx_liked_at` (`liked_at`) -); - --- 19. 用戶瀏覽記錄表 -CREATE TABLE `user_views` ( - `id` VARCHAR(36) PRIMARY KEY, - `user_id` VARCHAR(36) NOT NULL, - `app_id` VARCHAR(36) NOT NULL, - `viewed_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `ip_address` VARCHAR(45) NULL, - `user_agent` TEXT NULL, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON DELETE CASCADE, - INDEX `idx_user` (`user_id`), - INDEX `idx_app` (`app_id`), - INDEX `idx_viewed_at` (`viewed_at`) -); - --- 20. 用戶評分表 -CREATE TABLE `user_ratings` ( - `id` VARCHAR(36) PRIMARY KEY, - `user_id` VARCHAR(36) NOT NULL, - `app_id` VARCHAR(36) NOT NULL, - `rating` DECIMAL(3,2) NOT NULL CHECK (`rating` >= 1.0 AND `rating` <= 5.0), - `comment` TEXT NULL, - `rated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON DELETE CASCADE, - UNIQUE KEY `unique_user_app_rating` (`user_id`, `app_id`), - INDEX `idx_user` (`user_id`), - INDEX `idx_app` (`app_id`), - INDEX `idx_rating` (`rating`) -); - --- 21. AI助手聊天會話表 -CREATE TABLE `chat_sessions` ( - `id` VARCHAR(36) PRIMARY KEY, - `user_id` VARCHAR(36) NOT NULL, - `session_name` VARCHAR(200) NULL, - `is_active` BOOLEAN DEFAULT TRUE, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - INDEX `idx_user` (`user_id`), - INDEX `idx_is_active` (`is_active`) -); - --- 22. AI助手聊天訊息表 -CREATE TABLE `chat_messages` ( - `id` VARCHAR(36) PRIMARY KEY, - `session_id` VARCHAR(36) NOT NULL, - `text` TEXT NOT NULL, - `sender` ENUM('user', 'bot') NOT NULL, - `quick_questions` JSON NULL, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`session_id`) REFERENCES `chat_sessions`(`id`) ON DELETE CASCADE, - INDEX `idx_session` (`session_id`), - INDEX `idx_sender` (`sender`), - INDEX `idx_created_at` (`created_at`) -); - --- 23. AI助手配置表 -CREATE TABLE `ai_assistant_configs` ( - `id` VARCHAR(36) PRIMARY KEY, - `api_key` VARCHAR(255) NOT NULL, - `api_url` VARCHAR(500) NOT NULL, - `model` VARCHAR(100) NOT NULL, - `max_tokens` INT DEFAULT 200, - `temperature` DECIMAL(3,2) DEFAULT 0.70, - `system_prompt` TEXT NOT NULL, - `is_active` BOOLEAN DEFAULT TRUE, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX `idx_is_active` (`is_active`) -); - --- 24. 系統設定表 -CREATE TABLE `system_settings` ( - `id` VARCHAR(36) PRIMARY KEY, - `key` VARCHAR(100) UNIQUE NOT NULL, - `value` TEXT NOT NULL, - `description` TEXT, - `category` VARCHAR(50) DEFAULT 'general', - `is_public` BOOLEAN DEFAULT FALSE, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX `idx_key` (`key`), - INDEX `idx_category` (`category`), - INDEX `idx_is_public` (`is_public`) -); - --- 25. 活動日誌表 -CREATE TABLE `activity_logs` ( - `id` VARCHAR(36) PRIMARY KEY, - `user_id` VARCHAR(36) NULL, - `action` VARCHAR(100) NOT NULL, - `resource_type` VARCHAR(50) NOT NULL, - `resource_id` VARCHAR(36) NULL, - `details` JSON NULL, - `ip_address` VARCHAR(45) NULL, - `user_agent` TEXT NULL, - `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL, - INDEX `idx_user` (`user_id`), - INDEX `idx_action` (`action`), - INDEX `idx_resource` (`resource_type`, `resource_id`), - INDEX `idx_created_at` (`created_at`) -); - -SELECT '資料庫表結構創建完成!' as message; diff --git a/env.example b/env.example index 3ab7e4e..f5faa96 100644 --- a/env.example +++ b/env.example @@ -2,13 +2,34 @@ # AI 展示平台環境變數配置 # ===================================================== -# 資料庫配置 +# ===== 主機資料庫配置 ===== DB_HOST=mysql.theaken.com DB_PORT=33306 DB_NAME=db_AI_Platform DB_USER=AI_Platform DB_PASSWORD=Aa123456 +# ===== 備機資料庫配置 ===== +SLAVE_DB_HOST=122.100.99.161 +SLAVE_DB_PORT=43306 +SLAVE_DB_NAME=db_AI_Platform +SLAVE_DB_USER=A999 +SLAVE_DB_PASSWORD=1023 + +# ===== 資料庫備援配置 ===== +DB_FAILOVER_ENABLED=true + +# ===== 資料庫雙寫同步配置 ===== +DB_DUAL_WRITE_ENABLED=false +DB_MASTER_PRIORITY=true +DB_CONFLICT_RESOLUTION=master +DB_RETRY_ATTEMPTS=3 +DB_RETRY_DELAY=1000 +DB_HEALTH_CHECK_INTERVAL=30000 +DB_CONNECTION_TIMEOUT=5000 +DB_RETRY_ATTEMPTS=3 +DB_RETRY_DELAY=2000 + # DeepSeek API 配置 NEXT_PUBLIC_DEEPSEEK_API_KEY=your_deepseek_api_key_here NEXT_PUBLIC_DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions diff --git a/lib/database-failover.js b/lib/database-failover.js new file mode 100644 index 0000000..8b0df35 --- /dev/null +++ b/lib/database-failover.js @@ -0,0 +1,424 @@ +"use strict"; +// ===================================================== +// 資料庫備援連線服務 +// ===================================================== +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.dbFailover = exports.DatabaseFailover = void 0; +const promise_1 = __importDefault(require("mysql2/promise")); +// 主機資料庫配置 +const masterConfig = { + host: process.env.DB_HOST || 'mysql.theaken.com', + port: parseInt(process.env.DB_PORT || '33306'), + user: process.env.DB_USER || 'AI_Platform', + password: process.env.DB_PASSWORD || 'Aa123456', + database: process.env.DB_NAME || 'db_AI_Platform', + charset: 'utf8mb4', + timezone: '+08:00', + connectionLimit: 10, + queueLimit: 0, + idleTimeout: 300000, + ssl: false, +}; +// 備機資料庫配置 +const slaveConfig = { + host: process.env.SLAVE_DB_HOST || '122.100.99.161', + port: parseInt(process.env.SLAVE_DB_PORT || '43306'), + user: process.env.SLAVE_DB_USER || 'A999', + password: process.env.SLAVE_DB_PASSWORD || '1023', + database: process.env.SLAVE_DB_NAME || 'db_AI_Platform', // 修正為 AI 平台資料庫 + charset: 'utf8mb4', + timezone: '+08:00', + connectionLimit: 10, + queueLimit: 0, + idleTimeout: 300000, + ssl: false, +}; +// 備援資料庫管理類 +class DatabaseFailover { + constructor() { + this.masterPool = null; + this.slavePool = null; + this.healthCheckInterval = null; + this.status = { + isEnabled: process.env.DB_FAILOVER_ENABLED === 'true', + currentDatabase: 'master', + masterHealthy: false, + slaveHealthy: false, + lastHealthCheck: 0, + consecutiveFailures: 0, + }; + // 同步初始化連接池 + this.initializePoolsSync(); + this.startHealthCheck(); + } + static getInstance() { + if (!DatabaseFailover.instance) { + DatabaseFailover.instance = new DatabaseFailover(); + } + return DatabaseFailover.instance; + } + // 同步初始化連接池 + initializePoolsSync() { + try { + console.log('🚀 開始同步初始化資料庫備援系統...'); + // 直接創建連接池,不等待測試 + this.masterPool = promise_1.default.createPool(masterConfig); + console.log('✅ 主機資料庫連接池創建成功'); + this.slavePool = promise_1.default.createPool(slaveConfig); + console.log('✅ 備機資料庫連接池創建成功'); + // 設置默認使用主機 + this.status.currentDatabase = 'master'; + this.status.masterHealthy = true; // 假設主機健康,後續健康檢查會驗證 + console.log('🎯 當前使用資料庫: 主機'); + // 異步測試連接 + this.testConnectionsAsync(); + } + catch (error) { + console.error('❌ 資料庫連接池同步初始化失敗:', error); + } + } + // 異步測試連接 + async testConnectionsAsync() { + try { + await this.testConnections(); + } + catch (error) { + console.error('❌ 異步連接測試失敗:', error); + } + } + // 初始化連接池 + async initializePools() { + try { + console.log('🚀 開始初始化資料庫備援系統...'); + // 先測試主機連接 + console.log('📡 測試主機資料庫連接...'); + const masterHealthy = await this.testMasterConnection(); + if (masterHealthy) { + console.log('✅ 主機資料庫連接正常,使用主機'); + this.status.currentDatabase = 'master'; + this.status.masterHealthy = true; + // 初始化主機連接池 + this.masterPool = promise_1.default.createPool(masterConfig); + console.log('✅ 主機資料庫連接池初始化成功'); + } + else { + console.log('❌ 主機資料庫連接失敗,測試備機...'); + // 測試備機連接 + const slaveHealthy = await this.testSlaveConnection(); + if (slaveHealthy) { + console.log('✅ 備機資料庫連接正常,切換到備機'); + this.status.currentDatabase = 'slave'; + this.status.slaveHealthy = true; + // 初始化備機連接池 + this.slavePool = promise_1.default.createPool(slaveConfig); + console.log('✅ 備機資料庫連接池初始化成功'); + } + else { + console.log('❌ 主機和備機都無法連接,嘗試初始化主機連接池'); + this.masterPool = promise_1.default.createPool(masterConfig); + this.status.currentDatabase = 'master'; + } + } + // 初始化另一個連接池(用於健康檢查) + if (this.status.currentDatabase === 'master' && !this.slavePool) { + this.slavePool = promise_1.default.createPool(slaveConfig); + console.log('✅ 備機資料庫連接池初始化成功(用於健康檢查)'); + } + else if (this.status.currentDatabase === 'slave' && !this.masterPool) { + this.masterPool = promise_1.default.createPool(masterConfig); + console.log('✅ 主機資料庫連接池初始化成功(用於健康檢查)'); + } + console.log(`🎯 當前使用資料庫: ${this.status.currentDatabase === 'master' ? '主機' : '備機'}`); + } + catch (error) { + console.error('❌ 資料庫連接池初始化失敗:', error); + } + } + // 測試主機連接 + async testMasterConnection() { + try { + const connection = await promise_1.default.createConnection(masterConfig); + await connection.ping(); + await connection.end(); + return true; + } + catch (error) { + console.error('主機資料庫連接失敗:', error.message); + return false; + } + } + // 測試備機連接 + async testSlaveConnection() { + try { + const connection = await promise_1.default.createConnection(slaveConfig); + await connection.ping(); + await connection.end(); + return true; + } + catch (error) { + console.error('備機資料庫連接失敗:', error.message); + return false; + } + } + // 測試資料庫連接 + async testConnections() { + // 測試主機 + try { + if (this.masterPool) { + const connection = await this.masterPool.getConnection(); + await connection.ping(); + connection.release(); + this.status.masterHealthy = true; + console.log('主機資料庫連接正常'); + } + } + catch (error) { + this.status.masterHealthy = false; + console.error('主機資料庫連接失敗:', error); + } + // 測試備機 + try { + if (this.slavePool) { + const connection = await this.slavePool.getConnection(); + await connection.ping(); + connection.release(); + this.status.slaveHealthy = true; + console.log('備機資料庫連接正常'); + } + } + catch (error) { + this.status.slaveHealthy = false; + console.error('備機資料庫連接失敗:', error); + } + } + // 開始健康檢查 + startHealthCheck() { + if (!this.status.isEnabled) + return; + const interval = parseInt(process.env.DB_HEALTH_CHECK_INTERVAL || '30000'); + this.healthCheckInterval = setInterval(async () => { + await this.performHealthCheck(); + }, interval); + } + // 執行健康檢查 + async performHealthCheck() { + const now = Date.now(); + this.status.lastHealthCheck = now; + // 檢查主機 + if (this.masterPool) { + try { + const connection = await this.masterPool.getConnection(); + await connection.ping(); + connection.release(); + this.status.masterHealthy = true; + } + catch (error) { + this.status.masterHealthy = false; + console.error('主機資料庫健康檢查失敗:', error); + } + } + // 檢查備機 + if (this.slavePool) { + try { + const connection = await this.slavePool.getConnection(); + await connection.ping(); + connection.release(); + this.status.slaveHealthy = true; + } + catch (error) { + this.status.slaveHealthy = false; + console.error('備機資料庫健康檢查失敗:', error); + } + } + // 決定當前使用的資料庫 + this.determineCurrentDatabase(); + } + // 決定當前使用的資料庫 + determineCurrentDatabase() { + const previousDatabase = this.status.currentDatabase; + if (this.status.masterHealthy) { + if (this.status.currentDatabase !== 'master') { + console.log('🔄 主機資料庫恢復,切換回主機'); + this.status.currentDatabase = 'master'; + this.status.consecutiveFailures = 0; + } + } + else if (this.status.slaveHealthy) { + if (this.status.currentDatabase !== 'slave') { + console.log('🔄 主機資料庫故障,切換到備機'); + this.status.currentDatabase = 'slave'; + this.status.consecutiveFailures++; + } + } + else { + this.status.consecutiveFailures++; + console.error('❌ 主機和備機資料庫都無法連接'); + } + // 記錄狀態變化 + if (previousDatabase !== this.status.currentDatabase) { + console.log(`📊 資料庫狀態變化: ${previousDatabase} → ${this.status.currentDatabase}`); + } + } + // 獲取當前連接池 + getCurrentPool() { + if (this.status.currentDatabase === 'master') { + if (this.masterPool) { + return this.masterPool; + } + else if (this.slavePool) { + // 主機不可用,嘗試使用備機 + console.log('⚠️ 主機連接池不可用,嘗試使用備機'); + this.status.currentDatabase = 'slave'; + return this.slavePool; + } + } + else if (this.status.currentDatabase === 'slave') { + if (this.slavePool) { + return this.slavePool; + } + else if (this.masterPool) { + // 備機不可用,嘗試使用主機 + console.log('⚠️ 備機連接池不可用,嘗試使用主機'); + this.status.currentDatabase = 'master'; + return this.masterPool; + } + } + console.error('❌ 沒有可用的資料庫連接池'); + return null; + } + // 獲取連接 + async getConnection() { + const pool = this.getCurrentPool(); + if (!pool) { + throw new Error('沒有可用的資料庫連接'); + } + let retries = 0; + const maxRetries = parseInt(process.env.DB_RETRY_ATTEMPTS || '3'); + const retryDelay = parseInt(process.env.DB_RETRY_DELAY || '2000'); + while (retries < maxRetries) { + try { + return await pool.getConnection(); + } + catch (error) { + console.error(`資料庫連接失敗 (嘗試 ${retries + 1}/${maxRetries}):`, error.message); + if (error.code === 'ECONNRESET' || error.code === 'PROTOCOL_CONNECTION_LOST') { + // 觸發健康檢查 + await this.performHealthCheck(); + retries++; + if (retries < maxRetries) { + console.log(`等待 ${retryDelay}ms 後重試...`); + await new Promise(resolve => setTimeout(resolve, retryDelay)); + continue; + } + } + throw error; + } + } + throw new Error('資料庫連接失敗,已達到最大重試次數'); + } + // 執行查詢 + async query(sql, params) { + const connection = await this.getConnection(); + try { + const [rows] = await connection.execute(sql, params); + return rows; + } + finally { + connection.release(); + } + } + // 執行單一查詢 + async queryOne(sql, params) { + try { + const results = await this.query(sql, params); + return results.length > 0 ? results[0] : null; + } + catch (error) { + console.error('資料庫單一查詢錯誤:', error); + throw error; + } + } + // 執行插入 + async insert(sql, params) { + const connection = await this.getConnection(); + try { + const [result] = await connection.execute(sql, params); + return result; + } + finally { + connection.release(); + } + } + // 執行更新 + async update(sql, params) { + const connection = await this.getConnection(); + try { + const [result] = await connection.execute(sql, params); + return result; + } + finally { + connection.release(); + } + } + // 執行刪除 + async delete(sql, params) { + const connection = await this.getConnection(); + try { + const [result] = await connection.execute(sql, params); + return result; + } + finally { + connection.release(); + } + } + // 開始事務 + async beginTransaction() { + const connection = await this.getConnection(); + await connection.beginTransaction(); + return connection; + } + // 提交事務 + async commit(connection) { + await connection.commit(); + connection.release(); + } + // 回滾事務 + async rollback(connection) { + await connection.rollback(); + connection.release(); + } + // 獲取備援狀態 + getStatus() { + return { ...this.status }; + } + // 強制切換到指定資料庫 + async switchToDatabase(database) { + if (database === 'master' && this.status.masterHealthy) { + this.status.currentDatabase = 'master'; + return true; + } + else if (database === 'slave' && this.status.slaveHealthy) { + this.status.currentDatabase = 'slave'; + return true; + } + return false; + } + // 關閉所有連接池 + async close() { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + } + if (this.masterPool) { + await this.masterPool.end(); + } + if (this.slavePool) { + await this.slavePool.end(); + } + } +} +exports.DatabaseFailover = DatabaseFailover; +// 導出單例實例 +exports.dbFailover = DatabaseFailover.getInstance(); diff --git a/lib/database-failover.ts b/lib/database-failover.ts new file mode 100644 index 0000000..0e66ca3 --- /dev/null +++ b/lib/database-failover.ts @@ -0,0 +1,499 @@ +// ===================================================== +// 資料庫備援連線服務 +// ===================================================== + +import mysql from 'mysql2/promise'; + +// 資料庫配置介面 +interface DatabaseConfig { + host: string; + port: number; + user: string; + password: string; + database: string; + charset: string; + timezone: string; + acquireTimeout: number; + timeout: number; + reconnect: boolean; + connectionLimit: number; + queueLimit: number; + retryDelay: number; + maxRetries: number; + idleTimeout: number; + maxIdle: number; + ssl: boolean; +} + +// 主機資料庫配置 +const masterConfig = { + host: process.env.DB_HOST || 'mysql.theaken.com', + port: parseInt(process.env.DB_PORT || '33306'), + user: process.env.DB_USER || 'AI_Platform', + password: process.env.DB_PASSWORD || 'Aa123456', + database: process.env.DB_NAME || 'db_AI_Platform', + charset: 'utf8mb4', + timezone: '+08:00', + connectionLimit: 10, + queueLimit: 0, + idleTimeout: 300000, + ssl: false as any, +}; + +// 備機資料庫配置 +const slaveConfig = { + host: process.env.SLAVE_DB_HOST || '122.100.99.161', + port: parseInt(process.env.SLAVE_DB_PORT || '43306'), + user: process.env.SLAVE_DB_USER || 'A999', + password: process.env.SLAVE_DB_PASSWORD || '1023', + database: process.env.SLAVE_DB_NAME || 'db_AI_Platform', // 修正為 AI 平台資料庫 + charset: 'utf8mb4', + timezone: '+08:00', + connectionLimit: 10, + queueLimit: 0, + idleTimeout: 300000, + ssl: false as any, +}; + +// 備援狀態 +interface FailoverStatus { + isEnabled: boolean; + currentDatabase: 'master' | 'slave'; + masterHealthy: boolean; + slaveHealthy: boolean; + lastHealthCheck: number; + consecutiveFailures: number; +} + +// 備援資料庫管理類 +export class DatabaseFailover { + private static instance: DatabaseFailover; + private masterPool: mysql.Pool | null = null; + private slavePool: mysql.Pool | null = null; + private status: FailoverStatus; + private healthCheckInterval: NodeJS.Timeout | null = null; + + private constructor() { + this.status = { + isEnabled: process.env.DB_FAILOVER_ENABLED === 'true', + currentDatabase: 'master', + masterHealthy: false, + slaveHealthy: false, + lastHealthCheck: Date.now(), // 使用當前時間作為初始值 + consecutiveFailures: 0, + }; + + // 同步初始化連接池 + this.initializePoolsSync(); + this.startHealthCheck(); + } + + public static getInstance(): DatabaseFailover { + if (!DatabaseFailover.instance) { + DatabaseFailover.instance = new DatabaseFailover(); + } + return DatabaseFailover.instance; + } + + // 同步初始化連接池 + private initializePoolsSync(): void { + try { + console.log('🚀 開始同步初始化資料庫備援系統...'); + + // 直接創建連接池,不等待測試 + this.masterPool = mysql.createPool(masterConfig); + console.log('✅ 主機資料庫連接池創建成功'); + + this.slavePool = mysql.createPool(slaveConfig); + console.log('✅ 備機資料庫連接池創建成功'); + + // 設置默認使用主機 + this.status.currentDatabase = 'master'; + this.status.masterHealthy = true; // 假設主機健康,後續健康檢查會驗證 + + console.log('🎯 當前使用資料庫: 主機'); + + // 異步測試連接 + this.testConnectionsAsync(); + + } catch (error) { + console.error('❌ 資料庫連接池同步初始化失敗:', error); + } + } + + // 異步測試連接 + private async testConnectionsAsync(): Promise { + try { + await this.testConnections(); + } catch (error) { + console.error('❌ 異步連接測試失敗:', error); + } + } + + // 初始化連接池 + private async initializePools(): Promise { + try { + console.log('🚀 開始初始化資料庫備援系統...'); + + // 先測試主機連接 + console.log('📡 測試主機資料庫連接...'); + const masterHealthy = await this.testMasterConnection(); + + if (masterHealthy) { + console.log('✅ 主機資料庫連接正常,使用主機'); + this.status.currentDatabase = 'master'; + this.status.masterHealthy = true; + + // 初始化主機連接池 + this.masterPool = mysql.createPool(masterConfig); + console.log('✅ 主機資料庫連接池初始化成功'); + } else { + console.log('❌ 主機資料庫連接失敗,測試備機...'); + + // 測試備機連接 + const slaveHealthy = await this.testSlaveConnection(); + + if (slaveHealthy) { + console.log('✅ 備機資料庫連接正常,切換到備機'); + this.status.currentDatabase = 'slave'; + this.status.slaveHealthy = true; + + // 初始化備機連接池 + this.slavePool = mysql.createPool(slaveConfig); + console.log('✅ 備機資料庫連接池初始化成功'); + } else { + console.log('❌ 主機和備機都無法連接,嘗試初始化主機連接池'); + this.masterPool = mysql.createPool(masterConfig); + this.status.currentDatabase = 'master'; + } + } + + // 初始化另一個連接池(用於健康檢查) + if (this.status.currentDatabase === 'master' && !this.slavePool) { + this.slavePool = mysql.createPool(slaveConfig); + console.log('✅ 備機資料庫連接池初始化成功(用於健康檢查)'); + } else if (this.status.currentDatabase === 'slave' && !this.masterPool) { + this.masterPool = mysql.createPool(masterConfig); + console.log('✅ 主機資料庫連接池初始化成功(用於健康檢查)'); + } + + console.log(`🎯 當前使用資料庫: ${this.status.currentDatabase === 'master' ? '主機' : '備機'}`); + + } catch (error) { + console.error('❌ 資料庫連接池初始化失敗:', error); + } + } + + // 測試主機連接 + private async testMasterConnection(): Promise { + try { + const connection = await mysql.createConnection(masterConfig); + await connection.ping(); + await connection.end(); + return true; + } catch (error: any) { + console.error('主機資料庫連接失敗:', error.message); + return false; + } + } + + // 測試備機連接 + private async testSlaveConnection(): Promise { + try { + const connection = await mysql.createConnection(slaveConfig); + await connection.ping(); + await connection.end(); + return true; + } catch (error: any) { + console.error('備機資料庫連接失敗:', error.message); + return false; + } + } + + // 測試資料庫連接 + private async testConnections(): Promise { + // 測試主機 + try { + if (this.masterPool) { + const connection = await this.masterPool.getConnection(); + await connection.ping(); + connection.release(); + this.status.masterHealthy = true; + console.log('主機資料庫連接正常'); + } + } catch (error) { + this.status.masterHealthy = false; + console.error('主機資料庫連接失敗:', error); + } + + // 測試備機 + try { + if (this.slavePool) { + const connection = await this.slavePool.getConnection(); + await connection.ping(); + connection.release(); + this.status.slaveHealthy = true; + console.log('備機資料庫連接正常'); + } + } catch (error) { + this.status.slaveHealthy = false; + console.error('備機資料庫連接失敗:', error); + } + } + + // 開始健康檢查 + private startHealthCheck(): void { + if (!this.status.isEnabled) return; + + const interval = parseInt(process.env.DB_HEALTH_CHECK_INTERVAL || '30000'); + this.healthCheckInterval = setInterval(async () => { + await this.performHealthCheck(); + }, interval); + } + + // 執行健康檢查 + private async performHealthCheck(): Promise { + const now = Date.now(); + this.status.lastHealthCheck = now; + + // 檢查主機 + if (this.masterPool) { + try { + const connection = await this.masterPool.getConnection(); + await connection.ping(); + connection.release(); + this.status.masterHealthy = true; + } catch (error) { + this.status.masterHealthy = false; + console.error('主機資料庫健康檢查失敗:', error); + } + } + + // 檢查備機 + if (this.slavePool) { + try { + const connection = await this.slavePool.getConnection(); + await connection.ping(); + connection.release(); + this.status.slaveHealthy = true; + } catch (error) { + this.status.slaveHealthy = false; + console.error('備機資料庫健康檢查失敗:', error); + } + } + + // 決定當前使用的資料庫 + this.determineCurrentDatabase(); + } + + // 決定當前使用的資料庫 + private determineCurrentDatabase(): void { + const previousDatabase = this.status.currentDatabase; + + if (this.status.masterHealthy) { + if (this.status.currentDatabase !== 'master') { + console.log('🔄 主機資料庫恢復,切換回主機'); + this.status.currentDatabase = 'master'; + this.status.consecutiveFailures = 0; + } + } else if (this.status.slaveHealthy) { + if (this.status.currentDatabase !== 'slave') { + console.log('🔄 主機資料庫故障,切換到備機'); + this.status.currentDatabase = 'slave'; + this.status.consecutiveFailures++; + } + } else { + this.status.consecutiveFailures++; + console.error('❌ 主機和備機資料庫都無法連接'); + } + + // 記錄狀態變化 + if (previousDatabase !== this.status.currentDatabase) { + console.log(`📊 資料庫狀態變化: ${previousDatabase} → ${this.status.currentDatabase}`); + } + } + + // 獲取當前連接池 + private getCurrentPool(): mysql.Pool | null { + if (this.status.currentDatabase === 'master') { + if (this.masterPool) { + return this.masterPool; + } else if (this.slavePool) { + // 主機不可用,嘗試使用備機 + console.log('⚠️ 主機連接池不可用,嘗試使用備機'); + this.status.currentDatabase = 'slave'; + return this.slavePool; + } + } else if (this.status.currentDatabase === 'slave') { + if (this.slavePool) { + return this.slavePool; + } else if (this.masterPool) { + // 備機不可用,嘗試使用主機 + console.log('⚠️ 備機連接池不可用,嘗試使用主機'); + this.status.currentDatabase = 'master'; + return this.masterPool; + } + } + + console.error('❌ 沒有可用的資料庫連接池'); + return null; + } + + // 獲取連接 + public async getConnection(): Promise { + const pool = this.getCurrentPool(); + if (!pool) { + throw new Error('沒有可用的資料庫連接'); + } + + let retries = 0; + const maxRetries = parseInt(process.env.DB_RETRY_ATTEMPTS || '3'); + const retryDelay = parseInt(process.env.DB_RETRY_DELAY || '2000'); + + while (retries < maxRetries) { + try { + return await pool.getConnection(); + } catch (error: any) { + console.error(`資料庫連接失敗 (嘗試 ${retries + 1}/${maxRetries}):`, error.message); + + if (error.code === 'ECONNRESET' || error.code === 'PROTOCOL_CONNECTION_LOST') { + // 觸發健康檢查 + await this.performHealthCheck(); + + retries++; + if (retries < maxRetries) { + console.log(`等待 ${retryDelay}ms 後重試...`); + await new Promise(resolve => setTimeout(resolve, retryDelay)); + continue; + } + } + + throw error; + } + } + + throw new Error('資料庫連接失敗,已達到最大重試次數'); + } + + // 執行查詢 + public async query(sql: string, params?: any[]): Promise { + const connection = await this.getConnection(); + try { + const [rows] = await connection.execute(sql, params); + return rows as T[]; + } finally { + connection.release(); + } + } + + // 執行單一查詢 + public async queryOne(sql: string, params?: any[]): Promise { + try { + const results = await this.query(sql, params); + return results.length > 0 ? results[0] : null; + } catch (error) { + console.error('資料庫單一查詢錯誤:', error); + throw error; + } + } + + // 執行插入 + public async insert(sql: string, params?: any[]): Promise { + const connection = await this.getConnection(); + try { + const [result] = await connection.execute(sql, params); + return result as mysql.ResultSetHeader; + } finally { + connection.release(); + } + } + + // 執行更新 + public async update(sql: string, params?: any[]): Promise { + const connection = await this.getConnection(); + try { + const [result] = await connection.execute(sql, params); + return result as mysql.ResultSetHeader; + } finally { + connection.release(); + } + } + + // 執行刪除 + public async delete(sql: string, params?: any[]): Promise { + const connection = await this.getConnection(); + try { + const [result] = await connection.execute(sql, params); + return result as mysql.ResultSetHeader; + } finally { + connection.release(); + } + } + + // 開始事務 + public async beginTransaction(): Promise { + const connection = await this.getConnection(); + await connection.beginTransaction(); + return connection; + } + + // 提交事務 + public async commit(connection: mysql.PoolConnection): Promise { + await connection.commit(); + connection.release(); + } + + // 回滾事務 + public async rollback(connection: mysql.PoolConnection): Promise { + await connection.rollback(); + connection.release(); + } + + // 獲取備援狀態 + public getStatus(): FailoverStatus { + return { ...this.status }; + } + + // 獲取主機連接池 + public getMasterPool(): mysql.Pool | null { + return this.masterPool; + } + + // 獲取備機連接池 + public getSlavePool(): mysql.Pool | null { + return this.slavePool; + } + + // 強制切換到指定資料庫 + public async switchToDatabase(database: 'master' | 'slave'): Promise { + if (database === 'master' && this.status.masterHealthy) { + this.status.currentDatabase = 'master'; + return true; + } else if (database === 'slave' && this.status.slaveHealthy) { + this.status.currentDatabase = 'slave'; + return true; + } + return false; + } + + // 關閉所有連接池 + public async close(): Promise { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + } + + if (this.masterPool) { + await this.masterPool.end(); + } + + if (this.slavePool) { + await this.slavePool.end(); + } + } +} + +// 導出單例實例 +export const dbFailover = DatabaseFailover.getInstance(); + +// 導出類型 +export type { PoolConnection } from 'mysql2/promise'; diff --git a/lib/database-sync.ts b/lib/database-sync.ts new file mode 100644 index 0000000..e625349 --- /dev/null +++ b/lib/database-sync.ts @@ -0,0 +1,229 @@ +// ===================================================== +// 資料庫雙寫同步服務 +// ===================================================== + +import mysql from 'mysql2/promise'; +import { dbFailover } from './database-failover'; + +// 雙寫配置 +interface DualWriteConfig { + enabled: boolean; + masterPriority: boolean; // 主機優先,如果主機失敗則只寫備機 + conflictResolution: 'master' | 'slave' | 'timestamp'; // 衝突解決策略 + retryAttempts: number; + retryDelay: number; +} + +// 寫入結果 +interface WriteResult { + success: boolean; + masterSuccess: boolean; + slaveSuccess: boolean; + masterError?: string; + slaveError?: string; + conflictDetected?: boolean; +} + +export class DatabaseSync { + private static instance: DatabaseSync; + private config: DualWriteConfig; + + private constructor() { + this.config = { + enabled: process.env.DB_DUAL_WRITE_ENABLED === 'true', + masterPriority: process.env.DB_MASTER_PRIORITY !== 'false', + conflictResolution: (process.env.DB_CONFLICT_RESOLUTION as any) || 'master', + retryAttempts: parseInt(process.env.DB_RETRY_ATTEMPTS || '3'), + retryDelay: parseInt(process.env.DB_RETRY_DELAY || '1000') + }; + } + + public static getInstance(): DatabaseSync { + if (!DatabaseSync.instance) { + DatabaseSync.instance = new DatabaseSync(); + } + return DatabaseSync.instance; + } + + // 雙寫插入 + async dualInsert(sql: string, params?: any[]): Promise { + if (!this.config.enabled) { + // 如果雙寫未啟用,使用備援系統選擇的資料庫 + try { + await dbFailover.insert(sql, params); + return { + success: true, + masterSuccess: true, + slaveSuccess: false + }; + } catch (error) { + return { + success: false, + masterSuccess: false, + slaveSuccess: false, + masterError: error instanceof Error ? error.message : '未知錯誤' + }; + } + } + + const result: WriteResult = { + success: false, + masterSuccess: false, + slaveSuccess: false + }; + + // 獲取主機和備機連接 + const masterPool = dbFailover.getMasterPool(); + const slavePool = dbFailover.getSlavePool(); + + if (!masterPool || !slavePool) { + result.masterError = '無法獲取資料庫連接池'; + return result; + } + + // 根據優先級決定寫入順序 + const writeOrder = this.config.masterPriority + ? [{ pool: masterPool, name: 'master' }, { pool: slavePool, name: 'slave' }] + : [{ pool: slavePool, name: 'slave' }, { pool: masterPool, name: 'master' }]; + + // 執行雙寫 + for (const { pool, name } of writeOrder) { + try { + await this.executeWithRetry(pool, sql, params); + result[`${name}Success`] = true; + console.log(`✅ ${name} 資料庫寫入成功`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : '未知錯誤'; + result[`${name}Error`] = errorMsg; + console.error(`❌ ${name} 資料庫寫入失敗:`, errorMsg); + + // 如果主機優先且主機失敗,嘗試備機 + if (this.config.masterPriority && name === 'master') { + continue; + } + } + } + + // 判斷整體成功狀態 + result.success = result.masterSuccess || result.slaveSuccess; + + // 檢查衝突 + if (result.masterSuccess && result.slaveSuccess) { + result.conflictDetected = await this.checkForConflicts(sql, params); + } + + return result; + } + + // 雙寫更新 + async dualUpdate(sql: string, params?: any[]): Promise { + return await this.dualInsert(sql, params); + } + + // 雙寫刪除 + async dualDelete(sql: string, params?: any[]): Promise { + return await this.dualInsert(sql, params); + } + + // 帶重試的執行 + private async executeWithRetry(pool: mysql.Pool, sql: string, params?: any[]): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= this.config.retryAttempts; attempt++) { + try { + const connection = await pool.getConnection(); + try { + const [result] = await connection.execute(sql, params); + return result; + } finally { + connection.release(); + } + } catch (error) { + lastError = error instanceof Error ? error : new Error('未知錯誤'); + + if (attempt < this.config.retryAttempts) { + console.log(`重試 ${attempt}/${this.config.retryAttempts}: ${lastError.message}`); + await new Promise(resolve => setTimeout(resolve, this.config.retryDelay * attempt)); + } + } + } + + throw lastError; + } + + // 檢查衝突(簡化版本,實際應用中可能需要更複雜的邏輯) + private async checkForConflicts(sql: string, params?: any[]): Promise { + // 這裡可以實現更複雜的衝突檢測邏輯 + // 例如:比較主機和備機的資料是否一致 + return false; + } + + // 同步資料(從主機到備機) + async syncFromMasterToSlave(tableName: string, condition?: string): Promise { + try { + const masterPool = dbFailover.getMasterPool(); + const slavePool = dbFailover.getSlavePool(); + + if (!masterPool || !slavePool) { + throw new Error('無法獲取資料庫連接池'); + } + + // 從主機讀取資料 + const masterConnection = await masterPool.getConnection(); + const slaveConnection = await slavePool.getConnection(); + + try { + const selectSql = condition + ? `SELECT * FROM ${tableName} WHERE ${condition}` + : `SELECT * FROM ${tableName}`; + + const [rows] = await masterConnection.execute(selectSql); + + if (Array.isArray(rows) && rows.length > 0) { + // 清空備機表(可選) + await slaveConnection.execute(`DELETE FROM ${tableName}${condition ? ` WHERE ${condition}` : ''}`); + + // 插入資料到備機 + for (const row of rows as any[]) { + const columns = Object.keys(row); + const values = columns.map(() => '?').join(', '); + const insertSql = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${values})`; + const insertParams = columns.map(col => row[col]); + + await slaveConnection.execute(insertSql, insertParams); + } + } + + console.log(`✅ 成功同步 ${tableName} 表資料到備機`); + return true; + } finally { + masterConnection.release(); + slaveConnection.release(); + } + } catch (error) { + console.error(`❌ 同步 ${tableName} 表資料失敗:`, error); + return false; + } + } + + // 獲取同步狀態 + async getSyncStatus(): Promise<{ + enabled: boolean; + masterHealthy: boolean; + slaveHealthy: boolean; + lastSyncTime?: string; + }> { + const masterPool = dbFailover.getMasterPool(); + const slavePool = dbFailover.getSlavePool(); + + return { + enabled: this.config.enabled, + masterHealthy: masterPool ? true : false, + slaveHealthy: slavePool ? true : false, + lastSyncTime: new Date().toISOString() + }; + } +} + +// 導出單例實例 +export const dbSync = DatabaseSync.getInstance(); diff --git a/lib/database.ts b/lib/database.ts index f23ced8..7a88074 100644 --- a/lib/database.ts +++ b/lib/database.ts @@ -1,8 +1,10 @@ // ===================================================== -// 資料庫連接配置 +// 資料庫連接配置 (整合備援功能) // ===================================================== import mysql from 'mysql2/promise'; +import { dbFailover } from './database-failover'; +import { dbSync } from './database-sync'; // 資料庫配置 const dbConfig = { @@ -25,19 +27,21 @@ const dbConfig = { idleTimeout: 300000, maxIdle: 10, // 添加 SSL 配置(如果需要) - ssl: false, + ssl: false as any, }; // 創建連接池 const pool = mysql.createPool(dbConfig); -// 資料庫連接類 +// 資料庫連接類 (整合備援功能) export class Database { private static instance: Database; private pool: mysql.Pool; + private useFailover: boolean; private constructor() { this.pool = pool; + this.useFailover = process.env.DB_FAILOVER_ENABLED === 'true'; } public static getInstance(): Database { @@ -49,11 +53,18 @@ export class Database { // 獲取連接 public async getConnection(): Promise { + if (this.useFailover) { + return await dbFailover.getConnection(); + } return await this.pool.getConnection(); } // 執行查詢 public async query(sql: string, params?: any[]): Promise { + if (this.useFailover) { + return await dbFailover.query(sql, params); + } + let connection; let retries = 0; const maxRetries = 3; @@ -92,6 +103,10 @@ export class Database { // 執行單一查詢 public async queryOne(sql: string, params?: any[]): Promise { + if (this.useFailover) { + return await dbFailover.queryOne(sql, params); + } + try { const results = await this.query(sql, params); return results.length > 0 ? results[0] : null; @@ -101,8 +116,28 @@ export class Database { } } - // 執行插入 + // 執行插入(支援雙寫) public async insert(sql: string, params?: any[]): Promise { + if (this.useFailover) { + // 檢查是否啟用雙寫 + const syncStatus = await dbSync.getSyncStatus(); + if (syncStatus.enabled) { + const result = await dbSync.dualInsert(sql, params); + if (result.success) { + // 返回主機的結果(如果主機成功) + if (result.masterSuccess) { + return await dbFailover.insert(sql, params); + } else if (result.slaveSuccess) { + // 如果只有備機成功,返回備機結果 + return await dbFailover.insert(sql, params); + } + } + throw new Error(`雙寫失敗: 主機${result.masterError || '成功'}, 備機${result.slaveError || '成功'}`); + } else { + return await dbFailover.insert(sql, params); + } + } + const connection = await this.getConnection(); try { const [result] = await connection.execute(sql, params); @@ -112,8 +147,28 @@ export class Database { } } - // 執行更新 + // 執行更新(支援雙寫) public async update(sql: string, params?: any[]): Promise { + if (this.useFailover) { + // 檢查是否啟用雙寫 + const syncStatus = await dbSync.getSyncStatus(); + if (syncStatus.enabled) { + const result = await dbSync.dualUpdate(sql, params); + if (result.success) { + // 返回主機的結果(如果主機成功) + if (result.masterSuccess) { + return await dbFailover.update(sql, params); + } else if (result.slaveSuccess) { + // 如果只有備機成功,返回備機結果 + return await dbFailover.update(sql, params); + } + } + throw new Error(`雙寫失敗: 主機${result.masterError || '成功'}, 備機${result.slaveError || '成功'}`); + } else { + return await dbFailover.update(sql, params); + } + } + const connection = await this.getConnection(); try { const [result] = await connection.execute(sql, params); @@ -123,8 +178,28 @@ export class Database { } } - // 執行刪除 + // 執行刪除(支援雙寫) public async delete(sql: string, params?: any[]): Promise { + if (this.useFailover) { + // 檢查是否啟用雙寫 + const syncStatus = await dbSync.getSyncStatus(); + if (syncStatus.enabled) { + const result = await dbSync.dualDelete(sql, params); + if (result.success) { + // 返回主機的結果(如果主機成功) + if (result.masterSuccess) { + return await dbFailover.delete(sql, params); + } else if (result.slaveSuccess) { + // 如果只有備機成功,返回備機結果 + return await dbFailover.delete(sql, params); + } + } + throw new Error(`雙寫失敗: 主機${result.masterError || '成功'}, 備機${result.slaveError || '成功'}`); + } else { + return await dbFailover.delete(sql, params); + } + } + const connection = await this.getConnection(); try { const [result] = await connection.execute(sql, params); @@ -136,6 +211,10 @@ export class Database { // 開始事務 public async beginTransaction(): Promise { + if (this.useFailover) { + return await dbFailover.beginTransaction(); + } + const connection = await this.getConnection(); await connection.beginTransaction(); return connection; @@ -143,18 +222,45 @@ export class Database { // 提交事務 public async commit(connection: mysql.PoolConnection): Promise { + if (this.useFailover) { + return await dbFailover.commit(connection); + } + await connection.commit(); connection.release(); } // 回滾事務 public async rollback(connection: mysql.PoolConnection): Promise { + if (this.useFailover) { + return await dbFailover.rollback(connection); + } + await connection.rollback(); connection.release(); } + // 獲取備援狀態 + public getFailoverStatus() { + if (this.useFailover) { + return dbFailover.getStatus(); + } + return null; + } + + // 切換資料庫 + public async switchDatabase(database: 'master' | 'slave'): Promise { + if (this.useFailover) { + return await dbFailover.switchToDatabase(database); + } + return false; + } + // 關閉連接池 public async close(): Promise { + if (this.useFailover) { + await dbFailover.close(); + } await this.pool.end(); } } diff --git a/lib/services/database-service.ts b/lib/services/database-service.ts index e53f05d..9645055 100644 --- a/lib/services/database-service.ts +++ b/lib/services/database-service.ts @@ -202,69 +202,6 @@ export class UserService { }; } - // 獲取用戶的應用和評價統計 - async getUserAppAndReviewStats(userId: string): Promise<{ - appCount: number; - reviewCount: number; - }> { - try { - console.log('獲取用戶統計數據,userId:', userId); - - // 先檢查資料庫中是否有數據 - const checkAppsSql = 'SELECT COUNT(*) as total FROM apps'; - const checkRatingsSql = 'SELECT COUNT(*) as total FROM user_ratings'; - const checkUsersSql = 'SELECT COUNT(*) as total FROM users'; - - const [totalApps, totalRatings, totalUsers] = await Promise.all([ - this.queryOne(checkAppsSql), - this.queryOne(checkRatingsSql), - this.queryOne(checkUsersSql) - ]); - - console.log('資料庫總數據:', { - totalApps: totalApps?.total || 0, - totalRatings: totalRatings?.total || 0, - totalUsers: totalUsers?.total || 0 - }); - - // 獲取用戶創建的應用數量 - const appCountSql = ` - SELECT COUNT(*) as app_count - FROM apps - WHERE creator_id = ? - `; - - // 獲取用戶撰寫的評價數量 - const reviewCountSql = ` - SELECT COUNT(*) as review_count - FROM user_ratings - WHERE user_id = ? - `; - - const [appResult, reviewResult] = await Promise.all([ - this.queryOne(appCountSql, [userId]), - this.queryOne(reviewCountSql, [userId]) - ]); - - console.log('應用查詢結果:', appResult); - console.log('評價查詢結果:', reviewResult); - - const result = { - appCount: appResult?.app_count || 0, - reviewCount: reviewResult?.review_count || 0 - }; - - console.log('最終統計結果:', result); - return result; - } catch (error) { - console.error('獲取用戶應用和評價統計錯誤:', error); - return { - appCount: 0, - reviewCount: 0 - }; - } - } - // 獲取用戶活動記錄 async getUserActivities( userId: string, @@ -804,7 +741,7 @@ export class UserService { // 獲取所有用戶 async getAllUsers(limit = 50, offset = 0): Promise { - const sql = 'SELECT * FROM users WHERE is_active = TRUE ORDER BY created_at DESC LIMIT ? OFFSET ?'; + const sql = 'SELECT * FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?'; return await db.query(sql, [limit, offset]); } @@ -839,6 +776,46 @@ export class UserService { const service = new UserService(); return await service.getAllUsers(limit, offset); } + + // 獲取用戶的應用和評價統計 + async getUserAppAndReviewStats(userId: string): Promise<{ + appCount: number; + reviewCount: number; + }> { + try { + // 獲取用戶創建的應用數量 + const appCountSql = ` + SELECT COUNT(*) as app_count + FROM apps + WHERE creator_id = ? + `; + + // 獲取用戶撰寫的評價數量 + const reviewCountSql = ` + SELECT COUNT(*) as review_count + FROM user_ratings + WHERE user_id = ? + `; + + const [appResult, reviewResult] = await Promise.all([ + this.queryOne(appCountSql, [userId]), + this.queryOne(reviewCountSql, [userId]) + ]); + + const result = { + appCount: appResult?.app_count || 0, + reviewCount: reviewResult?.review_count || 0 + }; + + return result; + } catch (error) { + console.error('獲取用戶應用和評價統計錯誤:', error); + return { + appCount: 0, + reviewCount: 0 + }; + } + } } // ===================================================== @@ -1358,7 +1335,7 @@ export class AppService { } // 獲取應用使用統計 - async getAppUsageStats(appId: string, startDate?: string, endDate?: string): Promise<{ + async getAppUsageStats(appId: string, startDate?: string, endDate?: string, department?: string): Promise<{ dailyUsers: number; weeklyUsers: number; monthlyUsers: number; @@ -1369,7 +1346,7 @@ export class AppService { try { // 今日使用者 const dailySql = ` - SELECT COUNT(DISTINCT user_id) as daily_users + SELECT COUNT(*) as daily_users FROM user_views WHERE app_id = ? AND DATE(viewed_at) = CURDATE() `; @@ -1377,7 +1354,7 @@ export class AppService { // 本週使用者 const weeklySql = ` - SELECT COUNT(DISTINCT user_id) as weekly_users + SELECT COUNT(*) as weekly_users FROM user_views WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 1 WEEK) `; @@ -1385,7 +1362,7 @@ export class AppService { // 本月使用者 const monthlySql = ` - SELECT COUNT(DISTINCT user_id) as monthly_users + SELECT COUNT(*) as monthly_users FROM user_views WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 1 MONTH) `; @@ -1399,48 +1376,73 @@ export class AppService { `; const totalResult = await this.queryOne(totalSql, [appId]); - // 部門使用統計 - const deptSql = ` - SELECT - u.department, - COUNT(*) as count - FROM user_views uv - JOIN users u ON uv.user_id = u.id - WHERE uv.app_id = ? - GROUP BY u.department - ORDER BY count DESC - LIMIT 5 - `; - const deptResult = await this.query(deptSql, [appId]); + // 部門使用統計 - 支援日期範圍過濾 + let deptSql: string; + let deptParams: any[]; - // 使用趨勢 - 支援自定義日期範圍 + if (startDate && endDate) { + deptSql = ` + SELECT + u.department, + COUNT(*) as count + FROM user_views uv + JOIN users u ON uv.user_id = u.id + WHERE uv.app_id = ? AND DATE(uv.viewed_at) BETWEEN ? AND ? + GROUP BY u.department + ORDER BY count DESC + LIMIT 5 + `; + deptParams = [appId, startDate, endDate]; + } else { + deptSql = ` + SELECT + u.department, + COUNT(*) as count + FROM user_views uv + JOIN users u ON uv.user_id = u.id + WHERE uv.app_id = ? + GROUP BY u.department + ORDER BY count DESC + LIMIT 5 + `; + deptParams = [appId]; + } + const deptResult = await this.query(deptSql, deptParams); + + // 使用趨勢 - 支援自定義日期範圍和部門過濾 let trendSql: string; let trendParams: any[]; + // 構建部門過濾條件 + const departmentFilter = department ? 'AND u.department = ?' : ''; + const baseWhere = `uv.app_id = ? ${departmentFilter}`; + if (startDate && endDate) { // 使用自定義日期範圍 trendSql = ` SELECT - DATE(viewed_at) as date, - COUNT(DISTINCT user_id) as users - FROM user_views - WHERE app_id = ? AND DATE(viewed_at) BETWEEN ? AND ? - GROUP BY DATE(viewed_at) + DATE(uv.viewed_at) as date, + COUNT(*) as users + FROM user_views uv + JOIN users u ON uv.user_id = u.id + WHERE ${baseWhere} AND DATE(uv.viewed_at) BETWEEN ? AND ? + GROUP BY DATE(uv.viewed_at) ORDER BY date ASC `; - trendParams = [appId, startDate, endDate]; + trendParams = department ? [appId, department, startDate, endDate] : [appId, startDate, endDate]; } else { // 預設過去7天 trendSql = ` SELECT - DATE(viewed_at) as date, - COUNT(DISTINCT user_id) as users - FROM user_views - WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) - GROUP BY DATE(viewed_at) + DATE(uv.viewed_at) as date, + COUNT(*) as users + FROM user_views uv + JOIN users u ON uv.user_id = u.id + WHERE ${baseWhere} AND uv.viewed_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) + GROUP BY DATE(uv.viewed_at) ORDER BY date ASC `; - trendParams = [appId]; + trendParams = department ? [appId, department] : [appId]; } const trendResult = await this.query(trendSql, trendParams); diff --git a/package.json b/package.json index 77f9458..295ad53 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,17 @@ "test:role-display": "node scripts/test-role-display.js", "test:activity-records": "node scripts/test-activity-records.js", "test:hydration-fix": "node scripts/test-hydration-fix.js", - "setup": "node scripts/setup.js" + "setup": "node scripts/setup.js", + "db:init-slave": "node scripts/init-slave-database.js", + "db:sync": "node scripts/sync-database.js", + "db:health": "node scripts/check-database-health.js", + "db:monitor": "node scripts/check-database-health.js && echo '資料庫健康檢查完成'", + "db:test-failover": "node scripts/test-failover.js", + "db:test-system": "node scripts/test-failover-system.js", + "db:test-simple": "node scripts/test-simple-failover.js", + "db:test-startup": "node scripts/test-startup-failover.js", + "db:create-ai-tables": "node scripts/create-ai-tables.js", + "db:create-ai-tables-master": "node scripts/create-ai-tables-master.js" }, "dependencies": { "@hookform/resolvers": "^3.9.1",