完成 APP 建立流程和使用分析、增加主機備機的備援機制、管理者後臺增加資料庫監控
This commit is contained in:
198
DATABASE-FAILOVER-SETUP.md
Normal file
198
DATABASE-FAILOVER-SETUP.md
Normal file
@@ -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';
|
||||||
|
|
||||||
|
// 在管理頁面中使用
|
||||||
|
<DatabaseMonitor />
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 系統狀態
|
||||||
|
|
||||||
|
### 當前配置
|
||||||
|
|
||||||
|
- **備援功能**: ✅ 已啟用
|
||||||
|
- **健康檢查間隔**: 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" 問題,您的應用程式仍然可以正常運行!
|
275
README-DATABASE-FAILOVER.md
Normal file
275
README-DATABASE-FAILOVER.md
Normal file
@@ -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';
|
||||||
|
|
||||||
|
// 在管理頁面中使用
|
||||||
|
<DatabaseMonitor />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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. 監控面板狀態
|
92
app/api/admin/database-status/route.ts
Normal file
92
app/api/admin/database-status/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
73
app/api/admin/database-sync/route.ts
Normal file
73
app/api/admin/database-sync/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
@@ -14,24 +14,19 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const users = await userService.query(sql);
|
const users = await userService.query(sql);
|
||||||
|
|
||||||
// 為每個用戶獲取統計數據
|
// 格式化用戶數據
|
||||||
const usersWithStats = await Promise.all(
|
const usersWithStats = users.map((user) => {
|
||||||
users.map(async (user) => {
|
return {
|
||||||
const stats = await userService.getUserAppAndReviewStats(user.id);
|
id: user.id,
|
||||||
return {
|
name: user.name,
|
||||||
id: user.id,
|
email: user.email,
|
||||||
name: user.name,
|
department: user.department,
|
||||||
email: user.email,
|
role: user.role,
|
||||||
department: user.department,
|
status: user.status,
|
||||||
role: user.role,
|
createdAt: user.created_at,
|
||||||
status: user.status,
|
lastLogin: user.last_login
|
||||||
createdAt: user.created_at,
|
};
|
||||||
lastLogin: user.last_login,
|
});
|
||||||
appCount: stats.appCount,
|
|
||||||
reviewCount: stats.reviewCount
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
@@ -8,9 +8,10 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
|
|||||||
const { id: appId } = await params
|
const { id: appId } = await params
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
|
|
||||||
// 獲取日期範圍參數
|
// 獲取日期範圍和部門過濾參數
|
||||||
const startDate = searchParams.get('startDate')
|
const startDate = searchParams.get('startDate')
|
||||||
const endDate = searchParams.get('endDate')
|
const endDate = searchParams.get('endDate')
|
||||||
|
const department = searchParams.get('department')
|
||||||
|
|
||||||
// 獲取應用基本統計
|
// 獲取應用基本統計
|
||||||
const app = await appService.getAppById(appId)
|
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 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)
|
const favoritesCount = await appService.getAppFavoritesCount(appId)
|
||||||
|
303
components/admin/database-monitor.tsx
Normal file
303
components/admin/database-monitor.tsx
Normal file
@@ -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<DatabaseStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
資料庫監控
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">載入中...</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
資料庫監控
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Button
|
||||||
|
onClick={fetchStatus}
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
重新載入
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
資料庫監控
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p>無法獲取資料庫狀態</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
資料庫監控
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={fetchStatus}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
監控主機和備機資料庫的連接狀態
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* 整體狀態 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">備援狀態</span>
|
||||||
|
<Badge variant={status.isEnabled ? "default" : "secondary"}>
|
||||||
|
{status.isEnabled ? "啟用" : "停用"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 當前資料庫 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">當前資料庫</span>
|
||||||
|
<Badge variant={status.currentDatabase === 'master' ? "default" : "secondary"}>
|
||||||
|
{status.currentDatabase === 'master' ? "主機" : "備機"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主機狀態 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Server className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">主機資料庫</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{status.masterHealthy ? (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
<Badge variant={status.masterHealthy ? "default" : "destructive"}>
|
||||||
|
{status.masterHealthy ? "正常" : "異常"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status.masterHealthy && (
|
||||||
|
<Button
|
||||||
|
onClick={() => switchDatabase('master')}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={switching || status.currentDatabase === 'master'}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
切換到主機
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 備機狀態 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Server className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">備機資料庫</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{status.slaveHealthy ? (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
<Badge variant={status.slaveHealthy ? "default" : "destructive"}>
|
||||||
|
{status.slaveHealthy ? "正常" : "異常"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status.slaveHealthy && (
|
||||||
|
<Button
|
||||||
|
onClick={() => switchDatabase('slave')}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={switching || status.currentDatabase === 'slave'}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
切換到備機
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 統計信息 */}
|
||||||
|
<div className="pt-4 border-t space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>連續失敗次數</span>
|
||||||
|
<span className="font-mono">{status.consecutiveFailures}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>最後檢查時間</span>
|
||||||
|
<span className="font-mono">{formatTime(status.lastHealthCheck)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>系統運行時間</span>
|
||||||
|
<span className="font-mono">{formatUptime(status.uptime)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 警告信息 */}
|
||||||
|
{status.consecutiveFailures > 0 && (
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
資料庫已連續失敗 {status.consecutiveFailures} 次,請檢查連接狀態
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import React, { useState } from "react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -25,6 +25,11 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Clock,
|
Clock,
|
||||||
|
Database,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
Globe,
|
Globe,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
@@ -72,6 +77,12 @@ export function SystemSettings() {
|
|||||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle")
|
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle")
|
||||||
const [showSmtpPassword, setShowSmtpPassword] = useState(false)
|
const [showSmtpPassword, setShowSmtpPassword] = useState(false)
|
||||||
|
|
||||||
|
// 資料庫狀態
|
||||||
|
const [databaseStatus, setDatabaseStatus] = useState<any>(null)
|
||||||
|
const [isLoadingStatus, setIsLoadingStatus] = useState(false)
|
||||||
|
const [syncStatus, setSyncStatus] = useState<any>(null)
|
||||||
|
const [isLoadingSync, setIsLoadingSync] = useState(false)
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaveStatus("saving")
|
setSaveStatus("saving")
|
||||||
// 模擬保存過程
|
// 模擬保存過程
|
||||||
@@ -90,6 +101,99 @@ export function SystemSettings() {
|
|||||||
setSettings((prev) => ({ ...prev, [key]: value }))
|
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 (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto">
|
<div className="p-6 max-w-6xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@@ -383,6 +487,243 @@ export function SystemSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 資料庫狀態監控 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Database className="w-5 h-5" />
|
||||||
|
資料庫狀態監控
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={fetchDatabaseStatus}
|
||||||
|
disabled={isLoadingStatus}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${isLoadingStatus ? 'animate-spin' : ''}`} />
|
||||||
|
重新整理
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{databaseStatus ? (
|
||||||
|
<>
|
||||||
|
{/* 備援狀態概覽 */}
|
||||||
|
<div className="p-4 bg-gray-50 border rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium">備援系統狀態</span>
|
||||||
|
<Badge variant={databaseStatus.isEnabled ? "default" : "secondary"}>
|
||||||
|
{databaseStatus.isEnabled ? "已啟用" : "已停用"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
當前使用: {databaseStatus.currentDatabase === 'master' ? '主機' : '備機'} |
|
||||||
|
連續失敗: {databaseStatus.consecutiveFailures} 次
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
最後檢查: {new Date(databaseStatus.lastHealthCheck).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主機資料庫 */}
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Server className="w-5 h-5" />
|
||||||
|
<span className="font-medium">主機資料庫</span>
|
||||||
|
{databaseStatus.currentDatabase === 'master' && (
|
||||||
|
<Badge variant="default" className="bg-green-100 text-green-800">
|
||||||
|
當前使用
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{databaseStatus.masterHealthy ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-5 h-5 text-red-500" />
|
||||||
|
)}
|
||||||
|
<Badge variant={databaseStatus.masterHealthy ? "default" : "destructive"}>
|
||||||
|
{databaseStatus.masterHealthy ? '正常' : '故障'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">主機:</span>
|
||||||
|
<span className="ml-2 font-mono">mysql.theaken.com:33306</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">資料庫:</span>
|
||||||
|
<span className="ml-2 font-mono">db_AI_Platform</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{databaseStatus.currentDatabase !== 'master' && databaseStatus.masterHealthy && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="mt-3"
|
||||||
|
onClick={() => switchDatabase('master')}
|
||||||
|
>
|
||||||
|
切換到主機
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 備機資料庫 */}
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="w-5 h-5" />
|
||||||
|
<span className="font-medium">備機資料庫</span>
|
||||||
|
{databaseStatus.currentDatabase === 'slave' && (
|
||||||
|
<Badge variant="default" className="bg-blue-100 text-blue-800">
|
||||||
|
當前使用
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{databaseStatus.slaveHealthy ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-5 h-5 text-red-500" />
|
||||||
|
)}
|
||||||
|
<Badge variant={databaseStatus.slaveHealthy ? "default" : "destructive"}>
|
||||||
|
{databaseStatus.slaveHealthy ? '正常' : '故障'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">主機:</span>
|
||||||
|
<span className="ml-2 font-mono">122.100.99.161:43306</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">資料庫:</span>
|
||||||
|
<span className="ml-2 font-mono">db_AI_Platform</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{databaseStatus.currentDatabase !== 'slave' && databaseStatus.slaveHealthy && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="mt-3"
|
||||||
|
onClick={() => switchDatabase('slave')}
|
||||||
|
>
|
||||||
|
切換到備機
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 狀態說明 */}
|
||||||
|
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-yellow-600 mt-0.5" />
|
||||||
|
<div className="text-sm text-yellow-800">
|
||||||
|
<p className="font-medium">注意事項:</p>
|
||||||
|
<ul className="mt-1 space-y-1 text-xs">
|
||||||
|
<li>• 系統會自動在健康檢查時切換資料庫</li>
|
||||||
|
<li>• 手動切換僅在目標資料庫健康時有效</li>
|
||||||
|
<li>• 建議在維護時手動切換到備機</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<Database className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{isLoadingStatus ? '載入資料庫狀態中...' : '點擊重新整理按鈕載入資料庫狀態'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 資料庫同步管理 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Database className="w-5 h-5" />
|
||||||
|
資料庫同步管理
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={fetchSyncStatus}
|
||||||
|
disabled={isLoadingSync}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${isLoadingSync ? 'animate-spin' : ''}`} />
|
||||||
|
重新整理
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{syncStatus ? (
|
||||||
|
<>
|
||||||
|
{/* 同步狀態概覽 */}
|
||||||
|
<div className="p-4 bg-gray-50 border rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium">雙寫同步狀態</span>
|
||||||
|
<Badge variant={syncStatus.enabled ? "default" : "secondary"}>
|
||||||
|
{syncStatus.enabled ? "已啟用" : "未啟用"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
<p>主機健康: {syncStatus.masterHealthy ? "正常" : "異常"}</p>
|
||||||
|
<p>備機健康: {syncStatus.slaveHealthy ? "正常" : "異常"}</p>
|
||||||
|
{syncStatus.lastSyncTime && (
|
||||||
|
<p>最後同步: {new Date(syncStatus.lastSyncTime).toLocaleString()}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 手動同步操作 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium">手動同步表資料</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{['users', 'apps', 'teams', 'competitions', 'proposals', 'activity_logs'].map((tableName) => (
|
||||||
|
<Button
|
||||||
|
key={tableName}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => syncTable(tableName)}
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
<Database className="w-4 h-4 mr-2" />
|
||||||
|
同步 {tableName}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 同步說明 */}
|
||||||
|
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-blue-600 mt-0.5" />
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium">同步說明:</p>
|
||||||
|
<ul className="mt-1 space-y-1 text-xs">
|
||||||
|
<li>• 手動同步會將主機資料複製到備機</li>
|
||||||
|
<li>• 建議在維護期間進行同步操作</li>
|
||||||
|
<li>• 同步過程中請避免對資料進行修改</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<Database className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{isLoadingSync ? '載入同步狀態中...' : '點擊重新整理按鈕載入同步狀態'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 用戶管理 */}
|
{/* 用戶管理 */}
|
||||||
|
@@ -858,7 +858,6 @@ export function UserManagement() {
|
|||||||
<TableHead>狀態</TableHead>
|
<TableHead>狀態</TableHead>
|
||||||
<TableHead>加入日期</TableHead>
|
<TableHead>加入日期</TableHead>
|
||||||
<TableHead>最後登入</TableHead>
|
<TableHead>最後登入</TableHead>
|
||||||
<TableHead>統計</TableHead>
|
|
||||||
<TableHead>操作</TableHead>
|
<TableHead>操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -901,12 +900,6 @@ export function UserManagement() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-gray-600">{user.joinDate || "-"}</TableCell>
|
<TableCell className="text-sm text-gray-600">{user.joinDate || "-"}</TableCell>
|
||||||
<TableCell className="text-sm text-gray-600">{user.lastLogin || "-"}</TableCell>
|
<TableCell className="text-sm text-gray-600">{user.lastLogin || "-"}</TableCell>
|
||||||
<TableCell>
|
|
||||||
<div className="text-sm">
|
|
||||||
<p>{user.appCount || 0} 應用</p>
|
|
||||||
<p className="text-gray-500">{user.reviewCount || 0} 評價</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
@@ -42,6 +42,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { FavoriteButton } from "./favorite-button"
|
import { FavoriteButton } from "./favorite-button"
|
||||||
import { ReviewSystem } from "./reviews/review-system"
|
import { ReviewSystem } from "./reviews/review-system"
|
||||||
|
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"
|
||||||
|
|
||||||
interface AppDetailDialogProps {
|
interface AppDetailDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -93,6 +94,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const [isLoadingStats, setIsLoadingStats] = useState(false)
|
const [isLoadingStats, setIsLoadingStats] = useState(false)
|
||||||
|
const [selectedDepartment, setSelectedDepartment] = useState<string | null>(null)
|
||||||
|
|
||||||
// Date range for usage trends
|
// Date range for usage trends
|
||||||
const [startDate, setStartDate] = useState(() => {
|
const [startDate, setStartDate] = useState(() => {
|
||||||
@@ -104,6 +106,9 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
|
|||||||
return new Date().toISOString().split("T")[0]
|
return new Date().toISOString().split("T")[0]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 圓餅圖顏色配置
|
||||||
|
const COLORS = ['#3b82f6', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#84cc16', '#f97316']
|
||||||
|
|
||||||
// 圖標映射函數
|
// 圖標映射函數
|
||||||
const getIconComponent = (iconName: string) => {
|
const getIconComponent = (iconName: string) => {
|
||||||
const iconMap: { [key: string]: any } = {
|
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
|
if (!app.id) return
|
||||||
|
|
||||||
setIsLoadingStats(true)
|
setIsLoadingStats(true)
|
||||||
@@ -161,6 +166,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
|
|||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (customStartDate) params.append('startDate', customStartDate)
|
if (customStartDate) params.append('startDate', customStartDate)
|
||||||
if (customEndDate) params.append('endDate', customEndDate)
|
if (customEndDate) params.append('endDate', customEndDate)
|
||||||
|
if (department) params.append('department', department)
|
||||||
|
|
||||||
const url = `/api/apps/${app.id}/stats${params.toString() ? `?${params.toString()}` : ''}`
|
const url = `/api/apps/${app.id}/stats${params.toString() ? `?${params.toString()}` : ''}`
|
||||||
const response = await fetch(url)
|
const response = await fetch(url)
|
||||||
@@ -189,7 +195,22 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
|
|||||||
// 處理日期範圍變更
|
// 處理日期範圍變更
|
||||||
const handleDateRangeChange = useCallback(async () => {
|
const handleDateRangeChange = useCallback(async () => {
|
||||||
if (startDate && endDate) {
|
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])
|
}, [startDate, endDate, loadAppStats])
|
||||||
|
|
||||||
@@ -543,211 +564,315 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Usage Trends with Date Range */}
|
{/* Date Range Filter */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<CardTitle>數據篩選</CardTitle>
|
||||||
<div>
|
<CardDescription>選擇日期範圍查看部門使用分布和使用趨勢</CardDescription>
|
||||||
<CardTitle>使用趨勢</CardTitle>
|
</CardHeader>
|
||||||
<CardDescription>查看指定時間範圍內的使用者活躍度</CardDescription>
|
<CardContent>
|
||||||
</div>
|
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex flex-col sm:flex-row gap-3 flex-1">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Label htmlFor="start-date" className="text-sm">
|
<Label htmlFor="start-date" className="text-sm whitespace-nowrap min-w-[60px]">
|
||||||
開始日期
|
開始日期
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="start-date"
|
id="start-date"
|
||||||
type="date"
|
type="date"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
onChange={(e) => handleDateChange(e.target.value, endDate)}
|
||||||
className="w-36"
|
className="w-36"
|
||||||
max={endDate}
|
max={endDate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Label htmlFor="end-date" className="text-sm">
|
<Label htmlFor="end-date" className="text-sm whitespace-nowrap min-w-[60px]">
|
||||||
結束日期
|
結束日期
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="end-date"
|
id="end-date"
|
||||||
type="date"
|
type="date"
|
||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
onChange={(e) => handleDateChange(startDate, e.target.value)}
|
||||||
className="w-36"
|
className="w-36"
|
||||||
min={startDate}
|
min={startDate}
|
||||||
max={new Date().toISOString().split("T")[0]}
|
max={new Date().toISOString().split("T")[0]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
onClick={handleDateRangeChange}
|
|
||||||
disabled={isLoadingStats || !startDate || !endDate}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{isLoadingStats ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500 mr-1"></div>
|
|
||||||
載入中
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'重新載入'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleDateRangeChange}
|
||||||
|
disabled={isLoadingStats || !startDate || !endDate}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{isLoadingStats ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500 mr-1"></div>
|
||||||
|
載入中
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'重新載入'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardContent>
|
||||||
<CardContent>
|
</Card>
|
||||||
<div className="space-y-4">
|
|
||||||
|
{/* Analytics Layout */}
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
|
{/* Department Usage Pie Chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>部門使用分布</CardTitle>
|
||||||
|
<CardDescription>點擊部門查看該部門的使用趨勢</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoadingStats ? (
|
{isLoadingStats ? (
|
||||||
<div className="h-80 flex items-center justify-center bg-gray-50 rounded-lg">
|
<div className="h-80 flex items-center justify-center bg-gray-50 rounded-lg">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||||
<p className="text-gray-500">載入使用趨勢數據中...</p>
|
<p className="text-gray-500">載入部門數據中...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : usageStats.trendData && usageStats.trendData.length > 0 ? (
|
) : usageStats.topDepartments && usageStats.topDepartments.length > 0 ? (
|
||||||
<>
|
<div className="space-y-4">
|
||||||
{/* Chart Container with Horizontal Scroll */}
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<div className="w-full overflow-x-auto">
|
<PieChart>
|
||||||
<div
|
<Pie
|
||||||
className="h-80 relative bg-gray-50 rounded-lg p-4"
|
data={usageStats.topDepartments.map((dept: any, index: number) => ({
|
||||||
style={{
|
name: dept.department || '未知部門',
|
||||||
minWidth: `${Math.max(800, usageStats.trendData.length * 40)}px`, // Dynamic width based on data points
|
value: dept.count,
|
||||||
}}
|
color: COLORS[index % COLORS.length]
|
||||||
>
|
}))}
|
||||||
{/* Month/Year Section Headers */}
|
cx="50%"
|
||||||
<div className="absolute top-2 left-4 right-4 flex">
|
cy="50%"
|
||||||
{(() => {
|
labelLine={false}
|
||||||
const sections = getDateSections(usageStats.trendData)
|
label={({ name, percent }) => `${name} ${((percent || 0) * 100).toFixed(0)}%`}
|
||||||
const totalBars = usageStats.trendData.length
|
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) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={COLORS[index % COLORS.length]}
|
||||||
|
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip formatter={(value: any) => [`${value} 人`, '使用人數']} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
|
||||||
return Object.entries(sections).map(([key, section]) => {
|
{/* Department Legend */}
|
||||||
const width = ((section.endIndex - section.startIndex + 1) / totalBars) * 100
|
<div className="space-y-2">
|
||||||
const left = (section.startIndex / totalBars) * 100
|
{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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={dept.department || index}
|
||||||
className="absolute text-xs font-medium text-gray-600 bg-white/90 px-2 py-1 rounded shadow-sm border"
|
className={`flex items-center justify-between p-2 rounded-lg cursor-pointer transition-colors ${
|
||||||
style={{
|
isSelected ? 'bg-blue-50 border border-blue-200' : 'hover:bg-gray-50'
|
||||||
left: `${left}%`,
|
}`}
|
||||||
width: `${width}%`,
|
onClick={() => {
|
||||||
textAlign: "center",
|
const department = dept.department === '未知部門' ? null : dept.department
|
||||||
}}
|
handleDepartmentSelect(department)
|
||||||
>
|
}}
|
||||||
{section.label}
|
>
|
||||||
</div>
|
<div className="flex items-center space-x-3">
|
||||||
)
|
|
||||||
})
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chart Bars */}
|
|
||||||
<div className="h-full flex items-end justify-between space-x-2" style={{ paddingTop: "40px" }}>
|
|
||||||
{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 (
|
|
||||||
<div
|
<div
|
||||||
key={day.date}
|
className="w-3 h-3 rounded-full"
|
||||||
className="flex-1 flex flex-col items-center group relative"
|
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
||||||
style={{ minWidth: "32px" }}
|
/>
|
||||||
>
|
<span className="font-medium">{dept.department || '未知部門'}</span>
|
||||||
{/* Month divider line */}
|
</div>
|
||||||
{isNewMonth && index > 0 && (
|
<div className="flex items-center space-x-2">
|
||||||
<div className="absolute left-0 top-0 bottom-8 w-px bg-gray-300 opacity-50" />
|
<span className="text-sm text-gray-600">{dept.count} 人</span>
|
||||||
)}
|
<span className="text-sm font-medium">{percentage}%</span>
|
||||||
|
</div>
|
||||||
<div
|
</div>
|
||||||
className="w-full flex flex-col items-center justify-end"
|
)
|
||||||
style={{ height: "200px" }}
|
})}
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full bg-gradient-to-t from-blue-500 to-purple-500 rounded-t-md transition-all duration-300 hover:from-blue-600 hover:to-purple-600 cursor-pointer relative"
|
|
||||||
style={{ height: `${normalizedHeight}%` }}
|
|
||||||
>
|
|
||||||
{/* Value label */}
|
|
||||||
<div className="absolute -top-5 left-1/2 transform -translate-x-1/2 text-xs font-medium text-gray-600 bg-white/80 px-1 rounded">
|
|
||||||
{day.users}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Consistent day-only labels */}
|
|
||||||
<div className="text-xs text-gray-500 mt-2 text-center">{currentDate.getDate()}日</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Scroll Hint */}
|
|
||||||
{usageStats.trendData && usageStats.trendData.length > 20 && (
|
|
||||||
<div className="text-xs text-gray-500 text-center">💡 提示:圖表可左右滑動查看更多數據</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="h-80 flex items-center justify-center bg-gray-50 rounded-lg">
|
<div className="h-80 flex items-center justify-center bg-gray-50 rounded-lg">
|
||||||
<div className="text-center text-gray-500">
|
<div className="text-center text-gray-500">
|
||||||
<TrendingUp className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
<Building className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
||||||
<p>在選定的日期範圍內暫無使用數據</p>
|
<p>暫無部門使用數據</p>
|
||||||
<p className="text-sm mt-1">請嘗試選擇其他日期範圍</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Department Usage */}
|
{/* Usage Trends */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>部門使用分布</CardTitle>
|
<CardTitle>使用趨勢</CardTitle>
|
||||||
<CardDescription>各部門使用者比例</CardDescription>
|
<CardDescription>
|
||||||
</CardHeader>
|
{selectedDepartment ? `${selectedDepartment} 部門的使用趨勢` : '查看指定時間範圍內的使用者活躍度'}
|
||||||
<CardContent>
|
</CardDescription>
|
||||||
<div className="space-y-4">
|
</CardHeader>
|
||||||
{usageStats.topDepartments && usageStats.topDepartments.length > 0 ? (
|
<CardContent>
|
||||||
usageStats.topDepartments.map((dept: any, index: number) => {
|
<div className="space-y-4">
|
||||||
const totalUsers = usageStats.topDepartments.reduce((sum: number, d: any) => sum + d.count, 0)
|
{isLoadingStats ? (
|
||||||
const percentage = totalUsers > 0 ? Math.round((dept.count / totalUsers) * 100) : 0
|
<div className="h-80 flex items-center justify-center bg-gray-50 rounded-lg">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||||
|
<p className="text-gray-500">載入使用趨勢數據中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : usageStats.trendData && usageStats.trendData.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* Chart Container with Horizontal Scroll */}
|
||||||
|
<div className="w-full overflow-x-auto">
|
||||||
|
<div
|
||||||
|
className="h-80 relative bg-gray-50 rounded-lg p-4"
|
||||||
|
style={{
|
||||||
|
minWidth: `${Math.max(400, usageStats.trendData.length * 80)}px`, // Dynamic width based on data points
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Month/Year Section Headers - Full Width */}
|
||||||
|
<div className="absolute top-2 left-4 right-4 flex">
|
||||||
|
{(() => {
|
||||||
|
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 (
|
return Object.entries(sections).map(([key, section]) => {
|
||||||
<div key={dept.department || index} className="flex items-center justify-between">
|
// 計算該月份在柱狀圖中的實際位置
|
||||||
<div className="flex items-center space-x-3">
|
const sectionStartBar = section.startIndex
|
||||||
<div className="w-3 h-3 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full" />
|
const sectionEndBar = section.endIndex
|
||||||
<span className="font-medium">{dept.department || '未知部門'}</span>
|
const sectionBarCount = sectionEndBar - sectionStartBar + 1
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
// 計算該月份標籤的起始位置(相對於圖表區域)
|
||||||
<span className="text-sm text-gray-600">{dept.count} 人</span>
|
const sectionLeft = sectionStartBar * (barWidth + barGap)
|
||||||
<span className="text-sm font-medium">{percentage}%</span>
|
const sectionWidth = sectionBarCount * barWidth + (sectionBarCount - 1) * barGap
|
||||||
|
|
||||||
|
// 轉換為相對於整個容器的百分比(從左邊界開始)
|
||||||
|
const containerWidth = chartLeft + totalChartWidth
|
||||||
|
const leftPercent = ((sectionLeft + chartLeft) / containerWidth) * 100
|
||||||
|
const widthPercent = (sectionWidth / containerWidth) * 100
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="absolute text-xs font-medium text-gray-700 bg-blue-50 px-3 py-1 rounded shadow-sm border border-blue-200"
|
||||||
|
style={{
|
||||||
|
left: `${leftPercent}%`,
|
||||||
|
width: `${widthPercent}%`,
|
||||||
|
textAlign: "center",
|
||||||
|
minWidth: "60px", // 確保標籤有最小寬度
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Y-axis labels and grid lines */}
|
||||||
|
<div className="absolute left-2 top-12 bottom-8 flex flex-col justify-between text-xs text-gray-500">
|
||||||
|
<span>{Math.max(...usageStats.trendData.map((d: any) => d.users))}</span>
|
||||||
|
<span>{Math.round(Math.max(...usageStats.trendData.map((d: any) => d.users)) * 0.75)}</span>
|
||||||
|
<span>{Math.round(Math.max(...usageStats.trendData.map((d: any) => d.users)) * 0.5)}</span>
|
||||||
|
<span>{Math.round(Math.max(...usageStats.trendData.map((d: any) => d.users)) * 0.25)}</span>
|
||||||
|
<span>0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid lines */}
|
||||||
|
<div className="absolute left-10 top-12 bottom-8 right-4 flex flex-col justify-between">
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((ratio, index) => (
|
||||||
|
<div key={index} className="w-full h-px bg-gray-200 opacity-50"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart Bars */}
|
||||||
|
<div className="h-full flex items-end justify-start gap-3" style={{ paddingTop: "40px", paddingLeft: "40px" }}>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={day.date}
|
||||||
|
className="flex flex-col items-center group relative"
|
||||||
|
style={{ width: "80px" }}
|
||||||
|
>
|
||||||
|
{/* Month divider line */}
|
||||||
|
{isNewMonth && index > 0 && (
|
||||||
|
<div className="absolute left-0 top-0 bottom-8 w-px bg-gray-300 opacity-50" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-full flex flex-col items-center justify-end"
|
||||||
|
style={{ height: "200px" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-8 bg-gradient-to-t from-blue-500 to-purple-500 rounded-t-md transition-all duration-300 hover:from-blue-600 hover:to-purple-600 cursor-pointer relative shadow-sm"
|
||||||
|
style={{ height: `${normalizedHeight}%` }}
|
||||||
|
>
|
||||||
|
{/* Value label */}
|
||||||
|
<div className="absolute -top-6 left-1/2 transform -translate-x-1/2 text-xs font-medium text-gray-700 bg-white/90 px-2 py-1 rounded shadow-sm border">
|
||||||
|
{day.users}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consistent day-only labels */}
|
||||||
|
<div className="text-xs text-gray-500 mt-2 text-center">{currentDate.getDate()}日</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
})
|
{/* Scroll Hint */}
|
||||||
) : (
|
{usageStats.trendData && usageStats.trendData.length > 20 && (
|
||||||
<div className="text-center text-gray-500 py-8">
|
<div className="text-xs text-gray-500 text-center">💡 提示:圖表可左右滑動查看更多數據</div>
|
||||||
<Building className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
)}
|
||||||
<p>暫無部門使用數據</p>
|
</>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="h-80 flex items-center justify-center bg-gray-50 rounded-lg">
|
||||||
</div>
|
<div className="text-center text-gray-500">
|
||||||
</CardContent>
|
<TrendingUp className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
||||||
</Card>
|
<p>在選定的日期範圍內暫無使用數據</p>
|
||||||
|
<p className="text-sm mt-1">請嘗試選擇其他日期範圍</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="reviews" className="space-y-6">
|
<TabsContent value="reviews" className="space-y-6">
|
||||||
|
@@ -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;
|
|
@@ -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;
|
|
23
env.example
23
env.example
@@ -2,13 +2,34 @@
|
|||||||
# AI 展示平台環境變數配置
|
# AI 展示平台環境變數配置
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|
||||||
# 資料庫配置
|
# ===== 主機資料庫配置 =====
|
||||||
DB_HOST=mysql.theaken.com
|
DB_HOST=mysql.theaken.com
|
||||||
DB_PORT=33306
|
DB_PORT=33306
|
||||||
DB_NAME=db_AI_Platform
|
DB_NAME=db_AI_Platform
|
||||||
DB_USER=AI_Platform
|
DB_USER=AI_Platform
|
||||||
DB_PASSWORD=Aa123456
|
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 配置
|
# DeepSeek API 配置
|
||||||
NEXT_PUBLIC_DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
NEXT_PUBLIC_DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||||
NEXT_PUBLIC_DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions
|
NEXT_PUBLIC_DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions
|
||||||
|
424
lib/database-failover.js
Normal file
424
lib/database-failover.js
Normal file
@@ -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();
|
499
lib/database-failover.ts
Normal file
499
lib/database-failover.ts
Normal file
@@ -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<void> {
|
||||||
|
try {
|
||||||
|
await this.testConnections();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 異步連接測試失敗:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化連接池
|
||||||
|
private async initializePools(): Promise<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
// 測試主機
|
||||||
|
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<void> {
|
||||||
|
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<mysql.PoolConnection> {
|
||||||
|
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<T = any>(sql: string, params?: any[]): Promise<T[]> {
|
||||||
|
const connection = await this.getConnection();
|
||||||
|
try {
|
||||||
|
const [rows] = await connection.execute(sql, params);
|
||||||
|
return rows as T[];
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 執行單一查詢
|
||||||
|
public async queryOne<T = any>(sql: string, params?: any[]): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const results = await this.query<T>(sql, params);
|
||||||
|
return results.length > 0 ? results[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('資料庫單一查詢錯誤:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 執行插入
|
||||||
|
public async insert(sql: string, params?: any[]): Promise<mysql.ResultSetHeader> {
|
||||||
|
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<mysql.ResultSetHeader> {
|
||||||
|
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<mysql.ResultSetHeader> {
|
||||||
|
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<mysql.PoolConnection> {
|
||||||
|
const connection = await this.getConnection();
|
||||||
|
await connection.beginTransaction();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交事務
|
||||||
|
public async commit(connection: mysql.PoolConnection): Promise<void> {
|
||||||
|
await connection.commit();
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回滾事務
|
||||||
|
public async rollback(connection: mysql.PoolConnection): Promise<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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';
|
229
lib/database-sync.ts
Normal file
229
lib/database-sync.ts
Normal file
@@ -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<WriteResult> {
|
||||||
|
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<WriteResult> {
|
||||||
|
return await this.dualInsert(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 雙寫刪除
|
||||||
|
async dualDelete(sql: string, params?: any[]): Promise<WriteResult> {
|
||||||
|
return await this.dualInsert(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 帶重試的執行
|
||||||
|
private async executeWithRetry(pool: mysql.Pool, sql: string, params?: any[]): Promise<any> {
|
||||||
|
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<boolean> {
|
||||||
|
// 這裡可以實現更複雜的衝突檢測邏輯
|
||||||
|
// 例如:比較主機和備機的資料是否一致
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步資料(從主機到備機)
|
||||||
|
async syncFromMasterToSlave(tableName: string, condition?: string): Promise<boolean> {
|
||||||
|
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();
|
118
lib/database.ts
118
lib/database.ts
@@ -1,8 +1,10 @@
|
|||||||
// =====================================================
|
// =====================================================
|
||||||
// 資料庫連接配置
|
// 資料庫連接配置 (整合備援功能)
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|
||||||
import mysql from 'mysql2/promise';
|
import mysql from 'mysql2/promise';
|
||||||
|
import { dbFailover } from './database-failover';
|
||||||
|
import { dbSync } from './database-sync';
|
||||||
|
|
||||||
// 資料庫配置
|
// 資料庫配置
|
||||||
const dbConfig = {
|
const dbConfig = {
|
||||||
@@ -25,19 +27,21 @@ const dbConfig = {
|
|||||||
idleTimeout: 300000,
|
idleTimeout: 300000,
|
||||||
maxIdle: 10,
|
maxIdle: 10,
|
||||||
// 添加 SSL 配置(如果需要)
|
// 添加 SSL 配置(如果需要)
|
||||||
ssl: false,
|
ssl: false as any,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 創建連接池
|
// 創建連接池
|
||||||
const pool = mysql.createPool(dbConfig);
|
const pool = mysql.createPool(dbConfig);
|
||||||
|
|
||||||
// 資料庫連接類
|
// 資料庫連接類 (整合備援功能)
|
||||||
export class Database {
|
export class Database {
|
||||||
private static instance: Database;
|
private static instance: Database;
|
||||||
private pool: mysql.Pool;
|
private pool: mysql.Pool;
|
||||||
|
private useFailover: boolean;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.pool = pool;
|
this.pool = pool;
|
||||||
|
this.useFailover = process.env.DB_FAILOVER_ENABLED === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
public static getInstance(): Database {
|
public static getInstance(): Database {
|
||||||
@@ -49,11 +53,18 @@ export class Database {
|
|||||||
|
|
||||||
// 獲取連接
|
// 獲取連接
|
||||||
public async getConnection(): Promise<mysql.PoolConnection> {
|
public async getConnection(): Promise<mysql.PoolConnection> {
|
||||||
|
if (this.useFailover) {
|
||||||
|
return await dbFailover.getConnection();
|
||||||
|
}
|
||||||
return await this.pool.getConnection();
|
return await this.pool.getConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 執行查詢
|
// 執行查詢
|
||||||
public async query<T = any>(sql: string, params?: any[]): Promise<T[]> {
|
public async query<T = any>(sql: string, params?: any[]): Promise<T[]> {
|
||||||
|
if (this.useFailover) {
|
||||||
|
return await dbFailover.query<T>(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
let connection;
|
let connection;
|
||||||
let retries = 0;
|
let retries = 0;
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
@@ -92,6 +103,10 @@ export class Database {
|
|||||||
|
|
||||||
// 執行單一查詢
|
// 執行單一查詢
|
||||||
public async queryOne<T = any>(sql: string, params?: any[]): Promise<T | null> {
|
public async queryOne<T = any>(sql: string, params?: any[]): Promise<T | null> {
|
||||||
|
if (this.useFailover) {
|
||||||
|
return await dbFailover.queryOne<T>(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await this.query<T>(sql, params);
|
const results = await this.query<T>(sql, params);
|
||||||
return results.length > 0 ? results[0] : null;
|
return results.length > 0 ? results[0] : null;
|
||||||
@@ -101,8 +116,28 @@ export class Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 執行插入
|
// 執行插入(支援雙寫)
|
||||||
public async insert(sql: string, params?: any[]): Promise<mysql.ResultSetHeader> {
|
public async insert(sql: string, params?: any[]): Promise<mysql.ResultSetHeader> {
|
||||||
|
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();
|
const connection = await this.getConnection();
|
||||||
try {
|
try {
|
||||||
const [result] = await connection.execute(sql, params);
|
const [result] = await connection.execute(sql, params);
|
||||||
@@ -112,8 +147,28 @@ export class Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 執行更新
|
// 執行更新(支援雙寫)
|
||||||
public async update(sql: string, params?: any[]): Promise<mysql.ResultSetHeader> {
|
public async update(sql: string, params?: any[]): Promise<mysql.ResultSetHeader> {
|
||||||
|
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();
|
const connection = await this.getConnection();
|
||||||
try {
|
try {
|
||||||
const [result] = await connection.execute(sql, params);
|
const [result] = await connection.execute(sql, params);
|
||||||
@@ -123,8 +178,28 @@ export class Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 執行刪除
|
// 執行刪除(支援雙寫)
|
||||||
public async delete(sql: string, params?: any[]): Promise<mysql.ResultSetHeader> {
|
public async delete(sql: string, params?: any[]): Promise<mysql.ResultSetHeader> {
|
||||||
|
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();
|
const connection = await this.getConnection();
|
||||||
try {
|
try {
|
||||||
const [result] = await connection.execute(sql, params);
|
const [result] = await connection.execute(sql, params);
|
||||||
@@ -136,6 +211,10 @@ export class Database {
|
|||||||
|
|
||||||
// 開始事務
|
// 開始事務
|
||||||
public async beginTransaction(): Promise<mysql.PoolConnection> {
|
public async beginTransaction(): Promise<mysql.PoolConnection> {
|
||||||
|
if (this.useFailover) {
|
||||||
|
return await dbFailover.beginTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
const connection = await this.getConnection();
|
const connection = await this.getConnection();
|
||||||
await connection.beginTransaction();
|
await connection.beginTransaction();
|
||||||
return connection;
|
return connection;
|
||||||
@@ -143,18 +222,45 @@ export class Database {
|
|||||||
|
|
||||||
// 提交事務
|
// 提交事務
|
||||||
public async commit(connection: mysql.PoolConnection): Promise<void> {
|
public async commit(connection: mysql.PoolConnection): Promise<void> {
|
||||||
|
if (this.useFailover) {
|
||||||
|
return await dbFailover.commit(connection);
|
||||||
|
}
|
||||||
|
|
||||||
await connection.commit();
|
await connection.commit();
|
||||||
connection.release();
|
connection.release();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回滾事務
|
// 回滾事務
|
||||||
public async rollback(connection: mysql.PoolConnection): Promise<void> {
|
public async rollback(connection: mysql.PoolConnection): Promise<void> {
|
||||||
|
if (this.useFailover) {
|
||||||
|
return await dbFailover.rollback(connection);
|
||||||
|
}
|
||||||
|
|
||||||
await connection.rollback();
|
await connection.rollback();
|
||||||
connection.release();
|
connection.release();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 獲取備援狀態
|
||||||
|
public getFailoverStatus() {
|
||||||
|
if (this.useFailover) {
|
||||||
|
return dbFailover.getStatus();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切換資料庫
|
||||||
|
public async switchDatabase(database: 'master' | 'slave'): Promise<boolean> {
|
||||||
|
if (this.useFailover) {
|
||||||
|
return await dbFailover.switchToDatabase(database);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// 關閉連接池
|
// 關閉連接池
|
||||||
public async close(): Promise<void> {
|
public async close(): Promise<void> {
|
||||||
|
if (this.useFailover) {
|
||||||
|
await dbFailover.close();
|
||||||
|
}
|
||||||
await this.pool.end();
|
await this.pool.end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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(
|
async getUserActivities(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -804,7 +741,7 @@ export class UserService {
|
|||||||
|
|
||||||
// 獲取所有用戶
|
// 獲取所有用戶
|
||||||
async getAllUsers(limit = 50, offset = 0): Promise<User[]> {
|
async getAllUsers(limit = 50, offset = 0): Promise<User[]> {
|
||||||
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<User>(sql, [limit, offset]);
|
return await db.query<User>(sql, [limit, offset]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,6 +776,46 @@ export class UserService {
|
|||||||
const service = new UserService();
|
const service = new UserService();
|
||||||
return await service.getAllUsers(limit, offset);
|
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;
|
dailyUsers: number;
|
||||||
weeklyUsers: number;
|
weeklyUsers: number;
|
||||||
monthlyUsers: number;
|
monthlyUsers: number;
|
||||||
@@ -1369,7 +1346,7 @@ export class AppService {
|
|||||||
try {
|
try {
|
||||||
// 今日使用者
|
// 今日使用者
|
||||||
const dailySql = `
|
const dailySql = `
|
||||||
SELECT COUNT(DISTINCT user_id) as daily_users
|
SELECT COUNT(*) as daily_users
|
||||||
FROM user_views
|
FROM user_views
|
||||||
WHERE app_id = ? AND DATE(viewed_at) = CURDATE()
|
WHERE app_id = ? AND DATE(viewed_at) = CURDATE()
|
||||||
`;
|
`;
|
||||||
@@ -1377,7 +1354,7 @@ export class AppService {
|
|||||||
|
|
||||||
// 本週使用者
|
// 本週使用者
|
||||||
const weeklySql = `
|
const weeklySql = `
|
||||||
SELECT COUNT(DISTINCT user_id) as weekly_users
|
SELECT COUNT(*) as weekly_users
|
||||||
FROM user_views
|
FROM user_views
|
||||||
WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 1 WEEK)
|
WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 1 WEEK)
|
||||||
`;
|
`;
|
||||||
@@ -1385,7 +1362,7 @@ export class AppService {
|
|||||||
|
|
||||||
// 本月使用者
|
// 本月使用者
|
||||||
const monthlySql = `
|
const monthlySql = `
|
||||||
SELECT COUNT(DISTINCT user_id) as monthly_users
|
SELECT COUNT(*) as monthly_users
|
||||||
FROM user_views
|
FROM user_views
|
||||||
WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 1 MONTH)
|
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 totalResult = await this.queryOne(totalSql, [appId]);
|
||||||
|
|
||||||
// 部門使用統計
|
// 部門使用統計 - 支援日期範圍過濾
|
||||||
const deptSql = `
|
let deptSql: string;
|
||||||
SELECT
|
let deptParams: any[];
|
||||||
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]);
|
|
||||||
|
|
||||||
// 使用趨勢 - 支援自定義日期範圍
|
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 trendSql: string;
|
||||||
let trendParams: any[];
|
let trendParams: any[];
|
||||||
|
|
||||||
|
// 構建部門過濾條件
|
||||||
|
const departmentFilter = department ? 'AND u.department = ?' : '';
|
||||||
|
const baseWhere = `uv.app_id = ? ${departmentFilter}`;
|
||||||
|
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
// 使用自定義日期範圍
|
// 使用自定義日期範圍
|
||||||
trendSql = `
|
trendSql = `
|
||||||
SELECT
|
SELECT
|
||||||
DATE(viewed_at) as date,
|
DATE(uv.viewed_at) as date,
|
||||||
COUNT(DISTINCT user_id) as users
|
COUNT(*) as users
|
||||||
FROM user_views
|
FROM user_views uv
|
||||||
WHERE app_id = ? AND DATE(viewed_at) BETWEEN ? AND ?
|
JOIN users u ON uv.user_id = u.id
|
||||||
GROUP BY DATE(viewed_at)
|
WHERE ${baseWhere} AND DATE(uv.viewed_at) BETWEEN ? AND ?
|
||||||
|
GROUP BY DATE(uv.viewed_at)
|
||||||
ORDER BY date ASC
|
ORDER BY date ASC
|
||||||
`;
|
`;
|
||||||
trendParams = [appId, startDate, endDate];
|
trendParams = department ? [appId, department, startDate, endDate] : [appId, startDate, endDate];
|
||||||
} else {
|
} else {
|
||||||
// 預設過去7天
|
// 預設過去7天
|
||||||
trendSql = `
|
trendSql = `
|
||||||
SELECT
|
SELECT
|
||||||
DATE(viewed_at) as date,
|
DATE(uv.viewed_at) as date,
|
||||||
COUNT(DISTINCT user_id) as users
|
COUNT(*) as users
|
||||||
FROM user_views
|
FROM user_views uv
|
||||||
WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
JOIN users u ON uv.user_id = u.id
|
||||||
GROUP BY DATE(viewed_at)
|
WHERE ${baseWhere} AND uv.viewed_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||||
|
GROUP BY DATE(uv.viewed_at)
|
||||||
ORDER BY date ASC
|
ORDER BY date ASC
|
||||||
`;
|
`;
|
||||||
trendParams = [appId];
|
trendParams = department ? [appId, department] : [appId];
|
||||||
}
|
}
|
||||||
|
|
||||||
const trendResult = await this.query(trendSql, trendParams);
|
const trendResult = await this.query(trendSql, trendParams);
|
||||||
|
12
package.json
12
package.json
@@ -25,7 +25,17 @@
|
|||||||
"test:role-display": "node scripts/test-role-display.js",
|
"test:role-display": "node scripts/test-role-display.js",
|
||||||
"test:activity-records": "node scripts/test-activity-records.js",
|
"test:activity-records": "node scripts/test-activity-records.js",
|
||||||
"test:hydration-fix": "node scripts/test-hydration-fix.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": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
|
Reference in New Issue
Block a user