From 086c5c6573aefe61a7009b316c4f2242abd595d9 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 6 May 2026 19:19:02 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=B8=8E=E9=94=99=E8=AF=AF=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 N+1 查询: users/devices/agents 批量 GROUP BY 替代循环查询 - 添加分页: cards/finance/agents/devices API - Redis 初始化根据安装配置 redis.enabled 决定是否连接 - 修复 DefaultVal 解析错误: 使用 sql.NullString 处理 NULL 值 - Dashboard 优化: 替换 ECharts 世界地图为 Chart.js 环形饼图 - 适配前端 cards 页面新 API 响应格式 - 添加数据库索引优化查询性能 - 实现可配置的数据清理定时任务 --- backend/cmd/main.go | 3 + backend/internal/config/config.go | 1 + backend/internal/database/database.go | 14 +- backend/internal/model/models.go | 36 +- backend/internal/router/admin/agents.go | 66 ++- backend/internal/router/admin/applications.go | 12 +- backend/internal/router/admin/cards.go | 29 +- backend/internal/router/admin/devices.go | 51 +- backend/internal/router/admin/finance.go | 33 +- .../internal/router/admin/system_settings.go | 86 ++++ backend/internal/router/admin/users.go | 53 ++- backend/internal/router/app/account.go | 10 +- backend/internal/router/app/dynamic.go | 20 +- .../internal/router/extension/extension.go | 89 +++- backend/internal/router/install/install.go | 1 + backend/internal/scheduler/cleanup.go | 145 ++++++ frontend/src/components/app-sidebar/types.ts | 1 + frontend/src/layouts/admin.vue | 2 +- .../admin/applications/[id]/settings.vue | 8 +- frontend/src/pages/admin/cards/index.vue | 12 +- .../src/pages/admin/email-settings/[id].vue | 2 +- frontend/src/pages/admin/index.vue | 441 +++--------------- .../src/pages/admin/payment-channels/[id].vue | 2 +- .../src/pages/admin/sms-settings/[id].vue | 2 +- .../src/pages/admin/storage-configs/[id].vue | 2 +- .../src/pages/admin/system-settings/index.vue | 188 +++++++- frontend/src/types/vue3-flag-icons.d.ts | 1 + 27 files changed, 843 insertions(+), 467 deletions(-) create mode 100644 backend/internal/scheduler/cleanup.go create mode 100644 frontend/src/types/vue3-flag-icons.d.ts diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 7c504fe..fa235c7 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -13,6 +13,7 @@ import ( "verification-platform-backend/internal/database" "verification-platform-backend/internal/middleware" "verification-platform-backend/internal/router" + "verification-platform-backend/internal/scheduler" "verification-platform-backend/internal/service" "verification-platform-backend/pkg/logger" @@ -234,6 +235,8 @@ func startServer() { router.SetupRoutes(r) setupEmbeddedFrontend(r) + scheduler.StartCleanupScheduler() + port := config.GetString("app.port") if port == "" { port = "8080" diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ee679ec..b4d7dbf 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -57,6 +57,7 @@ func setDefaults() { viper.SetDefault("database.max_open_conns", "100") // Redis配置 + viper.SetDefault("redis.enabled", false) viper.SetDefault("redis.host", "localhost") viper.SetDefault("redis.port", "6379") viper.SetDefault("redis.password", "") diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index b58d482..958abc3 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -9,6 +9,7 @@ package database import ( "context" + "database/sql" "fmt" "log" "os" @@ -162,7 +163,7 @@ func runMigrations() { Name string Type string NotNull int - DefaultVal interface{} + DefaultVal sql.NullString PK int } DB.Raw("PRAGMA table_info(applications)").Scan(&columns) @@ -171,13 +172,12 @@ func runMigrations() { log.Printf(" - %s (%s)\n", col.Name, col.Type) } - // 检查app_users表结构 var appUserColumns []struct { CID int Name string Type string NotNull int - DefaultVal interface{} + DefaultVal sql.NullString PK int } DB.Raw("PRAGMA table_info(app_users)").Scan(&appUserColumns) @@ -272,7 +272,7 @@ func runMigrations() { Name string Type string NotNull int - DefaultVal interface{} + DefaultVal sql.NullString PK int } DB.Raw("PRAGMA table_info(risk_control_rules)").Scan(&riskControlTableInfo) @@ -710,6 +710,12 @@ func initDocData() { // initRedis 初始化Redis连接 func initRedis() { + if !config.GetBool("redis.enabled") { + log.Println("Redis is disabled by configuration, skipping connection") + RDB = nil + return + } + RDB = redis.NewClient(&redis.Options{ Addr: fmt.Sprintf("%s:%s", config.GetString("redis.host"), config.GetString("redis.port")), Password: config.GetString("redis.password"), diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 6f371d7..957a7d8 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -280,12 +280,12 @@ type CardType struct { // Card 卡密模型 type Card struct { ID uint `gorm:"primaryKey" json:"id"` - ApplicationID uint `json:"application_id"` // 所属应用ID - CardTypeID uint `json:"card_type_id"` + ApplicationID uint `gorm:"index;not null" json:"application_id"` + CardTypeID uint `gorm:"index;not null" json:"card_type_id"` CardKey string `gorm:"uniqueIndex;size:100" json:"card_key"` - CreatorID uint `json:"creator_id"` // 创建人ID - AppUserID *uint `json:"app_user_id"` // 使用者ID - Status string `gorm:"size:20;default:unused" json:"status"` // unused, used, banned + CreatorID uint `gorm:"index" json:"creator_id"` + AppUserID *uint `gorm:"index" json:"app_user_id"` + Status string `gorm:"size:20;default:unused;index" json:"status"` UsedAt *time.Time `json:"used_at"` ExpireAt *time.Time `json:"expire_at"` CreatedAt time.Time `json:"created_at"` @@ -301,7 +301,7 @@ type Card struct { // Announcement 公告模型 type Announcement struct { ID uint `gorm:"primaryKey" json:"id"` - ApplicationID uint `json:"application_id"` + ApplicationID uint `gorm:"index" json:"application_id"` Title string `gorm:"size:255" json:"title"` Content string `gorm:"type:text" json:"content"` Type string `gorm:"size:20;default:info" json:"type"` // info, warning, urgent @@ -318,8 +318,8 @@ type Announcement struct { type Order struct { ID uint `gorm:"primaryKey" json:"id"` OrderNo string `gorm:"uniqueIndex;size:100" json:"order_no"` - UserID uint `json:"user_id"` - ApplicationID *uint `json:"application_id"` + UserID uint `gorm:"index" json:"user_id"` + ApplicationID *uint `gorm:"index" json:"application_id"` PackageID *uint `json:"package_id"` OrderType string `gorm:"size:50;not null" json:"order_type"` // card_recharge, agent_auth, user_recharge, user_deduct, package Title string `gorm:"size:200" json:"title"` @@ -371,7 +371,7 @@ type Log struct { // RechargeRecord 充值记录模型 type RechargeRecord struct { ID uint `gorm:"primaryKey" json:"id"` - UserID uint `json:"user_id"` + UserID uint `gorm:"index" json:"user_id"` OrderNo string `gorm:"size:50;uniqueIndex" json:"order_no"` CardID *uint `json:"card_id"` CardCode string `gorm:"size:100" json:"card_code"` @@ -390,8 +390,8 @@ type RechargeRecord struct { // ConsumptionRecord 消费记录模型 type ConsumptionRecord struct { ID uint `gorm:"primaryKey" json:"id"` - UserID uint `json:"user_id"` - ApplicationID uint `json:"application_id"` + UserID uint `gorm:"index" json:"user_id"` + ApplicationID uint `gorm:"index" json:"application_id"` OrderNo string `gorm:"size:50;uniqueIndex" json:"order_no"` Type string `gorm:"size:50" json:"type"` // verification, card_purchase, subscription, feature, manual_deduct Content string `gorm:"type:text" json:"content"` @@ -895,11 +895,11 @@ type WebhookLog struct { RequestData string `gorm:"type:text" json:"request_data"` ResponseCode int `json:"response_code"` ResponseData string `gorm:"type:text" json:"response_data"` - Status string `gorm:"size:20" json:"status"` // success, failed, retrying + Status string `gorm:"size:20;index" json:"status"` RetryCount int `json:"retry_count"` ErrorMessage string `gorm:"type:text" json:"error_message"` - Duration int `json:"duration"` // 请求耗时(毫秒) - CreatedAt time.Time `json:"created_at"` + Duration int `json:"duration"` + CreatedAt time.Time `gorm:"index" json:"created_at"` WebhookConfig WebhookConfig `gorm:"foreignKey:WebhookID" json:"webhook,omitempty"` } @@ -925,17 +925,17 @@ type ExtensionAPIKey struct { // ApiUsage API调用统计模型 type ApiUsage struct { ID uint `gorm:"primaryKey" json:"id"` - UserID uint `json:"user_id"` - ApplicationID uint `json:"application_id"` + UserID uint `gorm:"index" json:"user_id"` + ApplicationID uint `gorm:"index" json:"application_id"` Endpoint string `gorm:"size:255" json:"endpoint"` Method string `gorm:"size:10" json:"method"` IPAddress string `gorm:"size:50" json:"ip_address"` UserAgent string `gorm:"size:500" json:"user_agent"` - ResponseTime int `json:"response_time"` // 响应时间(毫秒) + ResponseTime int `json:"response_time"` StatusCode int `json:"status_code"` Success bool `json:"success"` ErrorMessage string `gorm:"type:text" json:"error_message"` - CreatedAt time.Time `json:"created_at"` + CreatedAt time.Time `gorm:"index" json:"created_at"` User User `gorm:"foreignKey:UserID" json:"user,omitempty"` Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` diff --git a/backend/internal/router/admin/agents.go b/backend/internal/router/admin/agents.go index c464ff9..5ea0d07 100644 --- a/backend/internal/router/admin/agents.go +++ b/backend/internal/router/admin/agents.go @@ -1,7 +1,8 @@ -package admin +package admin import ( "fmt" + "strconv" "verification-platform-backend/internal/database" "verification-platform-backend/internal/model" "verification-platform-backend/internal/service" @@ -47,10 +48,18 @@ type AgentTreeNode struct { } func handleGetAgents(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + var total int64 + database.DB.Model(&model.User{}).Where("role = ?", "agent").Count(&total) + var users []model.User if err := database.DB.Where("role = ?", "agent"). Preload("ParentAgent"). Order("created_at DESC"). + Offset((page - 1) * pageSize). + Limit(pageSize). Find(&users).Error; err != nil { response.Error(c, 500, "获取代理列表失败") return @@ -74,18 +83,50 @@ func handleGetAgents(c *gin.Context) { } var result []AgentResponse + + agentIDs := make([]uint, len(users)) + for i, u := range users { + agentIDs[i] = u.ID + } + + childCountMap := make(map[uint]int) + cardsCountMap := make(map[uint]int) + if len(agentIDs) > 0 { + type CountResult struct { + ParentAgentID uint + Count int + } + var childCounts []CountResult + database.DB.Model(&model.User{}). + Select("parent_agent_id, COUNT(*) as count"). + Where("parent_agent_id IN ?", agentIDs). + Group("parent_agent_id"). + Find(&childCounts) + for _, cc := range childCounts { + childCountMap[cc.ParentAgentID] = cc.Count + } + + type CardCountResult struct { + CreatorID uint + Count int + } + var cardCounts []CardCountResult + database.DB.Model(&model.Card{}). + Select("creator_id, COUNT(*) as count"). + Where("creator_id IN ?", agentIDs). + Group("creator_id"). + Find(&cardCounts) + for _, cc := range cardCounts { + cardsCountMap[cc.CreatorID] = cc.Count + } + } + for _, user := range users { var parentAgentName string if user.ParentAgent != nil { parentAgentName = user.ParentAgent.Username } - var childAgentsCount int64 - database.DB.Model(&model.User{}).Where("parent_agent_id = ?", user.ID).Count(&childAgentsCount) - - var cardsCount int64 - database.DB.Model(&model.Card{}).Where("creator_id = ?", user.ID).Count(&cardsCount) - var lastLoginAt string if user.LastLoginAt != nil { lastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05") @@ -109,14 +150,17 @@ func handleGetAgents(c *gin.Context) { LastLoginAt: lastLoginAt, Balance: user.Balance, CanCreateAgent: user.CanCreateAgent, - CardsCount: int(cardsCount), - ChildAgentsCount: int(childAgentsCount), + CardsCount: cardsCountMap[user.ID], + ChildAgentsCount: childCountMap[user.ID], }) } response.Success(c, gin.H{ - "agents": result, - "total": len(result), + "agents": result, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), }) } diff --git a/backend/internal/router/admin/applications.go b/backend/internal/router/admin/applications.go index 3741ad3..8ef9b11 100644 --- a/backend/internal/router/admin/applications.go +++ b/backend/internal/router/admin/applications.go @@ -18,6 +18,7 @@ import ( "verification-platform-backend/pkg/response" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) func SetupApplicationRoutes(r *gin.RouterGroup) { @@ -1073,11 +1074,18 @@ func handleManualDeduct(c *gin.Context) { return } - user.Balance -= req.Amount - if err := database.DB.Save(&user).Error; err != nil { + result := database.DB.Model(&model.AppUser{}).Where("id = ? AND balance >= ?", user.ID, req.Amount). + Update("balance", gorm.Expr("balance - ?", req.Amount)) + if result.Error != nil { response.Error(c, 500, "扣费失败") return } + if result.RowsAffected == 0 { + response.Error(c, 400, "余额不足") + return + } + + database.DB.Where("id = ?", user.ID).First(&user) record := model.ConsumptionRecord{ UserID: user.ID, diff --git a/backend/internal/router/admin/cards.go b/backend/internal/router/admin/cards.go index 11da1a3..5ec43cf 100644 --- a/backend/internal/router/admin/cards.go +++ b/backend/internal/router/admin/cards.go @@ -443,7 +443,14 @@ func handleGetCards(c *gin.Context) { query = query.Where("cards.created_at <= ?", endDate+" 23:59:59") } - if err := query.Order("cards.created_at DESC").Find(&cards).Error; err != nil { + var total int64 + query.Count(&total) + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + offset := (page - 1) * pageSize + + if err := query.Order("cards.created_at DESC").Offset(offset).Limit(pageSize).Find(&cards).Error; err != nil { fmt.Printf("[DEBUG] Error fetching cards: %v\n", err) response.Error(c, 500, "获取卡密列表失败") return @@ -451,7 +458,13 @@ func handleGetCards(c *gin.Context) { fmt.Printf("[DEBUG] Found %d cards for user %d\n", len(cards), userID) - response.Success(c, cards) + response.Success(c, gin.H{ + "cards": cards, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), + }) } func handleCreateCards(c *gin.Context) { @@ -742,10 +755,8 @@ func handleBatchGenerateCards(c *gin.Context) { } totalCost := float64(req.Count) * cardType.Price - fmt.Printf("[DEBUG] Total cost: %f, Balance: %f\n", totalCost, agentUser.Balance) if agentUser.Balance < totalCost { - fmt.Printf("[DEBUG] Insufficient balance\n") response.Error(c, 400, "余额不足") return } @@ -757,12 +768,18 @@ func handleBatchGenerateCards(c *gin.Context) { } }() - if err := tx.Model(&agentUser).Update("balance", agentUser.Balance-totalCost).Error; err != nil { + result := tx.Model(&model.User{}).Where("id = ? AND balance >= ?", agentUser.ID, totalCost). + Update("balance", gorm.Expr("balance - ?", totalCost)) + if result.Error != nil { tx.Rollback() - fmt.Printf("[DEBUG] Update balance error: %v\n", err) response.Error(c, 500, "扣款失败") return } + if result.RowsAffected == 0 { + tx.Rollback() + response.Error(c, 400, "余额不足") + return + } cards := make([]model.Card, 0, req.Count) for i := 0; i < req.Count; i++ { diff --git a/backend/internal/router/admin/devices.go b/backend/internal/router/admin/devices.go index 7d09a5a..d27b458 100644 --- a/backend/internal/router/admin/devices.go +++ b/backend/internal/router/admin/devices.go @@ -1,8 +1,9 @@ -package admin +package admin import ( "fmt" "log" + "strconv" "time" "verification-platform-backend/internal/database" "verification-platform-backend/internal/model" @@ -96,28 +97,58 @@ func handleGetDevices(c *gin.Context) { query = query.Where("device_id = ?", deviceIDFilter) } - if err := query.Find(&devices).Error; err != nil { + var total int64 + query.Count(&total) + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + offset := (page - 1) * pageSize + + if err := query.Offset(offset).Limit(pageSize).Find(&devices).Error; err != nil { response.Error(c, 500, "获取设备列表失败") return } devicesWithDetails := make([]DeviceWithDetails, 0, len(devices)) - for _, device := range devices { - heartbeatTimeout := appHeartbeatTimeoutMap[device.ApplicationID] - timeoutThreshold := time.Now().Add(-time.Duration(heartbeatTimeout) * time.Second) - var onlineSessionCount int64 + deviceIDs := make([]uint, len(devices)) + for i, d := range devices { + deviceIDs[i] = d.ID + } + + onlineSessionMap := make(map[uint]int) + if len(deviceIDs) > 0 { + type SessionCountResult struct { + DeviceID uint + Count int + } + var sessionCounts []SessionCountResult database.DB.Model(&model.DeviceSession{}). - Where("device_id = ? AND last_heartbeat > ?", device.ID, timeoutThreshold). - Count(&onlineSessionCount) + Select("device_id, COUNT(*) as count"). + Where("device_id IN ? AND last_heartbeat > ?", deviceIDs, time.Now().Add(-time.Duration(300)*time.Second)). + Group("device_id"). + Find(&sessionCounts) + for _, sc := range sessionCounts { + onlineSessionMap[sc.DeviceID] = sc.Count + } + } + + for _, device := range devices { + _ = appHeartbeatTimeoutMap[device.ApplicationID] devicesWithDetails = append(devicesWithDetails, DeviceWithDetails{ UserDevice: device, - OnlineSessions: int(onlineSessionCount), + OnlineSessions: onlineSessionMap[device.ID], }) } - response.Success(c, gin.H{"devices": devicesWithDetails}) + response.Success(c, gin.H{ + "devices": devicesWithDetails, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), + }) } func handleUpdateDeviceStatus(c *gin.Context) { diff --git a/backend/internal/router/admin/finance.go b/backend/internal/router/admin/finance.go index db01abc..a27000f 100644 --- a/backend/internal/router/admin/finance.go +++ b/backend/internal/router/admin/finance.go @@ -1,7 +1,8 @@ -package admin +package admin import ( "fmt" + "strconv" "verification-platform-backend/internal/database" "verification-platform-backend/internal/model" "verification-platform-backend/internal/service" @@ -131,6 +132,8 @@ func handleGetRechargeRecords(c *gin.Context) { status := c.Query("status") startDate := c.Query("start_date") endDate := c.Query("end_date") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) if search != "" { query = query.Where("order_no LIKE ? OR card_code LIKE ?", "%"+search+"%", "%"+search+"%") @@ -145,13 +148,20 @@ func handleGetRechargeRecords(c *gin.Context) { query = query.Where("created_at <= ?", endDate+" 23:59:59") } - if err := query.Order("created_at DESC").Find(&records).Error; err != nil { + var total int64 + query.Count(&total) + + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil { response.Error(c, 500, "获取充值记录失败") return } response.Success(c, gin.H{ - "records": records, - "total": len(records), + "records": records, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), }) } @@ -192,6 +202,8 @@ func handleGetConsumptionRecords(c *gin.Context) { status := c.Query("status") startDate := c.Query("start_date") endDate := c.Query("end_date") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) if search != "" { query = query.Where("order_no LIKE ? OR content LIKE ?", "%"+search+"%", "%"+search+"%") @@ -209,13 +221,20 @@ func handleGetConsumptionRecords(c *gin.Context) { query = query.Where("created_at <= ?", endDate+" 23:59:59") } - if err := query.Order("created_at DESC").Find(&records).Error; err != nil { + var total int64 + query.Count(&total) + + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil { response.Error(c, 500, "获取消费记录失败") return } response.Success(c, gin.H{ - "records": records, - "total": len(records), + "records": records, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), }) } diff --git a/backend/internal/router/admin/system_settings.go b/backend/internal/router/admin/system_settings.go index 861b1d4..2b8a0ba 100644 --- a/backend/internal/router/admin/system_settings.go +++ b/backend/internal/router/admin/system_settings.go @@ -11,6 +11,7 @@ import ( "time" "verification-platform-backend/internal/database" "verification-platform-backend/internal/model" + "verification-platform-backend/internal/scheduler" "github.com/gin-gonic/gin" ) @@ -25,6 +26,9 @@ func SetupSystemSettingsRoutes(r *gin.RouterGroup) { settings.PUT("/payment", handleUpdatePaymentSettings) settings.GET("/email", handleGetEmailSettings) settings.PUT("/email", handleUpdateEmailSettings) + settings.GET("/cleanup", handleGetCleanupSettings) + settings.PUT("/cleanup", handleUpdateCleanupSettings) + settings.POST("/cleanup/run", handleRunCleanup) } paymentChannels := r.Group("/payment-channels") @@ -172,6 +176,88 @@ func handleGetSystemSettings(c *gin.Context) { }) } +type CleanupSettingsResponse struct { + EnableAutoCleanup bool `json:"enable_auto_cleanup"` + CleanupIntervalHours int `json:"cleanup_interval_hours"` + CaptchaRetentionDays int `json:"captcha_retention_days"` + VerifyCodeRetentionDays int `json:"verify_code_retention_days"` + ApiUsageRetentionDays int `json:"api_usage_retention_days"` + WebhookLogRetentionDays int `json:"webhook_log_retention_days"` + DeviceSessionRetentionDays int `json:"device_session_retention_days"` +} + +func handleGetCleanupSettings(c *gin.Context) { + cfg := scheduler.GetCleanupConfig() + + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "data": CleanupSettingsResponse{ + EnableAutoCleanup: cfg.EnableAutoCleanup, + CleanupIntervalHours: cfg.CleanupIntervalHours, + CaptchaRetentionDays: cfg.CaptchaRetentionDays, + VerifyCodeRetentionDays: cfg.VerifyCodeRetentionDays, + ApiUsageRetentionDays: cfg.ApiUsageRetentionDays, + WebhookLogRetentionDays: cfg.WebhookLogRetentionDays, + DeviceSessionRetentionDays: cfg.DeviceSessionRetentionDays, + }, + }) +} + +func handleUpdateCleanupSettings(c *gin.Context) { + var req CleanupSettingsResponse + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "message": "无效的请求数据", + }) + return + } + + settings := []struct { + Key string + Value string + }{ + {"enable_auto_cleanup", fmt.Sprintf("%v", req.EnableAutoCleanup)}, + {"cleanup_interval_hours", fmt.Sprintf("%d", req.CleanupIntervalHours)}, + {"captcha_retention_days", fmt.Sprintf("%d", req.CaptchaRetentionDays)}, + {"verify_code_retention_days", fmt.Sprintf("%d", req.VerifyCodeRetentionDays)}, + {"api_usage_retention_days", fmt.Sprintf("%d", req.ApiUsageRetentionDays)}, + {"webhook_log_retention_days", fmt.Sprintf("%d", req.WebhookLogRetentionDays)}, + {"device_session_retention_days", fmt.Sprintf("%d", req.DeviceSessionRetentionDays)}, + } + + for _, s := range settings { + var setting model.Setting + result := database.DB.Where("category = ? AND key = ?", "cleanup", s.Key).First(&setting) + if result.Error == nil { + setting.Value = s.Value + database.DB.Save(&setting) + } else { + setting = model.Setting{ + Category: "cleanup", + Key: s.Key, + Value: s.Value, + } + database.DB.Create(&setting) + } + } + + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "message": "保存成功", + }) +} + +func handleRunCleanup(c *gin.Context) { + cfg := scheduler.GetCleanupConfig() + scheduler.RunCleanup(cfg) + + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "message": "清理完成", + }) +} + func parseSettingInt(value string, defaultValue int) int { if value == "" { return defaultValue diff --git a/backend/internal/router/admin/users.go b/backend/internal/router/admin/users.go index cffa1ef..9d5b7b1 100644 --- a/backend/internal/router/admin/users.go +++ b/backend/internal/router/admin/users.go @@ -11,6 +11,7 @@ import ( "verification-platform-backend/pkg/utils" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) type UserWithStatus struct { @@ -192,6 +193,28 @@ func handleGetUsers(c *gin.Context) { offlineCount := 0 bannedCount := 0 + userIDs := make([]uint, len(users)) + for i, u := range users { + userIDs[i] = u.ID + } + + deviceCountMap := make(map[uint]int) + if len(userIDs) > 0 { + type DeviceCountResult struct { + UserID uint + Count int + } + var deviceCounts []DeviceCountResult + database.DB.Model(&model.UserDevice{}). + Select("user_id, COUNT(*) as count"). + Where("user_id IN ?", userIDs). + Group("user_id"). + Find(&deviceCounts) + for _, dc := range deviceCounts { + deviceCountMap[dc.UserID] = dc.Count + } + } + usersWithStatus := make([]UserWithStatus, 0, len(users)) for _, user := range users { log.Printf("[DEBUG] User ID=%d, Username=%s, LastLoginAt=%v, LastHeartbeatAt=%v", user.ID, user.Username, user.LastLoginAt, user.LastHeartbeatAt) @@ -214,15 +237,12 @@ func handleGetUsers(c *gin.Context) { bannedCount++ } - var deviceCount int64 - database.DB.Model(&model.UserDevice{}).Where("user_id = ?", user.ID).Count(&deviceCount) - log.Printf("[DEBUG] 用户 %s (ID=%d) 余额: %f", user.Username, user.ID, user.Balance) usersWithStatus = append(usersWithStatus, UserWithStatus{ AppUser: user, OnlineStatus: onlineStatus, - DeviceCount: int(deviceCount), + DeviceCount: deviceCountMap[user.ID], }) } @@ -705,22 +725,35 @@ func handleUpdateExpiry(c *gin.Context) { } } else { if req.Type == "recharge" { - appUser.Balance += req.Amount + if err := database.DB.Model(&model.AppUser{}).Where("id = ?", appUser.ID). + Update("balance", gorm.Expr("balance + ?", req.Amount)).Error; err != nil { + response.Error(c, 500, "充值失败") + return + } } else if req.Type == "deduct" { - if appUser.Balance < req.Amount { + result := database.DB.Model(&model.AppUser{}).Where("id = ? AND balance >= ?", appUser.ID, req.Amount). + Update("balance", gorm.Expr("balance - ?", req.Amount)) + if result.Error != nil { + response.Error(c, 500, "扣费失败") + return + } + if result.RowsAffected == 0 { response.Error(c, 400, "余额不足") return } - appUser.Balance -= req.Amount } else { response.Error(c, 400, "操作类型错误") return } } - if err := database.DB.Save(&appUser).Error; err != nil { - response.Error(c, 500, "更新失败") - return + if app.BillingType == "balance" { + database.DB.Where("id = ?", appUser.ID).First(&appUser) + } else { + if err := database.DB.Save(&appUser).Error; err != nil { + response.Error(c, 500, "更新失败") + return + } } response.Success(c, appUser) diff --git a/backend/internal/router/app/account.go b/backend/internal/router/app/account.go index dd67e44..1fabd2f 100644 --- a/backend/internal/router/app/account.go +++ b/backend/internal/router/app/account.go @@ -9,6 +9,7 @@ import ( "verification-platform-backend/pkg/response" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) func SetupAccountRoutes(r *gin.RouterGroup) { @@ -126,14 +127,15 @@ func handleAppHeartbeat(c *gin.Context) { } log.Printf("[DEBUG] User %d subscription valid, skip balance deduction", user.ID) } else { - if user.Balance >= app.DeductionAmount { - user.Balance -= app.DeductionAmount - log.Printf("[DEBUG] Deducted %.2f from user %d, new balance: %.2f", app.DeductionAmount, user.ID, user.Balance) - } else { + result := database.DB.Model(&model.AppUser{}).Where("id = ? AND balance >= ?", user.ID, app.DeductionAmount). + Update("balance", gorm.Expr("balance - ?", app.DeductionAmount)) + if result.Error != nil || result.RowsAffected == 0 { log.Printf("[DEBUG] User %d has insufficient balance: %.2f < %.2f", user.ID, user.Balance, app.DeductionAmount) response.Error(c, 403, "余额不足") return } + database.DB.Where("id = ?", user.ID).First(&user) + log.Printf("[DEBUG] Deducted %.2f from user %d, new balance: %.2f", app.DeductionAmount, user.ID, user.Balance) } } } diff --git a/backend/internal/router/app/dynamic.go b/backend/internal/router/app/dynamic.go index 22e4c96..b0f184e 100644 --- a/backend/internal/router/app/dynamic.go +++ b/backend/internal/router/app/dynamic.go @@ -17,6 +17,7 @@ import ( "github.com/dop251/goja" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) func SetupDynamicRoutes(r *gin.RouterGroup) { @@ -408,9 +409,8 @@ func handleExtendTime(appID uint, actionMap map[string]interface{}) error { return nil } - user.Balance += float64(days) - - return database.DB.Save(&user).Error + return database.DB.Model(&model.AppUser{}).Where("id = ?", user.ID). + Update("balance", gorm.Expr("balance + ?", float64(days))).Error } func handleDeductPoints(appID uint, actionMap map[string]interface{}) error { @@ -433,12 +433,16 @@ func handleDeductPoints(appID uint, actionMap map[string]interface{}) error { return nil } - user.Balance -= points - if user.Balance < 0 { - user.Balance = 0 + result := database.DB.Model(&model.AppUser{}).Where("id = ? AND balance >= ?", user.ID, points). + Update("balance", gorm.Expr("balance - ?", points)) + if result.Error != nil { + return result.Error } - - return database.DB.Save(&user).Error + if result.RowsAffected == 0 { + return database.DB.Model(&model.AppUser{}).Where("id = ?", user.ID). + Update("balance", 0).Error + } + return nil } func handleUpdateUserVariable(appID *uint, userID *uint, actionMap map[string]interface{}) error { diff --git a/backend/internal/router/extension/extension.go b/backend/internal/router/extension/extension.go index afdea70..6e2e051 100644 --- a/backend/internal/router/extension/extension.go +++ b/backend/internal/router/extension/extension.go @@ -16,6 +16,7 @@ import ( "verification-platform-backend/pkg/response" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) func SetupRoutes(r *gin.RouterGroup) { @@ -265,7 +266,45 @@ func handleRechargeUser(c *gin.Context) { newExpiry := baseTime.Add(duration) user.ExpiryAt = &newExpiry case "balance": - user.Balance += float64(req.Amount) + tx := database.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + if err := tx.Model(&model.AppUser{}).Where("id = ?", user.ID). + Update("balance", gorm.Expr("balance + ?", float64(req.Amount))).Error; err != nil { + tx.Rollback() + service.LogVerification(c, &app.ID, &user.ID, "extension_recharge_failed", fmt.Sprintf("扩展API充值失败: 保存失败 - %s", user.Username), "", err) + response.Error(c, 500, "充值失败") + return + } + + record := model.RechargeRecord{ + UserID: user.ID, + OrderNo: fmt.Sprintf("EXT%d%d", time.Now().Unix(), user.ID), + Amount: float64(req.Amount), + Status: "success", + PaymentType: "extension_api", + Remark: req.Description, + } + if err := tx.Create(&record).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "充值失败") + return + } + + tx.Commit() + database.DB.Where("id = ?", user.ID).First(&user) + + service.LogVerification(c, &app.ID, &user.ID, "extension_recharge", fmt.Sprintf("扩展API充值: 用户%s, 类型:%s, 数量:%d", user.Username, req.Type, req.Amount), "", nil) + + response.Success(c, gin.H{ + "message": "充值成功", + "user": user, + }) + return default: service.LogVerification(c, &app.ID, &user.ID, "extension_recharge_failed", fmt.Sprintf("扩展API充值失败: 类型无效 - %s", req.Type), "", fmt.Errorf("充值类型无效")) response.Error(c, 400, "充值类型无效,仅支持days或balance类型") @@ -362,7 +401,53 @@ func handleDeductUser(c *gin.Context) { response.Error(c, 400, "余额不足") return } - user.Balance -= float64(req.Amount) + + tx := database.DB.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + result := tx.Model(&model.AppUser{}).Where("id = ? AND balance >= ?", user.ID, float64(req.Amount)). + Update("balance", gorm.Expr("balance - ?", float64(req.Amount))) + if result.Error != nil { + tx.Rollback() + service.LogVerification(c, &app.ID, &user.ID, "extension_deduct_failed", fmt.Sprintf("扩展API扣费失败: 保存失败 - %s", user.Username), "", result.Error) + response.Error(c, 500, "扣除失败") + return + } + if result.RowsAffected == 0 { + tx.Rollback() + response.Error(c, 400, "余额不足") + return + } + + record := model.ConsumptionRecord{ + UserID: user.ID, + OrderNo: fmt.Sprintf("EXT%d%d", time.Now().Unix(), user.ID), + Type: req.Type, + Amount: float64(req.Amount), + Status: "success", + PaymentType: "extension_api", + Remark: req.Description, + } + if err := tx.Create(&record).Error; err != nil { + tx.Rollback() + response.Error(c, 500, "扣除失败") + return + } + + tx.Commit() + database.DB.Where("id = ?", user.ID).First(&user) + + service.LogVerification(c, &app.ID, &user.ID, "extension_deduct", fmt.Sprintf("扩展API扣费: 用户%s, 类型:%s, 数量:%d", user.Username, req.Type, req.Amount), "", nil) + + response.Success(c, gin.H{ + "message": "扣除成功", + "user": user, + }) + return default: service.LogVerification(c, &app.ID, &user.ID, "extension_deduct_failed", fmt.Sprintf("扩展API扣费失败: 类型无效 - %s", req.Type), "", fmt.Errorf("扣除类型无效")) response.Error(c, 400, "扣除类型无效,仅支持days或balance类型") diff --git a/backend/internal/router/install/install.go b/backend/internal/router/install/install.go index 8f608dd..041601b 100644 --- a/backend/internal/router/install/install.go +++ b/backend/internal/router/install/install.go @@ -325,6 +325,7 @@ func generateConfigFile(dbType string, req SetupRequest, jwtSecret string) strin content.WriteString("# Redis配置\n") content.WriteString("redis:\n") + content.WriteString(fmt.Sprintf(" enabled: %v\n", req.UseRedis)) if req.UseRedis { content.WriteString(fmt.Sprintf(" host: %s\n", req.RedisHost)) content.WriteString(fmt.Sprintf(" port: %s\n", req.RedisPort)) diff --git a/backend/internal/scheduler/cleanup.go b/backend/internal/scheduler/cleanup.go new file mode 100644 index 0000000..2037185 --- /dev/null +++ b/backend/internal/scheduler/cleanup.go @@ -0,0 +1,145 @@ +package scheduler + +import ( + "fmt" + "log" + "time" + + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" +) + +type CleanupConfig struct { + EnableAutoCleanup bool + CleanupIntervalHours int + CaptchaRetentionDays int + VerifyCodeRetentionDays int + ApiUsageRetentionDays int + WebhookLogRetentionDays int + DeviceSessionRetentionDays int +} + +var defaultConfig = CleanupConfig{ + EnableAutoCleanup: false, + CleanupIntervalHours: 24, + CaptchaRetentionDays: 1, + VerifyCodeRetentionDays: 7, + ApiUsageRetentionDays: 30, + WebhookLogRetentionDays: 30, + DeviceSessionRetentionDays: 7, +} + +func GetCleanupConfig() CleanupConfig { + cfg := defaultConfig + + var settings []model.Setting + database.DB.Where("category = ?", "cleanup").Find(&settings) + + for _, s := range settings { + switch s.Key { + case "enable_auto_cleanup": + cfg.EnableAutoCleanup = s.Value == "true" + case "cleanup_interval_hours": + cfg.CleanupIntervalHours = parseVal(s.Value, defaultConfig.CleanupIntervalHours) + case "captcha_retention_days": + cfg.CaptchaRetentionDays = parseVal(s.Value, defaultConfig.CaptchaRetentionDays) + case "verify_code_retention_days": + cfg.VerifyCodeRetentionDays = parseVal(s.Value, defaultConfig.VerifyCodeRetentionDays) + case "api_usage_retention_days": + cfg.ApiUsageRetentionDays = parseVal(s.Value, defaultConfig.ApiUsageRetentionDays) + case "webhook_log_retention_days": + cfg.WebhookLogRetentionDays = parseVal(s.Value, defaultConfig.WebhookLogRetentionDays) + case "device_session_retention_days": + cfg.DeviceSessionRetentionDays = parseVal(s.Value, defaultConfig.DeviceSessionRetentionDays) + } + } + + return cfg +} + +func parseVal(value string, defaultVal int) int { + if value == "" { + return defaultVal + } + var result int + if _, err := fmt.Sscanf(value, "%d", &result); err != nil || result <= 0 { + return defaultVal + } + return result +} + +func StartCleanupScheduler() { + go func() { + for { + cfg := GetCleanupConfig() + interval := time.Duration(cfg.CleanupIntervalHours) * time.Hour + if interval < time.Hour { + interval = time.Hour + } + time.Sleep(interval) + + if !cfg.EnableAutoCleanup { + continue + } + + RunCleanup(cfg) + } + }() + log.Println("[Scheduler] Cleanup scheduler started") +} + +func RunCleanup(cfg CleanupConfig) { + now := time.Now() + totalCleaned := 0 + + if cfg.CaptchaRetentionDays > 0 { + cutoff := now.AddDate(0, 0, -cfg.CaptchaRetentionDays) + result := database.DB.Where("expires_at < ?", cutoff).Delete(&model.Captcha{}) + if result.RowsAffected > 0 { + log.Printf("[Cleanup] Deleted %d expired captchas", result.RowsAffected) + totalCleaned += int(result.RowsAffected) + } + } + + if cfg.VerifyCodeRetentionDays > 0 { + cutoff := now.AddDate(0, 0, -cfg.VerifyCodeRetentionDays) + emailResult := database.DB.Where("expires_at < ? AND used = ?", cutoff, true).Delete(&model.EmailVerifyCode{}) + smsResult := database.DB.Where("expires_at < ? AND used = ?", cutoff, true).Delete(&model.SmsVerifyCode{}) + count := emailResult.RowsAffected + smsResult.RowsAffected + if count > 0 { + log.Printf("[Cleanup] Deleted %d expired verify codes", count) + totalCleaned += int(count) + } + } + + if cfg.ApiUsageRetentionDays > 0 { + cutoff := now.AddDate(0, 0, -cfg.ApiUsageRetentionDays) + result := database.DB.Where("created_at < ?", cutoff).Delete(&model.ApiUsage{}) + if result.RowsAffected > 0 { + log.Printf("[Cleanup] Deleted %d old api usage records", result.RowsAffected) + totalCleaned += int(result.RowsAffected) + } + } + + if cfg.WebhookLogRetentionDays > 0 { + cutoff := now.AddDate(0, 0, -cfg.WebhookLogRetentionDays) + result := database.DB.Where("created_at < ?", cutoff).Delete(&model.WebhookLog{}) + if result.RowsAffected > 0 { + log.Printf("[Cleanup] Deleted %d old webhook logs", result.RowsAffected) + totalCleaned += int(result.RowsAffected) + } + } + + if cfg.DeviceSessionRetentionDays > 0 { + cutoff := now.AddDate(0, 0, -cfg.DeviceSessionRetentionDays) + result := database.DB.Where("last_heartbeat < ? AND last_heartbeat IS NOT NULL", cutoff).Delete(&model.DeviceSession{}) + if result.RowsAffected > 0 { + log.Printf("[Cleanup] Deleted %d expired device sessions", result.RowsAffected) + totalCleaned += int(result.RowsAffected) + } + } + + if totalCleaned > 0 { + log.Printf("[Cleanup] Total cleaned: %d records", totalCleaned) + } +} diff --git a/frontend/src/components/app-sidebar/types.ts b/frontend/src/components/app-sidebar/types.ts index 48a0e4d..96bca8c 100644 --- a/frontend/src/components/app-sidebar/types.ts +++ b/frontend/src/components/app-sidebar/types.ts @@ -33,6 +33,7 @@ export interface User { export interface Team { name: string logo: NavIcon | string + plan?: string } export interface SidebarData { diff --git a/frontend/src/layouts/admin.vue b/frontend/src/layouts/admin.vue index 6bb1341..761c842 100644 --- a/frontend/src/layouts/admin.vue +++ b/frontend/src/layouts/admin.vue @@ -115,7 +115,7 @@ async function loadSystemSettings() { } try { - const data = await api.get('/dev/system-settings') + const data = await api.get('/dev/system-settings') if (data) { const settings = { site_name: data.site_name || '', diff --git a/frontend/src/pages/admin/applications/[id]/settings.vue b/frontend/src/pages/admin/applications/[id]/settings.vue index 8833d05..2b4ef99 100644 --- a/frontend/src/pages/admin/applications/[id]/settings.vue +++ b/frontend/src/pages/admin/applications/[id]/settings.vue @@ -779,7 +779,7 @@ function toggleWeekday(index: number) {
邮箱配置 - + @@ -801,7 +801,7 @@ function toggleWeekday(index: number) {
短信配置 - + @@ -875,7 +875,7 @@ function toggleWeekday(index: number) {
邮箱配置 - + @@ -897,7 +897,7 @@ function toggleWeekday(index: number) {
短信配置 - + diff --git a/frontend/src/pages/admin/cards/index.vue b/frontend/src/pages/admin/cards/index.vue index 1ac6fec..d14dbcf 100644 --- a/frontend/src/pages/admin/cards/index.vue +++ b/frontend/src/pages/admin/cards/index.vue @@ -149,8 +149,16 @@ async function fetchCardTypes() { async function fetchCards() { loading.value = true try { - const data = await api.get('/dev/cards') - cards.value = Array.isArray(data) ? data : [] + const data = await api.get('/dev/cards') + if (data && typeof data === 'object' && data.cards) { + cards.value = data.cards + } + else if (Array.isArray(data)) { + cards.value = data + } + else { + cards.value = [] + } } catch (error) { console.error('获取卡密列表失败:', error) diff --git a/frontend/src/pages/admin/email-settings/[id].vue b/frontend/src/pages/admin/email-settings/[id].vue index bf2047e..3370360 100644 --- a/frontend/src/pages/admin/email-settings/[id].vue +++ b/frontend/src/pages/admin/email-settings/[id].vue @@ -39,7 +39,7 @@ const selectedEncryption = computed(() => { async function fetchEmailConfig() { loading.value = true try { - const data = await api.get(`/dev/email-configs/${route.params.id}`) + const data = await api.get(`/dev/email-configs/${route.params.id}`) if (data) { formData.value = { name: data.name, diff --git a/frontend/src/pages/admin/index.vue b/frontend/src/pages/admin/index.vue index 752cf99..bc7ba29 100644 --- a/frontend/src/pages/admin/index.vue +++ b/frontend/src/pages/admin/index.vue @@ -48,9 +48,9 @@ const provinces = ref([]) const onlineTrendData = ref([]) const recentTickets = ref([]) -const mapChart = ref(null) +const distributionChart = ref(null) const activityChart = ref(null) -let chartInstance: any = null +let distributionChartInstance: any = null let activityChartInstance: any = null const overseasUsers = computed(() => { @@ -231,30 +231,77 @@ function fetchCurrentUser() { } } -async function initMapChart() { - if (!mapChart.value) +async function initDistributionChart() { + if (!distributionChart.value) return try { - const [echarts, worldResponse] = await Promise.all([ - import('echarts'), - fetch('/world.json'), - ]) + const { default: Chart } = await import('chart.js/auto') - if (!worldResponse.ok) { - throw new Error('Failed to load world map data') + const ctx = distributionChart.value.getContext('2d') + if (!ctx) + return + + if (distributionChartInstance) { + distributionChartInstance.destroy() } - const worldData = await worldResponse.json() + const data = provinces.value.filter((d: any) => d.count > 0) + const labels = data.map((d: any) => d.name) + const values = data.map((d: any) => d.count) + const dark = isDark.value - echarts.registerMap('world', worldData) + const colors = [ + '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', + '#06b6d4', '#ec4899', '#14b8a6', '#f97316', '#6366f1', + ] - chartInstance = echarts.init(mapChart.value) - - updateMapOption() - - window.addEventListener('resize', () => { - chartInstance?.resize() + distributionChartInstance = new Chart(ctx, { + type: 'doughnut', + data: { + labels, + datasets: [{ + data: values, + backgroundColor: colors.slice(0, values.length), + borderColor: dark ? '#1e293b' : '#ffffff', + borderWidth: 2, + hoverOffset: 8, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + cutout: '55%', + plugins: { + legend: { + position: 'right', + labels: { + color: dark ? '#cbd5e1' : '#475569', + padding: 16, + usePointStyle: true, + pointStyleWidth: 12, + font: { + size: 13, + }, + }, + }, + tooltip: { + backgroundColor: 'rgba(15, 23, 42, 0.95)', + titleColor: '#f8fafc', + bodyColor: '#cbd5e1', + borderColor: '#334155', + borderWidth: 1, + padding: 12, + callbacks: { + label: (context: any) => { + const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0) + const percentage = total > 0 ? ((context.parsed / total) * 100).toFixed(1) : '0' + return ` ${context.label}: ${context.parsed} (${percentage}%)` + }, + }, + }, + }, + }, }) } catch (error) { @@ -262,352 +309,6 @@ async function initMapChart() { } } -const worldNameMap: Record = { - 'Afghanistan': '阿富汗', - 'Albania': '阿尔巴尼亚', - 'Algeria': '阿尔及利亚', - 'American Samoa': '美属萨摩亚', - 'Andorra': '安道尔', - 'Angola': '安哥拉', - 'Anguilla': '安圭拉', - 'Antarctica': '南极洲', - 'Antigua and Barbuda': '安提瓜和巴布达', - 'Argentina': '阿根廷', - 'Armenia': '亚美尼亚', - 'Aruba': '阿鲁巴', - 'Australia': '澳大利亚', - 'Austria': '奥地利', - 'Azerbaijan': '阿塞拜疆', - 'Bahamas': '巴哈马', - 'Bahrain': '巴林', - 'Bangladesh': '孟加拉国', - 'Barbados': '巴巴多斯', - 'Belarus': '白俄罗斯', - 'Belgium': '比利时', - 'Belize': '伯利兹', - 'Benin': '贝宁', - 'Bermuda': '百慕大', - 'Bhutan': '不丹', - 'Bolivia': '玻利维亚', - 'Bosnia and Herzegovina': '波黑', - 'Botswana': '博茨瓦纳', - 'Brazil': '巴西', - 'British Indian Ocean Ter.': '英属印度洋领地', - 'Brunei': '文莱', - 'Bulgaria': '保加利亚', - 'Burkina Faso': '布基纳法索', - 'Burundi': '布隆迪', - 'Cambodia': '柬埔寨', - 'Cameroon': '喀麦隆', - 'Canada': '加拿大', - 'Cape Verde': '佛得角', - 'Cayman Is.': '开曼群岛', - 'Central African Rep.': '中非', - 'Chad': '乍得', - 'Chile': '智利', - 'China': '中国', - 'Colombia': '哥伦比亚', - 'Comoros': '科摩罗', - 'Congo': '刚果', - 'Dem. Rep. Congo': '刚果民主共和国', - 'Cook Is.': '库克群岛', - 'Costa Rica': '哥斯达黎加', - 'Croatia': '克罗地亚', - 'Cuba': '古巴', - 'Cyprus': '塞浦路斯', - 'Czech Rep.': '捷克', - 'Côte d\'Ivoire': '科特迪瓦', - 'Denmark': '丹麦', - 'Djibouti': '吉布提', - 'Dominica': '多米尼克', - 'Dominican Rep.': '多米尼加', - 'Ecuador': '厄瓜多尔', - 'Egypt': '埃及', - 'El Salvador': '萨尔瓦多', - 'Equatorial Guinea': '赤道几内亚', - 'Eritrea': '厄立特里亚', - 'Estonia': '爱沙尼亚', - 'Ethiopia': '埃塞俄比亚', - 'Falkland Is.': '福克兰群岛', - 'Faeroe Is.': '法罗群岛', - 'Fiji': '斐济', - 'Finland': '芬兰', - 'France': '法国', - 'French Guiana': '法属圭亚那', - 'French Polynesia': '法属波利尼西亚', - 'French Southern Ter.': '法属南方领地', - 'Gabon': '加蓬', - 'Gambia': '冈比亚', - 'Gaza': '加沙', - 'Georgia': '格鲁吉亚', - 'Germany': '德国', - 'Ghana': '加纳', - 'Gibraltar': '直布罗陀', - 'Greece': '希腊', - 'Greenland': '格陵兰', - 'Grenada': '格林纳达', - 'Guadeloupe': '瓜德罗普', - 'Guam': '关岛', - 'Guatemala': '危地马拉', - 'Guinea': '几内亚', - 'Guinea-Bissau': '几内亚比绍', - 'Guyana': '圭亚那', - 'Haiti': '海地', - 'Heard I. and McDonald Is.': '赫德岛和麦克唐纳群岛', - 'Honduras': '洪都拉斯', - 'Hong Kong': '香港', - 'Hungary': '匈牙利', - 'Iceland': '冰岛', - 'India': '印度', - 'Indonesia': '印度尼西亚', - 'Iran': '伊朗', - 'Iraq': '伊拉克', - 'Ireland': '爱尔兰', - 'Isle of Man': '马恩岛', - 'Israel': '以色列', - 'Italy': '意大利', - 'Jamaica': '牙买加', - 'Japan': '日本', - 'Jordan': '约旦', - 'Kazakhstan': '哈萨克斯坦', - 'Kenya': '肯尼亚', - 'Kiribati': '基里巴斯', - 'Korea': '韩国', - 'Dem. Rep. Korea': '朝鲜', - 'Kuwait': '科威特', - 'Kyrgyzstan': '吉尔吉斯斯坦', - 'Lao PDR': '老挝', - 'Latvia': '拉脱维亚', - 'Lebanon': '黎巴嫩', - 'Lesotho': '莱索托', - 'Liberia': '利比里亚', - 'Libya': '利比亚', - 'Liechtenstein': '列支敦士登', - 'Lithuania': '立陶宛', - 'Luxembourg': '卢森堡', - 'Macao': '澳门', - 'Macedonia': '马其顿', - 'Madagascar': '马达加斯加', - 'Malawi': '马拉维', - 'Malaysia': '马来西亚', - 'Maldives': '马尔代夫', - 'Mali': '马里', - 'Malta': '马耳他', - 'Marshall Is.': '马绍尔群岛', - 'Martinique': '马提尼克', - 'Mauritania': '毛里塔尼亚', - 'Mauritius': '毛里求斯', - 'Mexico': '墨西哥', - 'Micronesia': '密克罗尼西亚', - 'Moldova': '摩尔多瓦', - 'Monaco': '摩纳哥', - 'Mongolia': '蒙古', - 'Montenegro': '黑山', - 'Montserrat': '蒙特塞拉特', - 'Morocco': '摩洛哥', - 'Mozambique': '莫桑比克', - 'Myanmar': '缅甸', - 'Namibia': '纳米比亚', - 'Nauru': '瑙鲁', - 'Nepal': '尼泊尔', - 'Netherlands': '荷兰', - 'New Caledonia': '新喀里多尼亚', - 'New Zealand': '新西兰', - 'Nicaragua': '尼加拉瓜', - 'Niger': '尼日尔', - 'Nigeria': '尼日利亚', - 'Niue': '纽埃', - 'Norfolk Island': '诺福克岛', - 'Northern Mariana Is.': '北马里亚纳群岛', - 'Norway': '挪威', - 'Oman': '阿曼', - 'Pakistan': '巴基斯坦', - 'Palau': '帕劳', - 'Palestine': '巴勒斯坦', - 'Panama': '巴拿马', - 'Papua New Guinea': '巴布亚新几内亚', - 'Paraguay': '巴拉圭', - 'Peru': '秘鲁', - 'Philippines': '菲律宾', - 'Pitcairn Is.': '皮特凯恩群岛', - 'Poland': '波兰', - 'Portugal': '葡萄牙', - 'Puerto Rico': '波多黎各', - 'Qatar': '卡塔尔', - 'Réunion': '留尼汪', - 'Romania': '罗马尼亚', - 'Russia': '俄罗斯', - 'Rwanda': '卢旺达', - 'Saint Helena': '圣赫勒拿', - 'Saint Kitts and Nevis': '圣基茨和尼维斯', - 'Saint Lucia': '圣卢西亚', - 'Saint Pierre and Miquelon': '圣皮埃尔和密克隆', - 'Saint Vincent and the Grenadines': '圣文森特和格林纳丁斯', - 'Samoa': '萨摩亚', - 'San Marino': '圣马力诺', - 'Sao Tome and Principe': '圣多美和普林西比', - 'Saudi Arabia': '沙特阿拉伯', - 'Senegal': '塞内加尔', - 'Serbia': '塞尔维亚', - 'Seychelles': '塞舌尔', - 'Sierra Leone': '塞拉利昂', - 'Singapore': '新加坡', - 'Slovakia': '斯洛伐克', - 'Slovenia': '斯洛文尼亚', - 'Solomon Is.': '所罗门群岛', - 'Somalia': '索马里', - 'South Africa': '南非', - 'South Georgia and the South Sandwich Is.': '南乔治亚和南桑威奇群岛', - 'S. Sudan': '南苏丹', - 'Spain': '西班牙', - 'Sri Lanka': '斯里兰卡', - 'Sudan': '苏丹', - 'Suriname': '苏里南', - 'Swaziland': '斯威士兰', - 'Sweden': '瑞典', - 'Switzerland': '瑞士', - 'Syria': '叙利亚', - 'Taiwan': '台湾', - 'Tajikistan': '塔吉克斯坦', - 'Tanzania': '坦桑尼亚', - 'Thailand': '泰国', - 'Timor-Leste': '东帝汶', - 'Togo': '多哥', - 'Tokelau': '托克劳', - 'Tonga': '汤加', - 'Trinidad and Tobago': '特立尼达和多巴哥', - 'Tunisia': '突尼斯', - 'Turkey': '土耳其', - 'Turkmenistan': '土库曼斯坦', - 'Turks and Caicos Is.': '特克斯和凯科斯群岛', - 'Tuvalu': '图瓦卢', - 'Uganda': '乌干达', - 'Ukraine': '乌克兰', - 'United Arab Emirates': '阿联酋', - 'United Kingdom': '英国', - 'United States': '美国', - 'United States Minor Outlying Is.': '美属小离岛', - 'Uruguay': '乌拉圭', - 'Uzbekistan': '乌兹别克斯坦', - 'Vanuatu': '瓦努阿图', - 'Vatican City': '梵蒂冈', - 'Venezuela': '委内瑞拉', - 'Vietnam': '越南', - 'Virgin Is.': '维尔京群岛', - 'W. Sahara': '西撒哈拉', - 'Yemen': '也门', - 'Zambia': '赞比亚', - 'Zimbabwe': '津巴布韦', -} - -function updateMapOption() { - if (!chartInstance) - return - - const data = provinces.value.filter((d: any) => d.count > 0) - const maxCount = Math.max(...data.map((d: any) => d.count || 0), 1) - - const dark = isDark.value - - const seriesData = data.map((d: any) => { - const ratio = d.count / maxCount - let r: number, g: number, b: number - if (dark) { - r = Math.round(30 + (59 - 30) * ratio) - g = Math.round(58 + (130 - 58) * ratio) - b = Math.round(138 + (246 - 138) * ratio) - } - else { - r = Math.round(147 + (37 - 147) * ratio) - g = Math.round(197 + (99 - 197) * ratio) - b = Math.round(253 + (235 - 253) * ratio) - } - return { - name: d.name, - value: d.count, - count: d.count, - online: d.online || 0, - offline: d.offline || 0, - itemStyle: { - areaColor: `rgb(${r}, ${g}, ${b})`, - }, - } - }) - - const option = { - tooltip: { - trigger: 'item', - backgroundColor: dark ? 'rgba(15, 23, 42, 0.95)' : 'rgba(255, 255, 255, 0.95)', - borderColor: dark ? '#334155' : '#e2e8f0', - borderWidth: 1, - padding: 12, - textStyle: { - color: dark ? '#f8fafc' : '#0f172a', - }, - formatter: (params: any) => { - if (params.data && params.data.count > 0) { - return `
-
${params.name}
-
- - ${t('admin.online')}: ${params.data.online} -
-
- - ${t('admin.offline')}: ${params.data.offline} -
-
- - ${t('admin.total')}: ${params.data.count} -
-
` - } - return `
-
${params.name}
-
${t('admin.noUserData')}
-
` - }, - }, - geo: { - map: 'world', - roam: true, - zoom: 1.2, - nameMap: worldNameMap, - label: { - show: false, - }, - emphasis: { - label: { - show: true, - color: dark ? '#f8fafc' : '#0f172a', - fontSize: 11, - fontWeight: 600, - }, - itemStyle: { - areaColor: dark ? '#334155' : '#f1f5f9', - borderColor: dark ? '#475569' : '#94a3b8', - borderWidth: 1, - }, - }, - itemStyle: { - areaColor: dark ? '#1e293b' : '#e2e8f0', - borderColor: dark ? '#334155' : '#cbd5e1', - borderWidth: 0.5, - }, - }, - series: [ - { - name: t('admin.userDistribution'), - type: 'map', - geoIndex: 0, - data: seriesData, - }, - ], - } - - chartInstance.setOption(option) -} - function initActivityChart() { if (!activityChart.value) return @@ -719,20 +420,20 @@ watch(loading, async (newVal) => { if (!newVal) { await nextTick() setTimeout(() => { - initMapChart() + initDistributionChart() initActivityChart() }, 100) } }) watch(isDark, () => { - updateMapOption() + initDistributionChart() }) onUnmounted(() => { - if (chartInstance) { - chartInstance.dispose() - chartInstance = null + if (distributionChartInstance) { + distributionChartInstance.destroy() + distributionChartInstance = null } if (activityChartInstance) { activityChartInstance.destroy() @@ -852,7 +553,7 @@ onUnmounted(() => {
-
+
diff --git a/frontend/src/pages/admin/payment-channels/[id].vue b/frontend/src/pages/admin/payment-channels/[id].vue index 211d8f6..c5843a3 100644 --- a/frontend/src/pages/admin/payment-channels/[id].vue +++ b/frontend/src/pages/admin/payment-channels/[id].vue @@ -42,7 +42,7 @@ const selectedType = computed(() => { async function fetchPaymentChannel() { loading.value = true try { - const data = await api.get(`/dev/payment-channels/${route.params.id}`) + const data = await api.get(`/dev/payment-channels/${route.params.id}`) if (data) { formData.value = { name: data.name, diff --git a/frontend/src/pages/admin/sms-settings/[id].vue b/frontend/src/pages/admin/sms-settings/[id].vue index fd69ec2..f9c74c2 100644 --- a/frontend/src/pages/admin/sms-settings/[id].vue +++ b/frontend/src/pages/admin/sms-settings/[id].vue @@ -60,7 +60,7 @@ const configPlaceholder = computed(() => { async function fetchSmsConfig() { loading.value = true try { - const data = await api.get(`/dev/sms-configs/${route.params.id}`) + const data = await api.get(`/dev/sms-configs/${route.params.id}`) if (data) { formData.value = { name: data.name, diff --git a/frontend/src/pages/admin/storage-configs/[id].vue b/frontend/src/pages/admin/storage-configs/[id].vue index 1093bb8..2e12fb8 100644 --- a/frontend/src/pages/admin/storage-configs/[id].vue +++ b/frontend/src/pages/admin/storage-configs/[id].vue @@ -67,7 +67,7 @@ const endpointPlaceholder = computed(() => { async function fetchStorageConfig() { loading.value = true try { - const data = await api.get(`/dev/storage-configs/${route.params.id}`) + const data = await api.get(`/dev/storage-configs/${route.params.id}`) if (data) { formData.value = { name: data.name || '', diff --git a/frontend/src/pages/admin/system-settings/index.vue b/frontend/src/pages/admin/system-settings/index.vue index d96f150..f1ada44 100644 --- a/frontend/src/pages/admin/system-settings/index.vue +++ b/frontend/src/pages/admin/system-settings/index.vue @@ -1,5 +1,5 @@