perf: 性能优化与错误修复

- 修复 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 响应格式
- 添加数据库索引优化查询性能
- 实现可配置的数据清理定时任务
This commit is contained in:
2026-05-06 19:19:02 +08:00
parent 98986b0eb7
commit 086c5c6573
27 changed files with 843 additions and 467 deletions
+55 -11
View File
@@ -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),
})
}
+10 -2
View File
@@ -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,
+23 -6
View File
@@ -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++ {
+41 -10
View File
@@ -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) {
+26 -7
View File
@@ -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),
})
}
@@ -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
+43 -10
View File
@@ -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)