完成 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 usersWithStats = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const stats = await userService.getUserAppAndReviewStats(user.id);
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
department: user.department,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
createdAt: user.created_at,
|
||||
lastLogin: user.last_login,
|
||||
appCount: stats.appCount,
|
||||
reviewCount: stats.reviewCount
|
||||
};
|
||||
})
|
||||
);
|
||||
// 格式化用戶數據
|
||||
const usersWithStats = users.map((user) => {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
department: user.department,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
createdAt: user.created_at,
|
||||
lastLogin: user.last_login
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
@@ -8,9 +8,10 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
|
||||
const { id: appId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
// 獲取日期範圍參數
|
||||
// 獲取日期範圍和部門過濾參數
|
||||
const startDate = searchParams.get('startDate')
|
||||
const endDate = searchParams.get('endDate')
|
||||
const department = searchParams.get('department')
|
||||
|
||||
// 獲取應用基本統計
|
||||
const app = await appService.getAppById(appId)
|
||||
@@ -24,8 +25,8 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
|
||||
// 獲取評分統計
|
||||
const ratingStats = await appService.getAppRatingStats(appId)
|
||||
|
||||
// 獲取使用趨勢數據(支援日期範圍)
|
||||
const usageStats = await appService.getAppUsageStats(appId, startDate || undefined, endDate || undefined)
|
||||
// 獲取使用趨勢數據(支援日期範圍和部門過濾)
|
||||
const usageStats = await appService.getAppUsageStats(appId, startDate || undefined, endDate || undefined, department || undefined)
|
||||
|
||||
// 獲取收藏數量
|
||||
const favoritesCount = await appService.getAppFavoritesCount(appId)
|
||||
|
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"
|
||||
|
||||
import { useState } from "react"
|
||||
import React, { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -25,6 +25,11 @@ import {
|
||||
CheckCircle,
|
||||
HardDrive,
|
||||
Clock,
|
||||
Database,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Globe,
|
||||
} from "lucide-react"
|
||||
|
||||
@@ -71,6 +76,12 @@ export function SystemSettings() {
|
||||
const [activeTab, setActiveTab] = useState("general")
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle")
|
||||
const [showSmtpPassword, setShowSmtpPassword] = useState(false)
|
||||
|
||||
// 資料庫狀態
|
||||
const [databaseStatus, setDatabaseStatus] = useState<any>(null)
|
||||
const [isLoadingStatus, setIsLoadingStatus] = useState(false)
|
||||
const [syncStatus, setSyncStatus] = useState<any>(null)
|
||||
const [isLoadingSync, setIsLoadingSync] = useState(false)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaveStatus("saving")
|
||||
@@ -90,6 +101,99 @@ export function SystemSettings() {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
// 獲取資料庫狀態
|
||||
const fetchDatabaseStatus = async () => {
|
||||
setIsLoadingStatus(true)
|
||||
try {
|
||||
const response = await fetch('/api/admin/database-status')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setDatabaseStatus(data.data)
|
||||
} else {
|
||||
console.error('獲取資料庫狀態失敗:', data.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('獲取資料庫狀態失敗:', error)
|
||||
} finally {
|
||||
setIsLoadingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 切換資料庫
|
||||
const switchDatabase = async (database: 'master' | 'slave') => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/database-status', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ action: 'switch', database }),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
// 重新獲取狀態
|
||||
await fetchDatabaseStatus()
|
||||
alert(data.message)
|
||||
} else {
|
||||
alert(`切換失敗: ${data.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切換資料庫失敗:', error)
|
||||
alert('切換資料庫失敗')
|
||||
}
|
||||
}
|
||||
|
||||
// 獲取同步狀態
|
||||
const fetchSyncStatus = async () => {
|
||||
setIsLoadingSync(true)
|
||||
try {
|
||||
const response = await fetch('/api/admin/database-sync')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setSyncStatus(data.data)
|
||||
} else {
|
||||
console.error('獲取同步狀態失敗:', data.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('獲取同步狀態失敗:', error)
|
||||
} finally {
|
||||
setIsLoadingSync(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 同步表資料
|
||||
const syncTable = async (tableName: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/database-sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'sync_table',
|
||||
tableName: tableName
|
||||
})
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
alert(`成功同步表 ${tableName} 到備機`)
|
||||
} else {
|
||||
alert(`同步表 ${tableName} 失敗: ${data.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('同步表失敗:', error)
|
||||
alert('同步表失敗')
|
||||
}
|
||||
}
|
||||
|
||||
// 組件載入時獲取資料庫狀態
|
||||
React.useEffect(() => {
|
||||
if (activeTab === 'performance') {
|
||||
fetchDatabaseStatus()
|
||||
fetchSyncStatus()
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -383,6 +487,243 @@ export function SystemSettings() {
|
||||
</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={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>
|
||||
|
||||
{/* 用戶管理 */}
|
||||
|
@@ -858,7 +858,6 @@ export function UserManagement() {
|
||||
<TableHead>狀態</TableHead>
|
||||
<TableHead>加入日期</TableHead>
|
||||
<TableHead>最後登入</TableHead>
|
||||
<TableHead>統計</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -901,12 +900,6 @@ export function UserManagement() {
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">{user.joinDate || "-"}</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>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
@@ -42,6 +42,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { FavoriteButton } from "./favorite-button"
|
||||
import { ReviewSystem } from "./reviews/review-system"
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"
|
||||
|
||||
interface AppDetailDialogProps {
|
||||
open: boolean
|
||||
@@ -93,6 +94,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
|
||||
}
|
||||
})
|
||||
const [isLoadingStats, setIsLoadingStats] = useState(false)
|
||||
const [selectedDepartment, setSelectedDepartment] = useState<string | null>(null)
|
||||
|
||||
// Date range for usage trends
|
||||
const [startDate, setStartDate] = useState(() => {
|
||||
@@ -104,6 +106,9 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
|
||||
return new Date().toISOString().split("T")[0]
|
||||
})
|
||||
|
||||
// 圓餅圖顏色配置
|
||||
const COLORS = ['#3b82f6', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#84cc16', '#f97316']
|
||||
|
||||
// 圖標映射函數
|
||||
const getIconComponent = (iconName: string) => {
|
||||
const iconMap: { [key: string]: any } = {
|
||||
@@ -152,7 +157,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
|
||||
}
|
||||
|
||||
// 載入應用統計數據
|
||||
const loadAppStats = useCallback(async (customStartDate?: string, customEndDate?: string) => {
|
||||
const loadAppStats = useCallback(async (customStartDate?: string, customEndDate?: string, department?: string) => {
|
||||
if (!app.id) return
|
||||
|
||||
setIsLoadingStats(true)
|
||||
@@ -161,6 +166,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
|
||||
const params = new URLSearchParams()
|
||||
if (customStartDate) params.append('startDate', customStartDate)
|
||||
if (customEndDate) params.append('endDate', customEndDate)
|
||||
if (department) params.append('department', department)
|
||||
|
||||
const url = `/api/apps/${app.id}/stats${params.toString() ? `?${params.toString()}` : ''}`
|
||||
const response = await fetch(url)
|
||||
@@ -189,7 +195,22 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
|
||||
// 處理日期範圍變更
|
||||
const handleDateRangeChange = useCallback(async () => {
|
||||
if (startDate && endDate) {
|
||||
await loadAppStats(startDate, endDate)
|
||||
await loadAppStats(startDate, endDate, selectedDepartment || undefined)
|
||||
}
|
||||
}, [startDate, endDate, selectedDepartment, loadAppStats])
|
||||
|
||||
// 處理日期變更時重置部門選擇
|
||||
const handleDateChange = useCallback((newStartDate: string, newEndDate: string) => {
|
||||
setStartDate(newStartDate)
|
||||
setEndDate(newEndDate)
|
||||
setSelectedDepartment(null) // 重置部門選擇
|
||||
}, [])
|
||||
|
||||
// 處理部門選擇
|
||||
const handleDepartmentSelect = useCallback(async (department: string | null) => {
|
||||
setSelectedDepartment(department)
|
||||
if (startDate && endDate) {
|
||||
await loadAppStats(startDate, endDate, department || undefined)
|
||||
}
|
||||
}, [startDate, endDate, loadAppStats])
|
||||
|
||||
@@ -543,211 +564,315 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Usage Trends with Date Range */}
|
||||
{/* Date Range Filter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>使用趨勢</CardTitle>
|
||||
<CardDescription>查看指定時間範圍內的使用者活躍度</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CardTitle>數據篩選</CardTitle>
|
||||
<CardDescription>選擇日期範圍查看部門使用分布和使用趨勢</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
||||
<div className="flex flex-col sm:flex-row gap-3 flex-1">
|
||||
<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>
|
||||
<Input
|
||||
id="start-date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
onChange={(e) => handleDateChange(e.target.value, endDate)}
|
||||
className="w-36"
|
||||
max={endDate}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<Input
|
||||
id="end-date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
onChange={(e) => handleDateChange(startDate, e.target.value)}
|
||||
className="w-36"
|
||||
min={startDate}
|
||||
max={new Date().toISOString().split("T")[0]}
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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 ? (
|
||||
<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>
|
||||
<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(800, usageStats.trendData.length * 40)}px`, // Dynamic width based on data points
|
||||
}}
|
||||
>
|
||||
{/* Month/Year Section Headers */}
|
||||
<div className="absolute top-2 left-4 right-4 flex">
|
||||
{(() => {
|
||||
const sections = getDateSections(usageStats.trendData)
|
||||
const totalBars = usageStats.trendData.length
|
||||
|
||||
return Object.entries(sections).map(([key, section]) => {
|
||||
const width = ((section.endIndex - section.startIndex + 1) / totalBars) * 100
|
||||
const left = (section.startIndex / totalBars) * 100
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="absolute text-xs font-medium text-gray-600 bg-white/90 px-2 py-1 rounded shadow-sm border"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</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
|
||||
key={day.date}
|
||||
className="flex-1 flex flex-col items-center group relative"
|
||||
style={{ minWidth: "32px" }}
|
||||
>
|
||||
{/* 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-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>
|
||||
) : usageStats.topDepartments && usageStats.topDepartments.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={usageStats.topDepartments.map((dept: any, index: number) => ({
|
||||
name: dept.department || '未知部門',
|
||||
value: dept.count,
|
||||
color: COLORS[index % COLORS.length]
|
||||
}))}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name} ${((percent || 0) * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
onClick={(data) => {
|
||||
const department = data.name === '未知部門' ? null : data.name
|
||||
handleDepartmentSelect(department)
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{usageStats.topDepartments.map((dept: any, index: number) => (
|
||||
<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>
|
||||
|
||||
{/* Department Legend */}
|
||||
<div className="space-y-2">
|
||||
{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 (
|
||||
<div
|
||||
key={dept.department || index}
|
||||
className={`flex items-center justify-between p-2 rounded-lg cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-blue-50 border border-blue-200' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => {
|
||||
const department = dept.department === '未知部門' ? null : dept.department
|
||||
handleDepartmentSelect(department)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
||||
/>
|
||||
<span className="font-medium">{dept.department || '未知部門'}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-600">{dept.count} 人</span>
|
||||
<span className="text-sm font-medium">{percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Scroll Hint */}
|
||||
{usageStats.trendData && usageStats.trendData.length > 20 && (
|
||||
<div className="text-xs text-gray-500 text-center">💡 提示:圖表可左右滑動查看更多數據</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-80 flex items-center justify-center bg-gray-50 rounded-lg">
|
||||
<div className="text-center text-gray-500">
|
||||
<TrendingUp className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
||||
<p>在選定的日期範圍內暫無使用數據</p>
|
||||
<p className="text-sm mt-1">請嘗試選擇其他日期範圍</p>
|
||||
<Building className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
||||
<p>暫無部門使用數據</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Department Usage */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>部門使用分布</CardTitle>
|
||||
<CardDescription>各部門使用者比例</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{usageStats.topDepartments && usageStats.topDepartments.length > 0 ? (
|
||||
usageStats.topDepartments.map((dept: any, index: number) => {
|
||||
const totalUsers = usageStats.topDepartments.reduce((sum: number, d: any) => sum + d.count, 0)
|
||||
const percentage = totalUsers > 0 ? Math.round((dept.count / totalUsers) * 100) : 0
|
||||
|
||||
return (
|
||||
<div key={dept.department || index} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full" />
|
||||
<span className="font-medium">{dept.department || '未知部門'}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-600">{dept.count} 人</span>
|
||||
<span className="text-sm font-medium">{percentage}%</span>
|
||||
{/* Usage Trends */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>使用趨勢</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedDepartment ? `${selectedDepartment} 部門的使用趨勢` : '查看指定時間範圍內的使用者活躍度'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{isLoadingStats ? (
|
||||
<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 Object.entries(sections).map(([key, section]) => {
|
||||
// 計算該月份在柱狀圖中的實際位置
|
||||
const sectionStartBar = section.startIndex
|
||||
const sectionEndBar = section.endIndex
|
||||
const sectionBarCount = sectionEndBar - sectionStartBar + 1
|
||||
|
||||
// 計算該月份標籤的起始位置(相對於圖表區域)
|
||||
const sectionLeft = sectionStartBar * (barWidth + barGap)
|
||||
const sectionWidth = sectionBarCount * barWidth + (sectionBarCount - 1) * barGap
|
||||
|
||||
// 轉換為相對於整個容器的百分比(從左邊界開始)
|
||||
const containerWidth = chartLeft + totalChartWidth
|
||||
const leftPercent = ((sectionLeft + chartLeft) / containerWidth) * 100
|
||||
const widthPercent = (sectionWidth / containerWidth) * 100
|
||||
|
||||
return (
|
||||
<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 className="text-center text-gray-500 py-8">
|
||||
<Building className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
||||
<p>暫無部門使用數據</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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="text-center text-gray-500">
|
||||
<TrendingUp className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
||||
<p>在選定的日期範圍內暫無使用數據</p>
|
||||
<p className="text-sm mt-1">請嘗試選擇其他日期範圍</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<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 展示平台環境變數配置
|
||||
# =====================================================
|
||||
|
||||
# 資料庫配置
|
||||
# ===== 主機資料庫配置 =====
|
||||
DB_HOST=mysql.theaken.com
|
||||
DB_PORT=33306
|
||||
DB_NAME=db_AI_Platform
|
||||
DB_USER=AI_Platform
|
||||
DB_PASSWORD=Aa123456
|
||||
|
||||
# ===== 備機資料庫配置 =====
|
||||
SLAVE_DB_HOST=122.100.99.161
|
||||
SLAVE_DB_PORT=43306
|
||||
SLAVE_DB_NAME=db_AI_Platform
|
||||
SLAVE_DB_USER=A999
|
||||
SLAVE_DB_PASSWORD=1023
|
||||
|
||||
# ===== 資料庫備援配置 =====
|
||||
DB_FAILOVER_ENABLED=true
|
||||
|
||||
# ===== 資料庫雙寫同步配置 =====
|
||||
DB_DUAL_WRITE_ENABLED=false
|
||||
DB_MASTER_PRIORITY=true
|
||||
DB_CONFLICT_RESOLUTION=master
|
||||
DB_RETRY_ATTEMPTS=3
|
||||
DB_RETRY_DELAY=1000
|
||||
DB_HEALTH_CHECK_INTERVAL=30000
|
||||
DB_CONNECTION_TIMEOUT=5000
|
||||
DB_RETRY_ATTEMPTS=3
|
||||
DB_RETRY_DELAY=2000
|
||||
|
||||
# DeepSeek API 配置
|
||||
NEXT_PUBLIC_DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||
NEXT_PUBLIC_DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions
|
||||
|
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 { dbFailover } from './database-failover';
|
||||
import { dbSync } from './database-sync';
|
||||
|
||||
// 資料庫配置
|
||||
const dbConfig = {
|
||||
@@ -25,19 +27,21 @@ const dbConfig = {
|
||||
idleTimeout: 300000,
|
||||
maxIdle: 10,
|
||||
// 添加 SSL 配置(如果需要)
|
||||
ssl: false,
|
||||
ssl: false as any,
|
||||
};
|
||||
|
||||
// 創建連接池
|
||||
const pool = mysql.createPool(dbConfig);
|
||||
|
||||
// 資料庫連接類
|
||||
// 資料庫連接類 (整合備援功能)
|
||||
export class Database {
|
||||
private static instance: Database;
|
||||
private pool: mysql.Pool;
|
||||
private useFailover: boolean;
|
||||
|
||||
private constructor() {
|
||||
this.pool = pool;
|
||||
this.useFailover = process.env.DB_FAILOVER_ENABLED === 'true';
|
||||
}
|
||||
|
||||
public static getInstance(): Database {
|
||||
@@ -49,11 +53,18 @@ export class Database {
|
||||
|
||||
// 獲取連接
|
||||
public async getConnection(): Promise<mysql.PoolConnection> {
|
||||
if (this.useFailover) {
|
||||
return await dbFailover.getConnection();
|
||||
}
|
||||
return await this.pool.getConnection();
|
||||
}
|
||||
|
||||
// 執行查詢
|
||||
public async query<T = any>(sql: string, params?: any[]): Promise<T[]> {
|
||||
if (this.useFailover) {
|
||||
return await dbFailover.query<T>(sql, params);
|
||||
}
|
||||
|
||||
let connection;
|
||||
let retries = 0;
|
||||
const maxRetries = 3;
|
||||
@@ -92,6 +103,10 @@ export class Database {
|
||||
|
||||
// 執行單一查詢
|
||||
public async queryOne<T = any>(sql: string, params?: any[]): Promise<T | null> {
|
||||
if (this.useFailover) {
|
||||
return await dbFailover.queryOne<T>(sql, params);
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await this.query<T>(sql, params);
|
||||
return results.length > 0 ? results[0] : null;
|
||||
@@ -101,8 +116,28 @@ export class Database {
|
||||
}
|
||||
}
|
||||
|
||||
// 執行插入
|
||||
// 執行插入(支援雙寫)
|
||||
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();
|
||||
try {
|
||||
const [result] = await connection.execute(sql, params);
|
||||
@@ -112,8 +147,28 @@ export class Database {
|
||||
}
|
||||
}
|
||||
|
||||
// 執行更新
|
||||
// 執行更新(支援雙寫)
|
||||
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();
|
||||
try {
|
||||
const [result] = await connection.execute(sql, params);
|
||||
@@ -123,8 +178,28 @@ export class Database {
|
||||
}
|
||||
}
|
||||
|
||||
// 執行刪除
|
||||
// 執行刪除(支援雙寫)
|
||||
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();
|
||||
try {
|
||||
const [result] = await connection.execute(sql, params);
|
||||
@@ -136,6 +211,10 @@ export class Database {
|
||||
|
||||
// 開始事務
|
||||
public async beginTransaction(): Promise<mysql.PoolConnection> {
|
||||
if (this.useFailover) {
|
||||
return await dbFailover.beginTransaction();
|
||||
}
|
||||
|
||||
const connection = await this.getConnection();
|
||||
await connection.beginTransaction();
|
||||
return connection;
|
||||
@@ -143,18 +222,45 @@ export class Database {
|
||||
|
||||
// 提交事務
|
||||
public async commit(connection: mysql.PoolConnection): Promise<void> {
|
||||
if (this.useFailover) {
|
||||
return await dbFailover.commit(connection);
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
connection.release();
|
||||
}
|
||||
|
||||
// 回滾事務
|
||||
public async rollback(connection: mysql.PoolConnection): Promise<void> {
|
||||
if (this.useFailover) {
|
||||
return await dbFailover.rollback(connection);
|
||||
}
|
||||
|
||||
await connection.rollback();
|
||||
connection.release();
|
||||
}
|
||||
|
||||
// 獲取備援狀態
|
||||
public getFailoverStatus() {
|
||||
if (this.useFailover) {
|
||||
return dbFailover.getStatus();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 切換資料庫
|
||||
public async switchDatabase(database: 'master' | 'slave'): Promise<boolean> {
|
||||
if (this.useFailover) {
|
||||
return await dbFailover.switchToDatabase(database);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 關閉連接池
|
||||
public async close(): Promise<void> {
|
||||
if (this.useFailover) {
|
||||
await dbFailover.close();
|
||||
}
|
||||
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(
|
||||
userId: string,
|
||||
@@ -804,7 +741,7 @@ export class UserService {
|
||||
|
||||
// 獲取所有用戶
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -839,6 +776,46 @@ export class UserService {
|
||||
const service = new UserService();
|
||||
return await service.getAllUsers(limit, offset);
|
||||
}
|
||||
|
||||
// 獲取用戶的應用和評價統計
|
||||
async getUserAppAndReviewStats(userId: string): Promise<{
|
||||
appCount: number;
|
||||
reviewCount: number;
|
||||
}> {
|
||||
try {
|
||||
// 獲取用戶創建的應用數量
|
||||
const appCountSql = `
|
||||
SELECT COUNT(*) as app_count
|
||||
FROM apps
|
||||
WHERE creator_id = ?
|
||||
`;
|
||||
|
||||
// 獲取用戶撰寫的評價數量
|
||||
const reviewCountSql = `
|
||||
SELECT COUNT(*) as review_count
|
||||
FROM user_ratings
|
||||
WHERE user_id = ?
|
||||
`;
|
||||
|
||||
const [appResult, reviewResult] = await Promise.all([
|
||||
this.queryOne(appCountSql, [userId]),
|
||||
this.queryOne(reviewCountSql, [userId])
|
||||
]);
|
||||
|
||||
const result = {
|
||||
appCount: appResult?.app_count || 0,
|
||||
reviewCount: reviewResult?.review_count || 0
|
||||
};
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('獲取用戶應用和評價統計錯誤:', error);
|
||||
return {
|
||||
appCount: 0,
|
||||
reviewCount: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
@@ -1358,7 +1335,7 @@ export class AppService {
|
||||
}
|
||||
|
||||
// 獲取應用使用統計
|
||||
async getAppUsageStats(appId: string, startDate?: string, endDate?: string): Promise<{
|
||||
async getAppUsageStats(appId: string, startDate?: string, endDate?: string, department?: string): Promise<{
|
||||
dailyUsers: number;
|
||||
weeklyUsers: number;
|
||||
monthlyUsers: number;
|
||||
@@ -1369,7 +1346,7 @@ export class AppService {
|
||||
try {
|
||||
// 今日使用者
|
||||
const dailySql = `
|
||||
SELECT COUNT(DISTINCT user_id) as daily_users
|
||||
SELECT COUNT(*) as daily_users
|
||||
FROM user_views
|
||||
WHERE app_id = ? AND DATE(viewed_at) = CURDATE()
|
||||
`;
|
||||
@@ -1377,7 +1354,7 @@ export class AppService {
|
||||
|
||||
// 本週使用者
|
||||
const weeklySql = `
|
||||
SELECT COUNT(DISTINCT user_id) as weekly_users
|
||||
SELECT COUNT(*) as weekly_users
|
||||
FROM user_views
|
||||
WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 1 WEEK)
|
||||
`;
|
||||
@@ -1385,7 +1362,7 @@ export class AppService {
|
||||
|
||||
// 本月使用者
|
||||
const monthlySql = `
|
||||
SELECT COUNT(DISTINCT user_id) as monthly_users
|
||||
SELECT COUNT(*) as monthly_users
|
||||
FROM user_views
|
||||
WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 1 MONTH)
|
||||
`;
|
||||
@@ -1399,48 +1376,73 @@ export class AppService {
|
||||
`;
|
||||
const totalResult = await this.queryOne(totalSql, [appId]);
|
||||
|
||||
// 部門使用統計
|
||||
const deptSql = `
|
||||
SELECT
|
||||
u.department,
|
||||
COUNT(*) as count
|
||||
FROM user_views uv
|
||||
JOIN users u ON uv.user_id = u.id
|
||||
WHERE uv.app_id = ?
|
||||
GROUP BY u.department
|
||||
ORDER BY count DESC
|
||||
LIMIT 5
|
||||
`;
|
||||
const deptResult = await this.query(deptSql, [appId]);
|
||||
// 部門使用統計 - 支援日期範圍過濾
|
||||
let deptSql: string;
|
||||
let deptParams: any[];
|
||||
|
||||
// 使用趨勢 - 支援自定義日期範圍
|
||||
if (startDate && endDate) {
|
||||
deptSql = `
|
||||
SELECT
|
||||
u.department,
|
||||
COUNT(*) as count
|
||||
FROM user_views uv
|
||||
JOIN users u ON uv.user_id = u.id
|
||||
WHERE uv.app_id = ? AND DATE(uv.viewed_at) BETWEEN ? AND ?
|
||||
GROUP BY u.department
|
||||
ORDER BY count DESC
|
||||
LIMIT 5
|
||||
`;
|
||||
deptParams = [appId, startDate, endDate];
|
||||
} else {
|
||||
deptSql = `
|
||||
SELECT
|
||||
u.department,
|
||||
COUNT(*) as count
|
||||
FROM user_views uv
|
||||
JOIN users u ON uv.user_id = u.id
|
||||
WHERE uv.app_id = ?
|
||||
GROUP BY u.department
|
||||
ORDER BY count DESC
|
||||
LIMIT 5
|
||||
`;
|
||||
deptParams = [appId];
|
||||
}
|
||||
const deptResult = await this.query(deptSql, deptParams);
|
||||
|
||||
// 使用趨勢 - 支援自定義日期範圍和部門過濾
|
||||
let trendSql: string;
|
||||
let trendParams: any[];
|
||||
|
||||
// 構建部門過濾條件
|
||||
const departmentFilter = department ? 'AND u.department = ?' : '';
|
||||
const baseWhere = `uv.app_id = ? ${departmentFilter}`;
|
||||
|
||||
if (startDate && endDate) {
|
||||
// 使用自定義日期範圍
|
||||
trendSql = `
|
||||
SELECT
|
||||
DATE(viewed_at) as date,
|
||||
COUNT(DISTINCT user_id) as users
|
||||
FROM user_views
|
||||
WHERE app_id = ? AND DATE(viewed_at) BETWEEN ? AND ?
|
||||
GROUP BY DATE(viewed_at)
|
||||
DATE(uv.viewed_at) as date,
|
||||
COUNT(*) as users
|
||||
FROM user_views uv
|
||||
JOIN users u ON uv.user_id = u.id
|
||||
WHERE ${baseWhere} AND DATE(uv.viewed_at) BETWEEN ? AND ?
|
||||
GROUP BY DATE(uv.viewed_at)
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
trendParams = [appId, startDate, endDate];
|
||||
trendParams = department ? [appId, department, startDate, endDate] : [appId, startDate, endDate];
|
||||
} else {
|
||||
// 預設過去7天
|
||||
trendSql = `
|
||||
SELECT
|
||||
DATE(viewed_at) as date,
|
||||
COUNT(DISTINCT user_id) as users
|
||||
FROM user_views
|
||||
WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||
GROUP BY DATE(viewed_at)
|
||||
DATE(uv.viewed_at) as date,
|
||||
COUNT(*) as users
|
||||
FROM user_views uv
|
||||
JOIN users u ON uv.user_id = u.id
|
||||
WHERE ${baseWhere} AND uv.viewed_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||
GROUP BY DATE(uv.viewed_at)
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
trendParams = [appId];
|
||||
trendParams = department ? [appId, department] : [appId];
|
||||
}
|
||||
|
||||
const trendResult = await this.query(trendSql, trendParams);
|
||||
|
12
package.json
12
package.json
@@ -25,7 +25,17 @@
|
||||
"test:role-display": "node scripts/test-role-display.js",
|
||||
"test:activity-records": "node scripts/test-activity-records.js",
|
||||
"test:hydration-fix": "node scripts/test-hydration-fix.js",
|
||||
"setup": "node scripts/setup.js"
|
||||
"setup": "node scripts/setup.js",
|
||||
"db:init-slave": "node scripts/init-slave-database.js",
|
||||
"db:sync": "node scripts/sync-database.js",
|
||||
"db:health": "node scripts/check-database-health.js",
|
||||
"db:monitor": "node scripts/check-database-health.js && echo '資料庫健康檢查完成'",
|
||||
"db:test-failover": "node scripts/test-failover.js",
|
||||
"db:test-system": "node scripts/test-failover-system.js",
|
||||
"db:test-simple": "node scripts/test-simple-failover.js",
|
||||
"db:test-startup": "node scripts/test-startup-failover.js",
|
||||
"db:create-ai-tables": "node scripts/create-ai-tables.js",
|
||||
"db:create-ai-tables-master": "node scripts/create-ai-tables-master.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
|
Reference in New Issue
Block a user