491 lines
15 KiB
Go
491 lines
15 KiB
Go
package handler
|
|
|
|
import (
|
|
"fmt"
|
|
"gpt-load/internal/encryption"
|
|
app_errors "gpt-load/internal/errors"
|
|
"gpt-load/internal/i18n"
|
|
"gpt-load/internal/models"
|
|
"gpt-load/internal/response"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// Stats Get dashboard statistics
|
|
func (s *Server) Stats(c *gin.Context) {
|
|
var activeKeys, invalidKeys int64
|
|
s.DB.Model(&models.APIKey{}).Where("status = ?", models.KeyStatusActive).Count(&activeKeys)
|
|
s.DB.Model(&models.APIKey{}).Where("status = ?", models.KeyStatusInvalid).Count(&invalidKeys)
|
|
|
|
now := time.Now()
|
|
rpmStats, err := s.getRPMStats(now)
|
|
if err != nil {
|
|
response.ErrorI18nFromAPIError(c, app_errors.ErrDatabase, "database.rpm_stats_failed")
|
|
return
|
|
}
|
|
twentyFourHoursAgo := now.Add(-24 * time.Hour)
|
|
fortyEightHoursAgo := now.Add(-48 * time.Hour)
|
|
|
|
currentPeriod, err := s.getHourlyStats(twentyFourHoursAgo, now)
|
|
if err != nil {
|
|
response.ErrorI18nFromAPIError(c, app_errors.ErrDatabase, "database.current_stats_failed")
|
|
return
|
|
}
|
|
previousPeriod, err := s.getHourlyStats(fortyEightHoursAgo, twentyFourHoursAgo)
|
|
if err != nil {
|
|
response.ErrorI18nFromAPIError(c, app_errors.ErrDatabase, "database.previous_stats_failed")
|
|
return
|
|
}
|
|
|
|
// 计算请求量趋势
|
|
reqTrend := 0.0
|
|
reqTrendIsGrowth := true
|
|
if previousPeriod.TotalRequests > 0 {
|
|
// 有前期数据,计算百分比变化
|
|
reqTrend = (float64(currentPeriod.TotalRequests-previousPeriod.TotalRequests) / float64(previousPeriod.TotalRequests)) * 100
|
|
reqTrendIsGrowth = reqTrend >= 0
|
|
} else if currentPeriod.TotalRequests > 0 {
|
|
// 前期无数据,当前有数据,视为100%增长
|
|
reqTrend = 100.0
|
|
reqTrendIsGrowth = true
|
|
} else {
|
|
// 前期和当前都无数据
|
|
reqTrend = 0.0
|
|
reqTrendIsGrowth = true
|
|
}
|
|
|
|
// 计算当前和前期错误率
|
|
currentErrorRate := 0.0
|
|
if currentPeriod.TotalRequests > 0 {
|
|
currentErrorRate = (float64(currentPeriod.TotalFailures) / float64(currentPeriod.TotalRequests)) * 100
|
|
}
|
|
|
|
previousErrorRate := 0.0
|
|
if previousPeriod.TotalRequests > 0 {
|
|
previousErrorRate = (float64(previousPeriod.TotalFailures) / float64(previousPeriod.TotalRequests)) * 100
|
|
}
|
|
|
|
// 计算错误率趋势
|
|
errorRateTrend := 0.0
|
|
errorRateTrendIsGrowth := false
|
|
if previousPeriod.TotalRequests > 0 {
|
|
// 有前期数据,计算百分点差异
|
|
errorRateTrend = currentErrorRate - previousErrorRate
|
|
errorRateTrendIsGrowth = errorRateTrend < 0 // 错误率下降是好事
|
|
} else if currentPeriod.TotalRequests > 0 {
|
|
// 前期无数据,当前有数据
|
|
errorRateTrend = currentErrorRate // 显示当前错误率
|
|
errorRateTrendIsGrowth = false // 有错误是坏事(如果错误率>0)
|
|
if currentErrorRate == 0 {
|
|
errorRateTrendIsGrowth = true // 如果当前无错误,标记为正面
|
|
}
|
|
} else {
|
|
// 都无数据
|
|
errorRateTrend = 0.0
|
|
errorRateTrendIsGrowth = true
|
|
}
|
|
|
|
// 获取安全警告信息
|
|
securityWarnings := s.getSecurityWarnings(c)
|
|
|
|
stats := models.DashboardStatsResponse{
|
|
KeyCount: models.StatCard{
|
|
Value: float64(activeKeys),
|
|
SubValue: invalidKeys,
|
|
SubValueTip: i18n.Message(c, "dashboard.invalid_keys"),
|
|
},
|
|
RPM: rpmStats,
|
|
RequestCount: models.StatCard{
|
|
Value: float64(currentPeriod.TotalRequests),
|
|
Trend: reqTrend,
|
|
TrendIsGrowth: reqTrendIsGrowth,
|
|
},
|
|
ErrorRate: models.StatCard{
|
|
Value: currentErrorRate,
|
|
Trend: errorRateTrend,
|
|
TrendIsGrowth: errorRateTrendIsGrowth,
|
|
},
|
|
SecurityWarnings: securityWarnings,
|
|
}
|
|
|
|
response.Success(c, stats)
|
|
}
|
|
|
|
// Chart Get dashboard chart data
|
|
func (s *Server) Chart(c *gin.Context) {
|
|
groupID := c.Query("groupId")
|
|
|
|
now := time.Now()
|
|
endHour := now.Truncate(time.Hour)
|
|
startHour := endHour.Add(-23 * time.Hour)
|
|
|
|
var hourlyStats []models.GroupHourlyStat
|
|
query := s.DB.Where("time >= ? AND time < ?", startHour, endHour.Add(time.Hour))
|
|
if groupID != "" {
|
|
query = query.Where("group_id = ?", groupID)
|
|
}
|
|
if err := query.Order("time asc").Find(&hourlyStats).Error; err != nil {
|
|
response.ErrorI18nFromAPIError(c, app_errors.ErrDatabase, "database.chart_data_failed")
|
|
return
|
|
}
|
|
|
|
statsByHour := make(map[time.Time]map[string]int64)
|
|
for _, stat := range hourlyStats {
|
|
hour := stat.Time.Local().Truncate(time.Hour)
|
|
if _, ok := statsByHour[hour]; !ok {
|
|
statsByHour[hour] = make(map[string]int64)
|
|
}
|
|
statsByHour[hour]["success"] += stat.SuccessCount
|
|
statsByHour[hour]["failure"] += stat.FailureCount
|
|
}
|
|
|
|
var labels []string
|
|
var successData, failureData []int64
|
|
|
|
for i := range 24 {
|
|
hour := startHour.Add(time.Duration(i) * time.Hour)
|
|
labels = append(labels, hour.Format(time.RFC3339))
|
|
|
|
if data, ok := statsByHour[hour]; ok {
|
|
successData = append(successData, data["success"])
|
|
failureData = append(failureData, data["failure"])
|
|
} else {
|
|
successData = append(successData, 0)
|
|
failureData = append(failureData, 0)
|
|
}
|
|
}
|
|
|
|
chartData := models.ChartData{
|
|
Labels: labels,
|
|
Datasets: []models.ChartDataset{
|
|
{
|
|
Label: i18n.Message(c, "dashboard.success_requests"),
|
|
Data: successData,
|
|
Color: "rgba(10, 200, 110, 1)",
|
|
},
|
|
{
|
|
Label: i18n.Message(c, "dashboard.failed_requests"),
|
|
Data: failureData,
|
|
Color: "rgba(255, 70, 70, 1)",
|
|
},
|
|
},
|
|
}
|
|
|
|
response.Success(c, chartData)
|
|
}
|
|
|
|
type hourlyStatResult struct {
|
|
TotalRequests int64
|
|
TotalFailures int64
|
|
}
|
|
|
|
func (s *Server) getHourlyStats(startTime, endTime time.Time) (hourlyStatResult, error) {
|
|
var result hourlyStatResult
|
|
err := s.DB.Model(&models.GroupHourlyStat{}).
|
|
Select("sum(success_count) + sum(failure_count) as total_requests, sum(failure_count) as total_failures").
|
|
Where("time >= ? AND time < ?", startTime, endTime).
|
|
Scan(&result).Error
|
|
return result, err
|
|
}
|
|
|
|
type rpmStatResult struct {
|
|
CurrentRequests int64
|
|
PreviousRequests int64
|
|
}
|
|
|
|
func (s *Server) getRPMStats(now time.Time) (models.StatCard, error) {
|
|
tenMinutesAgo := now.Add(-10 * time.Minute)
|
|
twentyMinutesAgo := now.Add(-20 * time.Minute)
|
|
|
|
var result rpmStatResult
|
|
err := s.DB.Model(&models.RequestLog{}).
|
|
Select("count(case when timestamp >= ? then 1 end) as current_requests, count(case when timestamp >= ? and timestamp < ? then 1 end) as previous_requests", tenMinutesAgo, twentyMinutesAgo, tenMinutesAgo).
|
|
Where("timestamp >= ? AND request_type = ?", twentyMinutesAgo, models.RequestTypeFinal).
|
|
Scan(&result).Error
|
|
|
|
if err != nil {
|
|
return models.StatCard{}, err
|
|
}
|
|
|
|
currentRPM := float64(result.CurrentRequests) / 10.0
|
|
previousRPM := float64(result.PreviousRequests) / 10.0
|
|
|
|
rpmTrend := 0.0
|
|
rpmTrendIsGrowth := true
|
|
if previousRPM > 0 {
|
|
rpmTrend = (currentRPM - previousRPM) / previousRPM * 100
|
|
rpmTrendIsGrowth = rpmTrend >= 0
|
|
} else if currentRPM > 0 {
|
|
rpmTrend = 100.0
|
|
rpmTrendIsGrowth = true
|
|
}
|
|
|
|
return models.StatCard{
|
|
Value: currentRPM,
|
|
Trend: rpmTrend,
|
|
TrendIsGrowth: rpmTrendIsGrowth,
|
|
}, nil
|
|
}
|
|
|
|
// getSecurityWarnings 检查安全配置并返回警告信息
|
|
func (s *Server) getSecurityWarnings(c *gin.Context) []models.SecurityWarning {
|
|
var warnings []models.SecurityWarning
|
|
|
|
// 获取AUTH_KEY和ENCRYPTION_KEY
|
|
authConfig := s.config.GetAuthConfig()
|
|
encryptionKey := s.config.GetEncryptionKey()
|
|
|
|
// 检查AUTH_KEY
|
|
if authConfig.Key == "" {
|
|
warnings = append(warnings, models.SecurityWarning{
|
|
Type: "AUTH_KEY",
|
|
Message: i18n.Message(c, "dashboard.auth_key_missing"),
|
|
Severity: "high",
|
|
Suggestion: i18n.Message(c, "dashboard.auth_key_required"),
|
|
})
|
|
} else {
|
|
authWarnings := checkPasswordSecurity(c, authConfig.Key, "AUTH_KEY")
|
|
warnings = append(warnings, authWarnings...)
|
|
}
|
|
|
|
// 检查ENCRYPTION_KEY
|
|
if encryptionKey == "" {
|
|
warnings = append(warnings, models.SecurityWarning{
|
|
Type: "ENCRYPTION_KEY",
|
|
Message: i18n.Message(c, "dashboard.encryption_key_missing"),
|
|
Severity: "high",
|
|
Suggestion: i18n.Message(c, "dashboard.encryption_key_recommended"),
|
|
})
|
|
} else {
|
|
encryptionWarnings := checkPasswordSecurity(c, encryptionKey, "ENCRYPTION_KEY")
|
|
warnings = append(warnings, encryptionWarnings...)
|
|
}
|
|
|
|
// 检查系统级代理密钥
|
|
systemSettings := s.SettingsManager.GetSettings()
|
|
if systemSettings.ProxyKeys != "" {
|
|
proxyKeys := strings.Split(systemSettings.ProxyKeys, ",")
|
|
for i, key := range proxyKeys {
|
|
key = strings.TrimSpace(key)
|
|
if key != "" {
|
|
keyName := fmt.Sprintf("%s #%d", i18n.Message(c, "dashboard.global_proxy_key"), i+1)
|
|
proxyWarnings := checkPasswordSecurity(c, key, keyName)
|
|
warnings = append(warnings, proxyWarnings...)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 检查分组级代理密钥
|
|
var groups []models.Group
|
|
if err := s.DB.Where("proxy_keys IS NOT NULL AND proxy_keys != ''").Find(&groups).Error; err == nil {
|
|
for _, group := range groups {
|
|
if group.ProxyKeys != "" {
|
|
proxyKeys := strings.Split(group.ProxyKeys, ",")
|
|
for i, key := range proxyKeys {
|
|
key = strings.TrimSpace(key)
|
|
if key != "" {
|
|
keyName := fmt.Sprintf("%s [%s] #%d", i18n.Message(c, "dashboard.group_proxy_key"), group.Name, i+1)
|
|
proxyWarnings := checkPasswordSecurity(c, key, keyName)
|
|
warnings = append(warnings, proxyWarnings...)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return warnings
|
|
}
|
|
|
|
// checkPasswordSecurity 综合检查密码安全性
|
|
func checkPasswordSecurity(c *gin.Context, password, keyType string) []models.SecurityWarning {
|
|
var warnings []models.SecurityWarning
|
|
|
|
// 1. 长度检查
|
|
if len(password) < 16 {
|
|
warnings = append(warnings, models.SecurityWarning{
|
|
Type: keyType,
|
|
Message: i18n.Message(c, "security.password_too_short", map[string]any{"keyType": keyType, "length": len(password)}),
|
|
Severity: "high", // 长度不足是高风险
|
|
Suggestion: i18n.Message(c, "security.password_recommendation_16"),
|
|
})
|
|
} else if len(password) < 32 {
|
|
warnings = append(warnings, models.SecurityWarning{
|
|
Type: keyType,
|
|
Message: i18n.Message(c, "security.password_short", map[string]any{"keyType": keyType, "length": len(password)}),
|
|
Severity: "medium",
|
|
Suggestion: i18n.Message(c, "security.password_recommendation_32"),
|
|
})
|
|
}
|
|
|
|
// 2. 常见弱密码检查
|
|
lower := strings.ToLower(password)
|
|
weakPatterns := []string{
|
|
"password", "123456", "admin", "secret", "test", "demo",
|
|
"sk-123456", "key", "token", "pass", "pwd", "qwerty",
|
|
"abc", "default", "user", "login", "auth", "temp",
|
|
}
|
|
|
|
for _, pattern := range weakPatterns {
|
|
if strings.Contains(lower, pattern) {
|
|
warnings = append(warnings, models.SecurityWarning{
|
|
Type: keyType,
|
|
Message: i18n.Message(c, "security.password_weak_pattern", map[string]any{"keyType": keyType, "pattern": pattern}),
|
|
Severity: "high",
|
|
Suggestion: i18n.Message(c, "security.password_avoid_common"),
|
|
})
|
|
break
|
|
}
|
|
}
|
|
|
|
// 3. 复杂度检查(仅在长度足够时检查)
|
|
if len(password) >= 16 && !hasGoodComplexity(password) {
|
|
warnings = append(warnings, models.SecurityWarning{
|
|
Type: keyType,
|
|
Message: i18n.Message(c, "security.password_low_complexity", map[string]any{"keyType": keyType}),
|
|
Severity: "medium",
|
|
Suggestion: i18n.Message(c, "security.password_complexity"),
|
|
})
|
|
}
|
|
|
|
return warnings
|
|
}
|
|
|
|
// hasGoodComplexity 检查密码复杂度
|
|
func hasGoodComplexity(password string) bool {
|
|
var hasUpper, hasLower, hasDigit, hasSpecial bool
|
|
|
|
for _, char := range password {
|
|
switch {
|
|
case char >= 'A' && char <= 'Z':
|
|
hasUpper = true
|
|
case char >= 'a' && char <= 'z':
|
|
hasLower = true
|
|
case char >= '0' && char <= '9':
|
|
hasDigit = true
|
|
case !((char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9')):
|
|
hasSpecial = true
|
|
}
|
|
}
|
|
|
|
// 至少包含3种类型的字符
|
|
count := 0
|
|
if hasUpper {
|
|
count++
|
|
}
|
|
if hasLower {
|
|
count++
|
|
}
|
|
if hasDigit {
|
|
count++
|
|
}
|
|
if hasSpecial {
|
|
count++
|
|
}
|
|
|
|
return count >= 3
|
|
}
|
|
|
|
// Encryption scenario types
|
|
const (
|
|
ScenarioNone = ""
|
|
ScenarioDataNotEncrypted = "data_not_encrypted"
|
|
ScenarioKeyNotConfigured = "key_not_configured"
|
|
ScenarioKeyMismatch = "key_mismatch"
|
|
)
|
|
|
|
// EncryptionStatus checks if ENCRYPTION_KEY is configured but keys are not encrypted
|
|
func (s *Server) EncryptionStatus(c *gin.Context) {
|
|
hasMismatch, scenarioType, message, suggestion := s.checkEncryptionMismatch(c)
|
|
|
|
response.Success(c, gin.H{
|
|
"has_mismatch": hasMismatch,
|
|
"scenario_type": scenarioType,
|
|
"message": message,
|
|
"suggestion": suggestion,
|
|
})
|
|
}
|
|
|
|
// checkEncryptionMismatch detects encryption configuration mismatches
|
|
func (s *Server) checkEncryptionMismatch(c *gin.Context) (bool, string, string, string) {
|
|
encryptionKey := s.config.GetEncryptionKey()
|
|
|
|
// Sample check API keys
|
|
var sampleKeys []models.APIKey
|
|
if err := s.DB.Limit(20).Where("key_hash IS NOT NULL AND key_hash != ''").Find(&sampleKeys).Error; err != nil {
|
|
logrus.WithError(err).Error("Failed to fetch sample keys for encryption check")
|
|
return false, ScenarioNone, "", ""
|
|
}
|
|
|
|
if len(sampleKeys) == 0 {
|
|
// No keys in database, no mismatch
|
|
return false, ScenarioNone, "", ""
|
|
}
|
|
|
|
// Check hash consistency with unencrypted data
|
|
noopService, err := encryption.NewService("")
|
|
if err != nil {
|
|
logrus.WithError(err).Error("Failed to create noop encryption service")
|
|
return false, ScenarioNone, "", ""
|
|
}
|
|
|
|
unencryptedHashMatchCount := 0
|
|
for _, key := range sampleKeys {
|
|
// For unencrypted data: key_hash should match SHA256(key_value)
|
|
expectedHash := noopService.Hash(key.KeyValue)
|
|
if expectedHash == key.KeyHash {
|
|
unencryptedHashMatchCount++
|
|
}
|
|
}
|
|
|
|
unencryptedConsistencyRate := float64(unencryptedHashMatchCount) / float64(len(sampleKeys))
|
|
|
|
// If ENCRYPTION_KEY is configured, also check if current key can decrypt the data
|
|
var currentKeyHashMatchCount int
|
|
if encryptionKey != "" {
|
|
currentService, err := encryption.NewService(encryptionKey)
|
|
if err == nil {
|
|
for _, key := range sampleKeys {
|
|
// Try to decrypt and re-hash to check if current key matches
|
|
decrypted, err := currentService.Decrypt(key.KeyValue)
|
|
if err == nil {
|
|
// Successfully decrypted, check if hash matches
|
|
expectedHash := currentService.Hash(decrypted)
|
|
if expectedHash == key.KeyHash {
|
|
currentKeyHashMatchCount++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
currentKeyConsistencyRate := float64(currentKeyHashMatchCount) / float64(len(sampleKeys))
|
|
|
|
// Scenario A: ENCRYPTION_KEY configured but data not encrypted
|
|
if encryptionKey != "" && unencryptedConsistencyRate > 0.8 {
|
|
return true,
|
|
ScenarioDataNotEncrypted,
|
|
i18n.Message(c, "dashboard.encryption_key_configured_but_data_not_encrypted"),
|
|
i18n.Message(c, "dashboard.encryption_key_migration_required")
|
|
}
|
|
|
|
// Scenario B: ENCRYPTION_KEY not configured but data is encrypted
|
|
if encryptionKey == "" && unencryptedConsistencyRate < 0.2 {
|
|
return true,
|
|
ScenarioKeyNotConfigured,
|
|
i18n.Message(c, "dashboard.data_encrypted_but_key_not_configured"),
|
|
i18n.Message(c, "dashboard.configure_same_encryption_key")
|
|
}
|
|
|
|
// Scenario C: ENCRYPTION_KEY configured but doesn't match encrypted data
|
|
if encryptionKey != "" && unencryptedConsistencyRate < 0.2 && currentKeyConsistencyRate < 0.2 {
|
|
return true,
|
|
ScenarioKeyMismatch,
|
|
i18n.Message(c, "dashboard.encryption_key_mismatch"),
|
|
i18n.Message(c, "dashboard.use_correct_encryption_key")
|
|
}
|
|
|
|
return false, ScenarioNone, "", ""
|
|
}
|