完成 APP 建立流程和使用分析、增加主機備機的備援機制、管理者後臺增加資料庫監控

This commit is contained in:
2025-09-12 18:22:30 +08:00
parent 9c5dceb001
commit b85a9ce95e
19 changed files with 2982 additions and 757 deletions

198
DATABASE-FAILOVER-SETUP.md Normal file
View 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
View 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. 監控面板狀態

View 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 });
}
}

View 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 });
}
}

View File

@@ -14,10 +14,8 @@ export async function GET(request: NextRequest) {
const users = await userService.query(sql); const users = await userService.query(sql);
// 為每個用戶獲取統計數據 // 格式化用戶數據
const usersWithStats = await Promise.all( const usersWithStats = users.map((user) => {
users.map(async (user) => {
const stats = await userService.getUserAppAndReviewStats(user.id);
return { return {
id: user.id, id: user.id,
name: user.name, name: user.name,
@@ -26,12 +24,9 @@ export async function GET(request: NextRequest) {
role: user.role, role: user.role,
status: user.status, status: user.status,
createdAt: user.created_at, createdAt: user.created_at,
lastLogin: user.last_login, lastLogin: user.last_login
appCount: stats.appCount,
reviewCount: stats.reviewCount
}; };
}) });
);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,

View File

@@ -8,9 +8,10 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
const { id: appId } = await params const { id: appId } = await params
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
// 獲取日期範圍參數 // 獲取日期範圍和部門過濾參數
const startDate = searchParams.get('startDate') const startDate = searchParams.get('startDate')
const endDate = searchParams.get('endDate') const endDate = searchParams.get('endDate')
const department = searchParams.get('department')
// 獲取應用基本統計 // 獲取應用基本統計
const app = await appService.getAppById(appId) const app = await appService.getAppById(appId)
@@ -24,8 +25,8 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
// 獲取評分統計 // 獲取評分統計
const ratingStats = await appService.getAppRatingStats(appId) const ratingStats = await appService.getAppRatingStats(appId)
// 獲取使用趨勢數據(支援日期範圍) // 獲取使用趨勢數據(支援日期範圍和部門過濾
const usageStats = await appService.getAppUsageStats(appId, startDate || undefined, endDate || undefined) const usageStats = await appService.getAppUsageStats(appId, startDate || undefined, endDate || undefined, department || undefined)
// 獲取收藏數量 // 獲取收藏數量
const favoritesCount = await appService.getAppFavoritesCount(appId) const favoritesCount = await appService.getAppFavoritesCount(appId)

View 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>
);
}

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from "react" import React, { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -25,6 +25,11 @@ import {
CheckCircle, CheckCircle,
HardDrive, HardDrive,
Clock, Clock,
Database,
RefreshCw,
AlertCircle,
CheckCircle2,
XCircle,
Globe, Globe,
} from "lucide-react" } from "lucide-react"
@@ -72,6 +77,12 @@ export function SystemSettings() {
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle") const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle")
const [showSmtpPassword, setShowSmtpPassword] = useState(false) const [showSmtpPassword, setShowSmtpPassword] = useState(false)
// 資料庫狀態
const [databaseStatus, setDatabaseStatus] = useState<any>(null)
const [isLoadingStatus, setIsLoadingStatus] = useState(false)
const [syncStatus, setSyncStatus] = useState<any>(null)
const [isLoadingSync, setIsLoadingSync] = useState(false)
const handleSave = async () => { const handleSave = async () => {
setSaveStatus("saving") setSaveStatus("saving")
// 模擬保存過程 // 模擬保存過程
@@ -90,6 +101,99 @@ export function SystemSettings() {
setSettings((prev) => ({ ...prev, [key]: value })) setSettings((prev) => ({ ...prev, [key]: value }))
} }
// 獲取資料庫狀態
const fetchDatabaseStatus = async () => {
setIsLoadingStatus(true)
try {
const response = await fetch('/api/admin/database-status')
const data = await response.json()
if (data.success) {
setDatabaseStatus(data.data)
} else {
console.error('獲取資料庫狀態失敗:', data.error)
}
} catch (error) {
console.error('獲取資料庫狀態失敗:', error)
} finally {
setIsLoadingStatus(false)
}
}
// 切換資料庫
const switchDatabase = async (database: 'master' | 'slave') => {
try {
const response = await fetch('/api/admin/database-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ action: 'switch', database }),
})
const data = await response.json()
if (data.success) {
// 重新獲取狀態
await fetchDatabaseStatus()
alert(data.message)
} else {
alert(`切換失敗: ${data.message}`)
}
} catch (error) {
console.error('切換資料庫失敗:', error)
alert('切換資料庫失敗')
}
}
// 獲取同步狀態
const fetchSyncStatus = async () => {
setIsLoadingSync(true)
try {
const response = await fetch('/api/admin/database-sync')
const data = await response.json()
if (data.success) {
setSyncStatus(data.data)
} else {
console.error('獲取同步狀態失敗:', data.error)
}
} catch (error) {
console.error('獲取同步狀態失敗:', error)
} finally {
setIsLoadingSync(false)
}
}
// 同步表資料
const syncTable = async (tableName: string) => {
try {
const response = await fetch('/api/admin/database-sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'sync_table',
tableName: tableName
})
})
const data = await response.json()
if (data.success) {
alert(`成功同步表 ${tableName} 到備機`)
} else {
alert(`同步表 ${tableName} 失敗: ${data.message}`)
}
} catch (error) {
console.error('同步表失敗:', error)
alert('同步表失敗')
}
}
// 組件載入時獲取資料庫狀態
React.useEffect(() => {
if (activeTab === 'performance') {
fetchDatabaseStatus()
fetchSyncStatus()
}
}, [activeTab])
return ( return (
<div className="p-6 max-w-6xl mx-auto"> <div className="p-6 max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -383,6 +487,243 @@ export function SystemSettings() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* 資料庫狀態監控 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5" />
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={fetchDatabaseStatus}
disabled={isLoadingStatus}
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoadingStatus ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{databaseStatus ? (
<>
{/* 備援狀態概覽 */}
<div className="p-4 bg-gray-50 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-medium"></span>
<Badge variant={databaseStatus.isEnabled ? "default" : "secondary"}>
{databaseStatus.isEnabled ? "已啟用" : "已停用"}
</Badge>
</div>
<div className="text-sm text-muted-foreground">
使: {databaseStatus.currentDatabase === 'master' ? '主機' : '備機'} |
: {databaseStatus.consecutiveFailures}
</div>
<div className="text-xs text-muted-foreground mt-1">
: {new Date(databaseStatus.lastHealthCheck).toLocaleString()}
</div>
</div>
{/* 主機資料庫 */}
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Server className="w-5 h-5" />
<span className="font-medium"></span>
{databaseStatus.currentDatabase === 'master' && (
<Badge variant="default" className="bg-green-100 text-green-800">
使
</Badge>
)}
</div>
<div className="flex items-center gap-2">
{databaseStatus.masterHealthy ? (
<CheckCircle2 className="w-5 h-5 text-green-500" />
) : (
<XCircle className="w-5 h-5 text-red-500" />
)}
<Badge variant={databaseStatus.masterHealthy ? "default" : "destructive"}>
{databaseStatus.masterHealthy ? '正常' : '故障'}
</Badge>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-mono">mysql.theaken.com:33306</span>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-mono">db_AI_Platform</span>
</div>
</div>
{databaseStatus.currentDatabase !== 'master' && databaseStatus.masterHealthy && (
<Button
size="sm"
className="mt-3"
onClick={() => switchDatabase('master')}
>
</Button>
)}
</div>
{/* 備機資料庫 */}
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Database className="w-5 h-5" />
<span className="font-medium"></span>
{databaseStatus.currentDatabase === 'slave' && (
<Badge variant="default" className="bg-blue-100 text-blue-800">
使
</Badge>
)}
</div>
<div className="flex items-center gap-2">
{databaseStatus.slaveHealthy ? (
<CheckCircle2 className="w-5 h-5 text-green-500" />
) : (
<XCircle className="w-5 h-5 text-red-500" />
)}
<Badge variant={databaseStatus.slaveHealthy ? "default" : "destructive"}>
{databaseStatus.slaveHealthy ? '正常' : '故障'}
</Badge>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-mono">122.100.99.161:43306</span>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-2 font-mono">db_AI_Platform</span>
</div>
</div>
{databaseStatus.currentDatabase !== 'slave' && databaseStatus.slaveHealthy && (
<Button
size="sm"
className="mt-3"
onClick={() => switchDatabase('slave')}
>
</Button>
)}
</div>
{/* 狀態說明 */}
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-yellow-600 mt-0.5" />
<div className="text-sm text-yellow-800">
<p className="font-medium">:</p>
<ul className="mt-1 space-y-1 text-xs">
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</div>
</>
) : (
<div className="p-8 text-center">
<Database className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
{isLoadingStatus ? '載入資料庫狀態中...' : '點擊重新整理按鈕載入資料庫狀態'}
</p>
</div>
)}
</CardContent>
</Card>
{/* 資料庫同步管理 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5" />
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={fetchSyncStatus}
disabled={isLoadingSync}
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoadingSync ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{syncStatus ? (
<>
{/* 同步狀態概覽 */}
<div className="p-4 bg-gray-50 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-medium"></span>
<Badge variant={syncStatus.enabled ? "default" : "secondary"}>
{syncStatus.enabled ? "已啟用" : "未啟用"}
</Badge>
</div>
<div className="text-sm text-gray-600">
<p>: {syncStatus.masterHealthy ? "正常" : "異常"}</p>
<p>: {syncStatus.slaveHealthy ? "正常" : "異常"}</p>
{syncStatus.lastSyncTime && (
<p>: {new Date(syncStatus.lastSyncTime).toLocaleString()}</p>
)}
</div>
</div>
{/* 手動同步操作 */}
<div className="space-y-3">
<h4 className="font-medium"></h4>
<div className="grid grid-cols-2 gap-2">
{['users', 'apps', 'teams', 'competitions', 'proposals', 'activity_logs'].map((tableName) => (
<Button
key={tableName}
variant="outline"
size="sm"
onClick={() => syncTable(tableName)}
className="justify-start"
>
<Database className="w-4 h-4 mr-2" />
{tableName}
</Button>
))}
</div>
</div>
{/* 同步說明 */}
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-blue-600 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium">:</p>
<ul className="mt-1 space-y-1 text-xs">
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</div>
</>
) : (
<div className="p-8 text-center">
<Database className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
{isLoadingSync ? '載入同步狀態中...' : '點擊重新整理按鈕載入同步狀態'}
</p>
</div>
)}
</CardContent>
</Card>
</TabsContent> </TabsContent>
{/* 用戶管理 */} {/* 用戶管理 */}

