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
+3
View File
@@ -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"
+1
View File
@@ -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", "")
+10 -4
View File
@@ -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"),
+18 -18
View File
@@ -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"`
+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)
+6 -4
View File
@@ -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)
}
}
}
+12 -8
View File
@@ -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 {
+87 -2
View File
@@ -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类型")
@@ -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))
+145
View File
@@ -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)
}
}