View File

@@ -858,7 +858,6 @@ export function UserManagement() {
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -901,12 +900,6 @@ export function UserManagement() {
</TableCell> </TableCell>
<TableCell className="text-sm text-gray-600">{user.joinDate || "-"}</TableCell> <TableCell className="text-sm text-gray-600">{user.joinDate || "-"}</TableCell>
<TableCell className="text-sm text-gray-600">{user.lastLogin || "-"}</TableCell> <TableCell className="text-sm text-gray-600">{user.lastLogin || "-"}</TableCell>
<TableCell>
<div className="text-sm">
<p>{user.appCount || 0} </p>
<p className="text-gray-500">{user.reviewCount || 0} </p>
</div>
</TableCell>
<TableCell> <TableCell>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>

View File

@@ -42,6 +42,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { FavoriteButton } from "./favorite-button" import { FavoriteButton } from "./favorite-button"
import { ReviewSystem } from "./reviews/review-system" import { ReviewSystem } from "./reviews/review-system"
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"
interface AppDetailDialogProps { interface AppDetailDialogProps {
open: boolean open: boolean
@@ -93,6 +94,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
} }
}) })
const [isLoadingStats, setIsLoadingStats] = useState(false) const [isLoadingStats, setIsLoadingStats] = useState(false)
const [selectedDepartment, setSelectedDepartment] = useState<string | null>(null)
// Date range for usage trends // Date range for usage trends
const [startDate, setStartDate] = useState(() => { const [startDate, setStartDate] = useState(() => {
@@ -104,6 +106,9 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
return new Date().toISOString().split("T")[0] return new Date().toISOString().split("T")[0]
}) })
// 圓餅圖顏色配置
const COLORS = ['#3b82f6', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#84cc16', '#f97316']
// 圖標映射函數 // 圖標映射函數
const getIconComponent = (iconName: string) => { const getIconComponent = (iconName: string) => {
const iconMap: { [key: string]: any } = { const iconMap: { [key: string]: any } = {
@@ -152,7 +157,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
} }
// 載入應用統計數據 // 載入應用統計數據
const loadAppStats = useCallback(async (customStartDate?: string, customEndDate?: string) => { const loadAppStats = useCallback(async (customStartDate?: string, customEndDate?: string, department?: string) => {
if (!app.id) return if (!app.id) return
setIsLoadingStats(true) setIsLoadingStats(true)
@@ -161,6 +166,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
const params = new URLSearchParams() const params = new URLSearchParams()
if (customStartDate) params.append('startDate', customStartDate) if (customStartDate) params.append('startDate', customStartDate)
if (customEndDate) params.append('endDate', customEndDate) if (customEndDate) params.append('endDate', customEndDate)
if (department) params.append('department', department)
const url = `/api/apps/${app.id}/stats${params.toString() ? `?${params.toString()}` : ''}` const url = `/api/apps/${app.id}/stats${params.toString() ? `?${params.toString()}` : ''}`
const response = await fetch(url) const response = await fetch(url)
@@ -189,7 +195,22 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
// 處理日期範圍變更 // 處理日期範圍變更
const handleDateRangeChange = useCallback(async () => { const handleDateRangeChange = useCallback(async () => {
if (startDate && endDate) { if (startDate && endDate) {
await loadAppStats(startDate, endDate) await loadAppStats(startDate, endDate, selectedDepartment || undefined)
}
}, [startDate, endDate, selectedDepartment, loadAppStats])
// 處理日期變更時重置部門選擇
const handleDateChange = useCallback((newStartDate: string, newEndDate: string) => {
setStartDate(newStartDate)
setEndDate(newEndDate)
setSelectedDepartment(null) // 重置部門選擇
}, [])
// 處理部門選擇
const handleDepartmentSelect = useCallback(async (department: string | null) => {
setSelectedDepartment(department)
if (startDate && endDate) {
await loadAppStats(startDate, endDate, department || undefined)
} }
}, [startDate, endDate, loadAppStats]) }, [startDate, endDate, loadAppStats])
@@ -543,47 +564,49 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</Card> </Card>
</div> </div>
{/* Usage Trends with Date Range */} {/* Date Range Filter */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <CardTitle></CardTitle>
<div> <CardDescription>使使</CardDescription>
<CardTitle>使</CardTitle> </CardHeader>
<CardDescription>使</CardDescription> <CardContent>
</div> <div className="flex flex-col sm:flex-row gap-3 items-end">
<div className="flex flex-col sm:flex-row gap-3 flex-1">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="flex items-center space-x-2"> <Label htmlFor="start-date" className="text-sm whitespace-nowrap min-w-[60px]">
<Label htmlFor="start-date" className="text-sm">
</Label> </Label>
<Input <Input
id="start-date" id="start-date"
type="date" type="date"
value={startDate} value={startDate}
onChange={(e) => setStartDate(e.target.value)} onChange={(e) => handleDateChange(e.target.value, endDate)}
className="w-36" className="w-36"
max={endDate} max={endDate}
/> />
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Label htmlFor="end-date" className="text-sm"> <Label htmlFor="end-date" className="text-sm whitespace-nowrap min-w-[60px]">
</Label> </Label>
<Input <Input
id="end-date" id="end-date"
type="date" type="date"
value={endDate} value={endDate}
onChange={(e) => setEndDate(e.target.value)} onChange={(e) => handleDateChange(startDate, e.target.value)}
className="w-36" className="w-36"
min={startDate} min={startDate}
max={new Date().toISOString().split("T")[0]} max={new Date().toISOString().split("T")[0]}
/> />
</div> </div>
</div>
<Button <Button
onClick={handleDateRangeChange} onClick={handleDateRangeChange}
disabled={isLoadingStats || !startDate || !endDate} disabled={isLoadingStats || !startDate || !endDate}
size="sm" size="sm"
variant="outline" variant="outline"
className="whitespace-nowrap"
> >
{isLoadingStats ? ( {isLoadingStats ? (
<> <>
@@ -595,7 +618,112 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
)} )}
</Button> </Button>
</div> </div>
</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>
</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>
</div>
) : (
<div className="h-80 flex items-center justify-center bg-gray-50 rounded-lg">
<div className="text-center text-gray-500">
<Building className="w-12 h-12 mx-auto mb-2 text-gray-300" />
<p>使</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Usage Trends */}
<Card>
<CardHeader>
<CardTitle>使</CardTitle>
<CardDescription>
{selectedDepartment ? `${selectedDepartment} 部門的使用趨勢` : '查看指定時間範圍內的使用者活躍度'}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
@@ -613,27 +741,43 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
<div <div
className="h-80 relative bg-gray-50 rounded-lg p-4" className="h-80 relative bg-gray-50 rounded-lg p-4"
style={{ style={{
minWidth: `${Math.max(800, usageStats.trendData.length * 40)}px`, // Dynamic width based on data points minWidth: `${Math.max(400, usageStats.trendData.length * 80)}px`, // Dynamic width based on data points
}} }}
> >
{/* Month/Year Section Headers */} {/* Month/Year Section Headers - Full Width */}
<div className="absolute top-2 left-4 right-4 flex"> <div className="absolute top-2 left-4 right-4 flex">
{(() => { {(() => {
const sections = getDateSections(usageStats.trendData) const sections = getDateSections(usageStats.trendData)
const totalBars = usageStats.trendData.length 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]) => { return Object.entries(sections).map(([key, section]) => {
const width = ((section.endIndex - section.startIndex + 1) / totalBars) * 100 // 計算該月份在柱狀圖中的實際位置
const left = (section.startIndex / totalBars) * 100 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 ( return (
<div <div
key={key} key={key}
className="absolute text-xs font-medium text-gray-600 bg-white/90 px-2 py-1 rounded shadow-sm border" className="absolute text-xs font-medium text-gray-700 bg-blue-50 px-3 py-1 rounded shadow-sm border border-blue-200"
style={{ style={{
left: `${left}%`, left: `${leftPercent}%`,
width: `${width}%`, width: `${widthPercent}%`,
textAlign: "center", textAlign: "center",
minWidth: "60px", // 確保標籤有最小寬度
}} }}
> >
{section.label} {section.label}
@@ -643,8 +787,24 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
})()} })()}
</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 */} {/* Chart Bars */}
<div className="h-full flex items-end justify-between space-x-2" style={{ paddingTop: "40px" }}> <div className="h-full flex items-end justify-start gap-3" style={{ paddingTop: "40px", paddingLeft: "40px" }}>
{usageStats.trendData.map((day: any, index: number) => { {usageStats.trendData.map((day: any, index: number) => {
const maxUsers = Math.max(...usageStats.trendData.map((d: any) => d.users)) const maxUsers = Math.max(...usageStats.trendData.map((d: any) => d.users))
const minUsers = Math.min(...usageStats.trendData.map((d: any) => d.users)) const minUsers = Math.min(...usageStats.trendData.map((d: any) => d.users))
@@ -663,8 +823,8 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
return ( return (
<div <div
key={day.date} key={day.date}
className="flex-1 flex flex-col items-center group relative" className="flex flex-col items-center group relative"
style={{ minWidth: "32px" }} style={{ width: "80px" }}
> >
{/* Month divider line */} {/* Month divider line */}
{isNewMonth && index > 0 && ( {isNewMonth && index > 0 && (
@@ -676,11 +836,11 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
style={{ height: "200px" }} style={{ height: "200px" }}
> >
<div <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" 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}%` }} style={{ height: `${normalizedHeight}%` }}
> >
{/* Value label */} {/* 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"> <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} {day.users}
</div> </div>
</div> </div>
@@ -712,42 +872,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</div> </div>
</CardContent> </CardContent>
</Card> </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>
<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 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>
</TabsContent> </TabsContent>
<TabsContent value="reviews" className="space-y-6"> <TabsContent value="reviews" className="space-y-6">

View File

@@ -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;

View File

@@ -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;

View File

@@ -2,13 +2,34 @@
# AI 展示平台環境變數配置 # AI 展示平台環境變數配置
# ===================================================== # =====================================================
# 資料庫配置 # ===== 主機資料庫配置 =====
DB_HOST=mysql.theaken.com DB_HOST=mysql.theaken.com
DB_PORT=33306 DB_PORT=33306
DB_NAME=db_AI_Platform DB_NAME=db_AI_Platform
DB_USER=AI_Platform DB_USER=AI_Platform
DB_PASSWORD=Aa123456 DB_PASSWORD=Aa123456
# ===== 備機資料庫配置 =====
SLAVE_DB_HOST=122.100.99.161
SLAVE_DB_PORT=43306
SLAVE_DB_NAME=db_AI_Platform
SLAVE_DB_USER=A999
SLAVE_DB_PASSWORD=1023
# ===== 資料庫備援配置 =====
DB_FAILOVER_ENABLED=true
# ===== 資料庫雙寫同步配置 =====
DB_DUAL_WRITE_ENABLED=false
DB_MASTER_PRIORITY=true
DB_CONFLICT_RESOLUTION=master
DB_RETRY_ATTEMPTS=3
DB_RETRY_DELAY=1000
DB_HEALTH_CHECK_INTERVAL=30000
DB_CONNECTION_TIMEOUT=5000
DB_RETRY_ATTEMPTS=3
DB_RETRY_DELAY=2000
# DeepSeek API 配置 # DeepSeek API 配置
NEXT_PUBLIC_DEEPSEEK_API_KEY=your_deepseek_api_key_here NEXT_PUBLIC_DEEPSEEK_API_KEY=your_deepseek_api_key_here
NEXT_PUBLIC_DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions NEXT_PUBLIC_DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions

424
lib/database-failover.js Normal file
View 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
View 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
View 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();

View File

@@ -1,8 +1,10 @@
// ===================================================== // =====================================================
// 資料庫連接配置 // 資料庫連接配置 (整合備援功能)
// ===================================================== // =====================================================
import mysql from 'mysql2/promise'; import mysql from 'mysql2/promise';
import { dbFailover } from './database-failover';
import { dbSync } from './database-sync';
// 資料庫配置 // 資料庫配置
const dbConfig = { const dbConfig = {
@@ -25,19 +27,21 @@ const dbConfig = {
idleTimeout: 300000, idleTimeout: 300000,
maxIdle: 10, maxIdle: 10,
// 添加 SSL 配置(如果需要) // 添加 SSL 配置(如果需要)
ssl: false, ssl: false as any,
}; };
// 創建連接池 // 創建連接池
const pool = mysql.createPool(dbConfig); const pool = mysql.createPool(dbConfig);
// 資料庫連接類 // 資料庫連接類 (整合備援功能)
export class Database { export class Database {
private static instance: Database; private static instance: Database;
private pool: mysql.Pool; private pool: mysql.Pool;
private useFailover: boolean;
private constructor() { private constructor() {
this.pool = pool; this.pool = pool;
this.useFailover = process.env.DB_FAILOVER_ENABLED === 'true';
} }
public static getInstance(): Database { public static getInstance(): Database {
@@ -49,11 +53,18 @@ export class Database {
// 獲取連接 // 獲取連接
public async getConnection(): Promise<mysql.PoolConnection> { public async getConnection(): Promise<mysql.PoolConnection> {
if (this.useFailover) {
return await dbFailover.getConnection();
}
return await this.pool.getConnection(); return await this.pool.getConnection();
} }
// 執行查詢 // 執行查詢
public async query<T = any>(sql: string, params?: any[]): Promise<T[]> { public async query<T = any>(sql: string, params?: any[]): Promise<T[]> {
if (this.useFailover) {
return await dbFailover.query<T>(sql, params);
}
let connection; let connection;
let retries = 0; let retries = 0;
const maxRetries = 3; const maxRetries = 3;
@@ -92,6 +103,10 @@ export class Database {
// 執行單一查詢 // 執行單一查詢
public async queryOne<T = any>(sql: string, params?: any[]): Promise<T | null> { public async queryOne<T = any>(sql: string, params?: any[]): Promise<T | null> {
if (this.useFailover) {
return await dbFailover.queryOne<T>(sql, params);
}
try { try {
const results = await this.query<T>(sql, params); const results = await this.query<T>(sql, params);
return results.length > 0 ? results[0] : null; return results.length > 0 ? results[0] : null;
@@ -101,8 +116,28 @@ export class Database {
} }
} }
// 執行插入 // 執行插入(支援雙寫)
public async insert(sql: string, params?: any[]): Promise<mysql.ResultSetHeader> { public async insert(sql: string, params?: any[]): Promise<mysql.ResultSetHeader> {
if (this.useFailover) {
// 檢查是否啟用雙寫
const syncStatus = await dbSync.getSyncStatus();
if (syncStatus.enabled) {
const result = await dbSync.dualInsert(sql, params);
if (result.success) {
// 返回主機的結果(如果主機成功)
if (result.masterSuccess) {
return await dbFailover.insert(sql, params);
} else if (result.slaveSuccess) {
// 如果只有備機成功,返回備機結果
return await dbFailover.insert(sql, params);
}
}
throw new Error(`雙寫失敗: 主機${result.masterError || '成功'}, 備機${result.slaveError || '成功'}`);
} else {
return await dbFailover.insert(sql, params);
}
}
const connection = await this.getConnection(); const connection = await this.getConnection();
try { try {
const [result] = await connection.execute(sql, params); const [result] = await connection.execute(sql, params);
@@ -112,8 +147,28 @@ export class Database {
} }
} }
// 執行更新 // 執行更新(支援雙寫)
public async update(sql: string, params?: any[]): Promise<mysql.ResultSetHeader> { public async update(sql: string, params?: any[]): Promise<mysql.ResultSetHeader> {
if (this.useFailover) {
// 檢查是否啟用雙寫
const syncStatus = await dbSync.getSyncStatus();
if (syncStatus.enabled) {
const result = await dbSync.dualUpdate(sql, params);
if (result.success) {
// 返回主機的結果(如果主機成功)
if (result.masterSuccess) {
return await dbFailover.update(sql, params);
} else if (result.slaveSuccess) {
// 如果只有備機成功,返回備機結果
return await dbFailover.update(sql, params);
}
}
throw new Error(`雙寫失敗: 主機${result.masterError || '成功'}, 備機${result.slaveError || '成功'}`);
} else {
return await dbFailover.update(sql, params);
}
}
const connection = await this.getConnection(); const connection = await this.getConnection();
try { try {
const [result] = await connection.execute(sql, params); const [result] = await connection.execute(sql, params);
@@ -123,8 +178,28 @@ export class Database {
} }
} }
// 執行刪除 // 執行刪除(支援雙寫)
public async delete(sql: string, params?: any[]): Promise<mysql.ResultSetHeader> { public async delete(sql: string, params?: any[]): Promise<mysql.ResultSetHeader> {
if (this.useFailover) {
// 檢查是否啟用雙寫
const syncStatus = await dbSync.getSyncStatus();
if (syncStatus.enabled) {
const result = await dbSync.dualDelete(sql, params);
if (result.success) {
// 返回主機的結果(如果主機成功)
if (result.masterSuccess) {
return await dbFailover.delete(sql, params);
} else if (result.slaveSuccess) {
// 如果只有備機成功,返回備機結果
return await dbFailover.delete(sql, params);
}
}
throw new Error(`雙寫失敗: 主機${result.masterError || '成功'}, 備機${result.slaveError || '成功'}`);
} else {
return await dbFailover.delete(sql, params);
}
}
const connection = await this.getConnection(); const connection = await this.getConnection();
try { try {
const [result] = await connection.execute(sql, params); const [result] = await connection.execute(sql, params);
@@ -136,6 +211,10 @@ export class Database {
// 開始事務 // 開始事務
public async beginTransaction(): Promise<mysql.PoolConnection> { public async beginTransaction(): Promise<mysql.PoolConnection> {
if (this.useFailover) {
return await dbFailover.beginTransaction();
}
const connection = await this.getConnection(); const connection = await this.getConnection();
await connection.beginTransaction(); await connection.beginTransaction();
return connection; return connection;
@@ -143,18 +222,45 @@ export class Database {
// 提交事務 // 提交事務
public async commit(connection: mysql.PoolConnection): Promise<void> { public async commit(connection: mysql.PoolConnection): Promise<void> {
if (this.useFailover) {
return await dbFailover.commit(connection);
}
await connection.commit(); await connection.commit();
connection.release(); connection.release();
} }
// 回滾事務 // 回滾事務
public async rollback(connection: mysql.PoolConnection): Promise<void> { public async rollback(connection: mysql.PoolConnection): Promise<void> {
if (this.useFailover) {
return await dbFailover.rollback(connection);
}
await connection.rollback(); await connection.rollback();
connection.release(); connection.release();
} }
// 獲取備援狀態
public getFailoverStatus() {
if (this.useFailover) {
return dbFailover.getStatus();
}
return null;
}
// 切換資料庫
public async switchDatabase(database: 'master' | 'slave'): Promise<boolean> {
if (this.useFailover) {
return await dbFailover.switchToDatabase(database);
}
return false;
}
// 關閉連接池 // 關閉連接池
public async close(): Promise<void> { public async close(): Promise<void> {
if (this.useFailover) {
await dbFailover.close();
}
await this.pool.end(); await this.pool.end();
} }
} }

View File

@@ -202,69 +202,6 @@ export class UserService {
}; };
} }
// 獲取用戶的應用和評價統計
async getUserAppAndReviewStats(userId: string): Promise<{
appCount: number;
reviewCount: number;
}> {
try {
console.log('獲取用戶統計數據userId:', userId);
// 先檢查資料庫中是否有數據
const checkAppsSql = 'SELECT COUNT(*) as total FROM apps';
const checkRatingsSql = 'SELECT COUNT(*) as total FROM user_ratings';
const checkUsersSql = 'SELECT COUNT(*) as total FROM users';
const [totalApps, totalRatings, totalUsers] = await Promise.all([
this.queryOne(checkAppsSql),
this.queryOne(checkRatingsSql),
this.queryOne(checkUsersSql)
]);
console.log('資料庫總數據:', {
totalApps: totalApps?.total || 0,
totalRatings: totalRatings?.total || 0,
totalUsers: totalUsers?.total || 0
});
// 獲取用戶創建的應用數量
const appCountSql = `
SELECT COUNT(*) as app_count
FROM apps
WHERE creator_id = ?
`;
// 獲取用戶撰寫的評價數量
const reviewCountSql = `
SELECT COUNT(*) as review_count
FROM user_ratings
WHERE user_id = ?
`;
const [appResult, reviewResult] = await Promise.all([
this.queryOne(appCountSql, [userId]),
this.queryOne(reviewCountSql, [userId])
]);
console.log('應用查詢結果:', appResult);
console.log('評價查詢結果:', reviewResult);
const result = {
appCount: appResult?.app_count || 0,
reviewCount: reviewResult?.review_count || 0
};
console.log('最終統計結果:', result);
return result;
} catch (error) {
console.error('獲取用戶應用和評價統計錯誤:', error);
return {
appCount: 0,
reviewCount: 0
};
}
}
// 獲取用戶活動記錄 // 獲取用戶活動記錄
async getUserActivities( async getUserActivities(
userId: string, userId: string,
@@ -804,7 +741,7 @@ export class UserService {
// 獲取所有用戶 // 獲取所有用戶
async getAllUsers(limit = 50, offset = 0): Promise<User[]> { async getAllUsers(limit = 50, offset = 0): Promise<User[]> {
const sql = 'SELECT * FROM users WHERE is_active = TRUE ORDER BY created_at DESC LIMIT ? OFFSET ?'; const sql = 'SELECT * FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?';
return await db.query<User>(sql, [limit, offset]); return await db.query<User>(sql, [limit, offset]);
} }
@@ -839,6 +776,46 @@ export class UserService {
const service = new UserService(); const service = new UserService();
return await service.getAllUsers(limit, offset); return await service.getAllUsers(limit, offset);
} }
// 獲取用戶的應用和評價統計
async getUserAppAndReviewStats(userId: string): Promise<{
appCount: number;
reviewCount: number;
}> {
try {
// 獲取用戶創建的應用數量
const appCountSql = `
SELECT COUNT(*) as app_count
FROM apps
WHERE creator_id = ?
`;
// 獲取用戶撰寫的評價數量
const reviewCountSql = `
SELECT COUNT(*) as review_count
FROM user_ratings
WHERE user_id = ?
`;
const [appResult, reviewResult] = await Promise.all([
this.queryOne(appCountSql, [userId]),
this.queryOne(reviewCountSql, [userId])
]);
const result = {
appCount: appResult?.app_count || 0,
reviewCount: reviewResult?.review_count || 0
};
return result;
} catch (error) {
console.error('獲取用戶應用和評價統計錯誤:', error);
return {
appCount: 0,
reviewCount: 0
};
}
}
} }
// ===================================================== // =====================================================
@@ -1358,7 +1335,7 @@ export class AppService {
} }
// 獲取應用使用統計 // 獲取應用使用統計
async getAppUsageStats(appId: string, startDate?: string, endDate?: string): Promise<{ async getAppUsageStats(appId: string, startDate?: string, endDate?: string, department?: string): Promise<{
dailyUsers: number; dailyUsers: number;
weeklyUsers: number; weeklyUsers: number;
monthlyUsers: number; monthlyUsers: number;
@@ -1369,7 +1346,7 @@ export class AppService {
try { try {
// 今日使用者 // 今日使用者
const dailySql = ` const dailySql = `
SELECT COUNT(DISTINCT user_id) as daily_users SELECT COUNT(*) as daily_users
FROM user_views FROM user_views
WHERE app_id = ? AND DATE(viewed_at) = CURDATE() WHERE app_id = ? AND DATE(viewed_at) = CURDATE()
`; `;
@@ -1377,7 +1354,7 @@ export class AppService {
// 本週使用者 // 本週使用者
const weeklySql = ` const weeklySql = `
SELECT COUNT(DISTINCT user_id) as weekly_users SELECT COUNT(*) as weekly_users
FROM user_views FROM user_views
WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 1 WEEK) WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 1 WEEK)
`; `;
@@ -1385,7 +1362,7 @@ export class AppService {
// 本月使用者 // 本月使用者
const monthlySql = ` const monthlySql = `
SELECT COUNT(DISTINCT user_id) as monthly_users SELECT COUNT(*) as monthly_users
FROM user_views FROM user_views
WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 1 MONTH) WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 1 MONTH)
`; `;
@@ -1399,8 +1376,25 @@ export class AppService {
`; `;
const totalResult = await this.queryOne(totalSql, [appId]); const totalResult = await this.queryOne(totalSql, [appId]);
// 部門使用統計 // 部門使用統計 - 支援日期範圍過濾
const deptSql = ` 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 SELECT
u.department, u.department,
COUNT(*) as count COUNT(*) as count
@@ -1411,36 +1405,44 @@ export class AppService {
ORDER BY count DESC ORDER BY count DESC
LIMIT 5 LIMIT 5
`; `;
const deptResult = await this.query(deptSql, [appId]); deptParams = [appId];
}
const deptResult = await this.query(deptSql, deptParams);
// 使用趨勢 - 支援自定義日期範圍 // 使用趨勢 - 支援自定義日期範圍和部門過濾
let trendSql: string; let trendSql: string;
let trendParams: any[]; let trendParams: any[];
// 構建部門過濾條件
const departmentFilter = department ? 'AND u.department = ?' : '';
const baseWhere = `uv.app_id = ? ${departmentFilter}`;
if (startDate && endDate) { if (startDate && endDate) {
// 使用自定義日期範圍 // 使用自定義日期範圍
trendSql = ` trendSql = `
SELECT SELECT
DATE(viewed_at) as date, DATE(uv.viewed_at) as date,
COUNT(DISTINCT user_id) as users COUNT(*) as users
FROM user_views FROM user_views uv
WHERE app_id = ? AND DATE(viewed_at) BETWEEN ? AND ? JOIN users u ON uv.user_id = u.id
GROUP BY DATE(viewed_at) WHERE ${baseWhere} AND DATE(uv.viewed_at) BETWEEN ? AND ?
GROUP BY DATE(uv.viewed_at)
ORDER BY date ASC ORDER BY date ASC
`; `;
trendParams = [appId, startDate, endDate]; trendParams = department ? [appId, department, startDate, endDate] : [appId, startDate, endDate];
} else { } else {
// 預設過去7天 // 預設過去7天
trendSql = ` trendSql = `
SELECT SELECT
DATE(viewed_at) as date, DATE(uv.viewed_at) as date,
COUNT(DISTINCT user_id) as users COUNT(*) as users
FROM user_views FROM user_views uv
WHERE app_id = ? AND viewed_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) JOIN users u ON uv.user_id = u.id
GROUP BY DATE(viewed_at) WHERE ${baseWhere} AND uv.viewed_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
GROUP BY DATE(uv.viewed_at)
ORDER BY date ASC ORDER BY date ASC
`; `;
trendParams = [appId]; trendParams = department ? [appId, department] : [appId];
} }
const trendResult = await this.query(trendSql, trendParams); const trendResult = await this.query(trendSql, trendParams);

View File

@@ -25,7 +25,17 @@
"test:role-display": "node scripts/test-role-display.js", "test:role-display": "node scripts/test-role-display.js",
"test:activity-records": "node scripts/test-activity-records.js", "test:activity-records": "node scripts/test-activity-records.js",
"test:hydration-fix": "node scripts/test-hydration-fix.js", "test:hydration-fix": "node scripts/test-hydration-fix.js",
"setup": "node scripts/setup.js" "setup": "node scripts/setup.js",
"db:init-slave": "node scripts/init-slave-database.js",
"db:sync": "node scripts/sync-database.js",
"db:health": "node scripts/check-database-health.js",
"db:monitor": "node scripts/check-database-health.js && echo '資料庫健康檢查完成'",
"db:test-failover": "node scripts/test-failover.js",
"db:test-system": "node scripts/test-failover-system.js",
"db:test-simple": "node scripts/test-simple-failover.js",
"db:test-startup": "node scripts/test-startup-failover.js",
"db:create-ai-tables": "node scripts/create-ai-tables.js",
"db:create-ai-tables-master": "node scripts/create-ai-tables-master.js"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",