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/database"
"verification-platform-backend/internal/middleware" "verification-platform-backend/internal/middleware"
"verification-platform-backend/internal/router" "verification-platform-backend/internal/router"
"verification-platform-backend/internal/scheduler"
"verification-platform-backend/internal/service" "verification-platform-backend/internal/service"
"verification-platform-backend/pkg/logger" "verification-platform-backend/pkg/logger"
@@ -234,6 +235,8 @@ func startServer() {
router.SetupRoutes(r) router.SetupRoutes(r)
setupEmbeddedFrontend(r) setupEmbeddedFrontend(r)
scheduler.StartCleanupScheduler()
port := config.GetString("app.port") port := config.GetString("app.port")
if port == "" { if port == "" {
port = "8080" port = "8080"
+1
View File
@@ -57,6 +57,7 @@ func setDefaults() {
viper.SetDefault("database.max_open_conns", "100") viper.SetDefault("database.max_open_conns", "100")
// Redis配置 // Redis配置
viper.SetDefault("redis.enabled", false)
viper.SetDefault("redis.host", "localhost") viper.SetDefault("redis.host", "localhost")
viper.SetDefault("redis.port", "6379") viper.SetDefault("redis.port", "6379")
viper.SetDefault("redis.password", "") viper.SetDefault("redis.password", "")
+10 -4
View File
@@ -9,6 +9,7 @@ package database
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -162,7 +163,7 @@ func runMigrations() {
Name string Name string
Type string Type string
NotNull int NotNull int
DefaultVal interface{} DefaultVal sql.NullString
PK int PK int
} }
DB.Raw("PRAGMA table_info(applications)").Scan(&columns) DB.Raw("PRAGMA table_info(applications)").Scan(&columns)
@@ -171,13 +172,12 @@ func runMigrations() {
log.Printf(" - %s (%s)\n", col.Name, col.Type) log.Printf(" - %s (%s)\n", col.Name, col.Type)
} }
// 检查app_users表结构
var appUserColumns []struct { var appUserColumns []struct {
CID int CID int
Name string Name string
Type string Type string
NotNull int NotNull int
DefaultVal interface{} DefaultVal sql.NullString
PK int PK int
} }
DB.Raw("PRAGMA table_info(app_users)").Scan(&appUserColumns) DB.Raw("PRAGMA table_info(app_users)").Scan(&appUserColumns)
@@ -272,7 +272,7 @@ func runMigrations() {
Name string Name string
Type string Type string
NotNull int NotNull int
DefaultVal interface{} DefaultVal sql.NullString
PK int PK int
} }
DB.Raw("PRAGMA table_info(risk_control_rules)").Scan(&riskControlTableInfo) DB.Raw("PRAGMA table_info(risk_control_rules)").Scan(&riskControlTableInfo)
@@ -710,6 +710,12 @@ func initDocData() {
// initRedis 初始化Redis连接 // initRedis 初始化Redis连接
func initRedis() { func initRedis() {
if !config.GetBool("redis.enabled") {
log.Println("Redis is disabled by configuration, skipping connection")
RDB = nil
return
}
RDB = redis.NewClient(&redis.Options{ RDB = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%s", config.GetString("redis.host"), config.GetString("redis.port")), Addr: fmt.Sprintf("%s:%s", config.GetString("redis.host"), config.GetString("redis.port")),
Password: config.GetString("redis.password"), Password: config.GetString("redis.password"),
+18 -18
View File
@@ -280,12 +280,12 @@ type CardType struct {
// Card 卡密模型 // Card 卡密模型
type Card struct { type Card struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
ApplicationID uint `json:"application_id"` // 所属应用ID ApplicationID uint `gorm:"index;not null" json:"application_id"`
CardTypeID uint `json:"card_type_id"` CardTypeID uint `gorm:"index;not null" json:"card_type_id"`
CardKey string `gorm:"uniqueIndex;size:100" json:"card_key"` CardKey string `gorm:"uniqueIndex;size:100" json:"card_key"`
CreatorID uint `json:"creator_id"` // 创建人ID CreatorID uint `gorm:"index" json:"creator_id"`
AppUserID *uint `json:"app_user_id"` // 使用者ID AppUserID *uint `gorm:"index" json:"app_user_id"`
Status string `gorm:"size:20;default:unused" json:"status"` // unused, used, banned Status string `gorm:"size:20;default:unused;index" json:"status"`
UsedAt *time.Time `json:"used_at"` UsedAt *time.Time `json:"used_at"`
ExpireAt *time.Time `json:"expire_at"` ExpireAt *time.Time `json:"expire_at"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -301,7 +301,7 @@ type Card struct {
// Announcement 公告模型 // Announcement 公告模型
type Announcement struct { type Announcement struct {
ID uint `gorm:"primaryKey" json:"id"` 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"` Title string `gorm:"size:255" json:"title"`
Content string `gorm:"type:text" json:"content"` Content string `gorm:"type:text" json:"content"`
Type string `gorm:"size:20;default:info" json:"type"` // info, warning, urgent Type string `gorm:"size:20;default:info" json:"type"` // info, warning, urgent
@@ -318,8 +318,8 @@ type Announcement struct {
type Order struct { type Order struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
OrderNo string `gorm:"uniqueIndex;size:100" json:"order_no"` OrderNo string `gorm:"uniqueIndex;size:100" json:"order_no"`
UserID uint `json:"user_id"` UserID uint `gorm:"index" json:"user_id"`
ApplicationID *uint `json:"application_id"` ApplicationID *uint `gorm:"index" json:"application_id"`
PackageID *uint `json:"package_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 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"` Title string `gorm:"size:200" json:"title"`
@@ -371,7 +371,7 @@ type Log struct {
// RechargeRecord 充值记录模型 // RechargeRecord 充值记录模型
type RechargeRecord struct { type RechargeRecord struct {
ID uint `gorm:"primaryKey" json:"id"` 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"` OrderNo string `gorm:"size:50;uniqueIndex" json:"order_no"`
CardID *uint `json:"card_id"` CardID *uint `json:"card_id"`
CardCode string `gorm:"size:100" json:"card_code"` CardCode string `gorm:"size:100" json:"card_code"`
@@ -390,8 +390,8 @@ type RechargeRecord struct {
// ConsumptionRecord 消费记录模型 // ConsumptionRecord 消费记录模型
type ConsumptionRecord struct { type ConsumptionRecord struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
UserID uint `json:"user_id"` UserID uint `gorm:"index" json:"user_id"`
ApplicationID uint `json:"application_id"` ApplicationID uint `gorm:"index" json:"application_id"`
OrderNo string `gorm:"size:50;uniqueIndex" json:"order_no"` OrderNo string `gorm:"size:50;uniqueIndex" json:"order_no"`
Type string `gorm:"size:50" json:"type"` // verification, card_purchase, subscription, feature, manual_deduct Type string `gorm:"size:50" json:"type"` // verification, card_purchase, subscription, feature, manual_deduct
Content string `gorm:"type:text" json:"content"` Content string `gorm:"type:text" json:"content"`
@@ -895,11 +895,11 @@ type WebhookLog struct {
RequestData string `gorm:"type:text" json:"request_data"` RequestData string `gorm:"type:text" json:"request_data"`
ResponseCode int `json:"response_code"` ResponseCode int `json:"response_code"`
ResponseData string `gorm:"type:text" json:"response_data"` 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"` RetryCount int `json:"retry_count"`
ErrorMessage string `gorm:"type:text" json:"error_message"` ErrorMessage string `gorm:"type:text" json:"error_message"`
Duration int `json:"duration"` // 请求耗时(毫秒) Duration int `json:"duration"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `gorm:"index" json:"created_at"`
WebhookConfig WebhookConfig `gorm:"foreignKey:WebhookID" json:"webhook,omitempty"` WebhookConfig WebhookConfig `gorm:"foreignKey:WebhookID" json:"webhook,omitempty"`
} }
@@ -925,17 +925,17 @@ type ExtensionAPIKey struct {
// ApiUsage API调用统计模型 // ApiUsage API调用统计模型
type ApiUsage struct { type ApiUsage struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
UserID uint `json:"user_id"` UserID uint `gorm:"index" json:"user_id"`
ApplicationID uint `json:"application_id"` ApplicationID uint `gorm:"index" json:"application_id"`
Endpoint string `gorm:"size:255" json:"endpoint"` Endpoint string `gorm:"size:255" json:"endpoint"`
Method string `gorm:"size:10" json:"method"` Method string `gorm:"size:10" json:"method"`
IPAddress string `gorm:"size:50" json:"ip_address"` IPAddress string `gorm:"size:50" json:"ip_address"`
UserAgent string `gorm:"size:500" json:"user_agent"` UserAgent string `gorm:"size:500" json:"user_agent"`
ResponseTime int `json:"response_time"` // 响应时间(毫秒) ResponseTime int `json:"response_time"`
StatusCode int `json:"status_code"` StatusCode int `json:"status_code"`
Success bool `json:"success"` Success bool `json:"success"`
ErrorMessage string `gorm:"type:text" json:"error_message"` 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"` User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"` Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"`
+55 -11
View File
@@ -1,7 +1,8 @@
package admin package admin
import ( import (
"fmt" "fmt"
"strconv"
"verification-platform-backend/internal/database" "verification-platform-backend/internal/database"
"verification-platform-backend/internal/model" "verification-platform-backend/internal/model"
"verification-platform-backend/internal/service" "verification-platform-backend/internal/service"
@@ -47,10 +48,18 @@ type AgentTreeNode struct {
} }
func handleGetAgents(c *gin.Context) { 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 var users []model.User
if err := database.DB.Where("role = ?", "agent"). if err := database.DB.Where("role = ?", "agent").
Preload("ParentAgent"). Preload("ParentAgent").
Order("created_at DESC"). Order("created_at DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&users).Error; err != nil { Find(&users).Error; err != nil {
response.Error(c, 500, "获取代理列表失败") response.Error(c, 500, "获取代理列表失败")
return return
@@ -74,18 +83,50 @@ func handleGetAgents(c *gin.Context) {
} }
var result []AgentResponse 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 { for _, user := range users {
var parentAgentName string var parentAgentName string
if user.ParentAgent != nil { if user.ParentAgent != nil {
parentAgentName = user.ParentAgent.Username 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 var lastLoginAt string
if user.LastLoginAt != nil { if user.LastLoginAt != nil {
lastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05") lastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05")
@@ -109,14 +150,17 @@ func handleGetAgents(c *gin.Context) {
LastLoginAt: lastLoginAt, LastLoginAt: lastLoginAt,
Balance: user.Balance, Balance: user.Balance,
CanCreateAgent: user.CanCreateAgent, CanCreateAgent: user.CanCreateAgent,
CardsCount: int(cardsCount), CardsCount: cardsCountMap[user.ID],
ChildAgentsCount: int(childAgentsCount), ChildAgentsCount: childCountMap[user.ID],
}) })
} }
response.Success(c, gin.H{ response.Success(c, gin.H{
"agents": result, "agents": result,
"total": len(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" "verification-platform-backend/pkg/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
func SetupApplicationRoutes(r *gin.RouterGroup) { func SetupApplicationRoutes(r *gin.RouterGroup) {
@@ -1073,11 +1074,18 @@ func handleManualDeduct(c *gin.Context) {
return return
} }
user.Balance -= req.Amount result := database.DB.Model(&model.AppUser{}).Where("id = ? AND balance >= ?", user.ID, req.Amount).
if err := database.DB.Save(&user).Error; err != nil { Update("balance", gorm.Expr("balance - ?", req.Amount))
if result.Error != nil {
response.Error(c, 500, "扣费失败") response.Error(c, 500, "扣费失败")
return return
} }
if result.RowsAffected == 0 {
response.Error(c, 400, "余额不足")
return
}
database.DB.Where("id = ?", user.ID).First(&user)
record := model.ConsumptionRecord{ record := model.ConsumptionRecord{
UserID: user.ID, 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") 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) fmt.Printf("[DEBUG] Error fetching cards: %v\n", err)
response.Error(c, 500, "获取卡密列表失败") response.Error(c, 500, "获取卡密列表失败")
return return
@@ -451,7 +458,13 @@ func handleGetCards(c *gin.Context) {
fmt.Printf("[DEBUG] Found %d cards for user %d\n", len(cards), userID) 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) { func handleCreateCards(c *gin.Context) {
@@ -742,10 +755,8 @@ func handleBatchGenerateCards(c *gin.Context) {
} }
totalCost := float64(req.Count) * cardType.Price totalCost := float64(req.Count) * cardType.Price
fmt.Printf("[DEBUG] Total cost: %f, Balance: %f\n", totalCost, agentUser.Balance)
if agentUser.Balance < totalCost { if agentUser.Balance < totalCost {
fmt.Printf("[DEBUG] Insufficient balance\n")
response.Error(c, 400, "余额不足") response.Error(c, 400, "余额不足")
return 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() tx.Rollback()
fmt.Printf("[DEBUG] Update balance error: %v\n", err)
response.Error(c, 500, "扣款失败") response.Error(c, 500, "扣款失败")
return return
} }
if result.RowsAffected == 0 {
tx.Rollback()
response.Error(c, 400, "余额不足")
return
}
cards := make([]model.Card, 0, req.Count) cards := make([]model.Card, 0, req.Count)
for i := 0; i < req.Count; i++ { for i := 0; i < req.Count; i++ {
+41 -10
View File
@@ -1,8 +1,9 @@
package admin package admin
import ( import (
"fmt" "fmt"
"log" "log"
"strconv"
"time" "time"
"verification-platform-backend/internal/database" "verification-platform-backend/internal/database"
"verification-platform-backend/internal/model" "verification-platform-backend/internal/model"
@@ -96,28 +97,58 @@ func handleGetDevices(c *gin.Context) {
query = query.Where("device_id = ?", deviceIDFilter) 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, "获取设备列表失败") response.Error(c, 500, "获取设备列表失败")
return return
} }
devicesWithDetails := make([]DeviceWithDetails, 0, len(devices)) 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{}). database.DB.Model(&model.DeviceSession{}).
Where("device_id = ? AND last_heartbeat > ?", device.ID, timeoutThreshold). Select("device_id, COUNT(*) as count").
Count(&onlineSessionCount) 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{ devicesWithDetails = append(devicesWithDetails, DeviceWithDetails{
UserDevice: device, 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) { func handleUpdateDeviceStatus(c *gin.Context) {
+26 -7
View File
@@ -1,7 +1,8 @@
package admin package admin
import ( import (
"fmt" "fmt"
"strconv"
"verification-platform-backend/internal/database" "verification-platform-backend/internal/database"
"verification-platform-backend/internal/model" "verification-platform-backend/internal/model"
"verification-platform-backend/internal/service" "verification-platform-backend/internal/service"
@@ -131,6 +132,8 @@ func handleGetRechargeRecords(c *gin.Context) {
status := c.Query("status") status := c.Query("status")
startDate := c.Query("start_date") startDate := c.Query("start_date")
endDate := c.Query("end_date") endDate := c.Query("end_date")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if search != "" { if search != "" {
query = query.Where("order_no LIKE ? OR card_code LIKE ?", "%"+search+"%", "%"+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") 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, "获取充值记录失败") response.Error(c, 500, "获取充值记录失败")
return return
} }
response.Success(c, gin.H{ response.Success(c, gin.H{
"records": records, "records": records,
"total": len(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") status := c.Query("status")
startDate := c.Query("start_date") startDate := c.Query("start_date")
endDate := c.Query("end_date") endDate := c.Query("end_date")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if search != "" { if search != "" {
query = query.Where("order_no LIKE ? OR content LIKE ?", "%"+search+"%", "%"+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") 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, "获取消费记录失败") response.Error(c, 500, "获取消费记录失败")
return return
} }
response.Success(c, gin.H{ response.Success(c, gin.H{
"records": records, "records": records,
"total": len(records), "total": total,
"page": page,
"page_size": pageSize,
"total_page": (total + int64(pageSize) - 1) / int64(pageSize),
}) })
} }
@@ -11,6 +11,7 @@ import (
"time" "time"
"verification-platform-backend/internal/database" "verification-platform-backend/internal/database"
"verification-platform-backend/internal/model" "verification-platform-backend/internal/model"
"verification-platform-backend/internal/scheduler"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -25,6 +26,9 @@ func SetupSystemSettingsRoutes(r *gin.RouterGroup) {
settings.PUT("/payment", handleUpdatePaymentSettings) settings.PUT("/payment", handleUpdatePaymentSettings)
settings.GET("/email", handleGetEmailSettings) settings.GET("/email", handleGetEmailSettings)
settings.PUT("/email", handleUpdateEmailSettings) settings.PUT("/email", handleUpdateEmailSettings)
settings.GET("/cleanup", handleGetCleanupSettings)
settings.PUT("/cleanup", handleUpdateCleanupSettings)
settings.POST("/cleanup/run", handleRunCleanup)
} }
paymentChannels := r.Group("/payment-channels") 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 { func parseSettingInt(value string, defaultValue int) int {
if value == "" { if value == "" {
return defaultValue return defaultValue
+43 -10
View File
@@ -11,6 +11,7 @@ import (
"verification-platform-backend/pkg/utils" "verification-platform-backend/pkg/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
type UserWithStatus struct { type UserWithStatus struct {
@@ -192,6 +193,28 @@ func handleGetUsers(c *gin.Context) {
offlineCount := 0 offlineCount := 0
bannedCount := 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)) usersWithStatus := make([]UserWithStatus, 0, len(users))
for _, user := range 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) 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++ 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) log.Printf("[DEBUG] 用户 %s (ID=%d) 余额: %f", user.Username, user.ID, user.Balance)
usersWithStatus = append(usersWithStatus, UserWithStatus{ usersWithStatus = append(usersWithStatus, UserWithStatus{
AppUser: user, AppUser: user,
OnlineStatus: onlineStatus, OnlineStatus: onlineStatus,
DeviceCount: int(deviceCount), DeviceCount: deviceCountMap[user.ID],
}) })
} }
@@ -705,22 +725,35 @@ func handleUpdateExpiry(c *gin.Context) {
} }
} else { } else {
if req.Type == "recharge" { 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" { } 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, "余额不足") response.Error(c, 400, "余额不足")
return return
} }
appUser.Balance -= req.Amount
} else { } else {
response.Error(c, 400, "操作类型错误") response.Error(c, 400, "操作类型错误")
return return
} }
} }
if err := database.DB.Save(&appUser).Error; err != nil { if app.BillingType == "balance" {
response.Error(c, 500, "更新失败") database.DB.Where("id = ?", appUser.ID).First(&appUser)
return } else {
if err := database.DB.Save(&appUser).Error; err != nil {
response.Error(c, 500, "更新失败")
return
}
} }
response.Success(c, appUser) response.Success(c, appUser)
+6 -4
View File
@@ -9,6 +9,7 @@ import (
"verification-platform-backend/pkg/response" "verification-platform-backend/pkg/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
func SetupAccountRoutes(r *gin.RouterGroup) { 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) log.Printf("[DEBUG] User %d subscription valid, skip balance deduction", user.ID)
} else { } else {
if user.Balance >= app.DeductionAmount { result := database.DB.Model(&model.AppUser{}).Where("id = ? AND balance >= ?", user.ID, app.DeductionAmount).
user.Balance -= app.DeductionAmount Update("balance", gorm.Expr("balance - ?", app.DeductionAmount))
log.Printf("[DEBUG] Deducted %.2f from user %d, new balance: %.2f", app.DeductionAmount, user.ID, user.Balance) if result.Error != nil || result.RowsAffected == 0 {
} else {
log.Printf("[DEBUG] User %d has insufficient balance: %.2f < %.2f", user.ID, user.Balance, app.DeductionAmount) log.Printf("[DEBUG] User %d has insufficient balance: %.2f < %.2f", user.ID, user.Balance, app.DeductionAmount)
response.Error(c, 403, "余额不足") response.Error(c, 403, "余额不足")
return 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/dop251/goja"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
func SetupDynamicRoutes(r *gin.RouterGroup) { func SetupDynamicRoutes(r *gin.RouterGroup) {
@@ -408,9 +409,8 @@ func handleExtendTime(appID uint, actionMap map[string]interface{}) error {
return nil return nil
} }
user.Balance += float64(days) return database.DB.Model(&model.AppUser{}).Where("id = ?", user.ID).
Update("balance", gorm.Expr("balance + ?", float64(days))).Error
return database.DB.Save(&user).Error
} }
func handleDeductPoints(appID uint, actionMap map[string]interface{}) 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 return nil
} }
user.Balance -= points result := database.DB.Model(&model.AppUser{}).Where("id = ? AND balance >= ?", user.ID, points).
if user.Balance < 0 { Update("balance", gorm.Expr("balance - ?", points))
user.Balance = 0 if result.Error != nil {
return result.Error
} }
if result.RowsAffected == 0 {
return database.DB.Save(&user).Error 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 { 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" "verification-platform-backend/pkg/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
func SetupRoutes(r *gin.RouterGroup) { func SetupRoutes(r *gin.RouterGroup) {
@@ -265,7 +266,45 @@ func handleRechargeUser(c *gin.Context) {
newExpiry := baseTime.Add(duration) newExpiry := baseTime.Add(duration)
user.ExpiryAt = &newExpiry user.ExpiryAt = &newExpiry
case "balance": 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: default:
service.LogVerification(c, &app.ID, &user.ID, "extension_recharge_failed", fmt.Sprintf("扩展API充值失败: 类型无效 - %s", req.Type), "", fmt.Errorf("充值类型无效")) service.LogVerification(c, &app.ID, &user.ID, "extension_recharge_failed", fmt.Sprintf("扩展API充值失败: 类型无效 - %s", req.Type), "", fmt.Errorf("充值类型无效"))
response.Error(c, 400, "充值类型无效,仅支持days或balance类型") response.Error(c, 400, "充值类型无效,仅支持days或balance类型")
@@ -362,7 +401,53 @@ func handleDeductUser(c *gin.Context) {
response.Error(c, 400, "余额不足") response.Error(c, 400, "余额不足")
return 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: default:
service.LogVerification(c, &app.ID, &user.ID, "extension_deduct_failed", fmt.Sprintf("扩展API扣费失败: 类型无效 - %s", req.Type), "", fmt.Errorf("扣除类型无效")) service.LogVerification(c, &app.ID, &user.ID, "extension_deduct_failed", fmt.Sprintf("扩展API扣费失败: 类型无效 - %s", req.Type), "", fmt.Errorf("扣除类型无效"))
response.Error(c, 400, "扣除类型无效,仅支持days或balance类型") 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("redis:\n") content.WriteString("redis:\n")
content.WriteString(fmt.Sprintf(" enabled: %v\n", req.UseRedis))
if req.UseRedis { if req.UseRedis {
content.WriteString(fmt.Sprintf(" host: %s\n", req.RedisHost)) content.WriteString(fmt.Sprintf(" host: %s\n", req.RedisHost))
content.WriteString(fmt.Sprintf(" port: %s\n", req.RedisPort)) 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)
}
}
@@ -33,6 +33,7 @@ export interface User {
export interface Team { export interface Team {
name: string name: string
logo: NavIcon | string logo: NavIcon | string
plan?: string
} }
export interface SidebarData { export interface SidebarData {
+1 -1
View File
@@ -115,7 +115,7 @@ async function loadSystemSettings() {
} }
try { try {
const data = await api.get('/dev/system-settings') const data = await api.get<any>('/dev/system-settings')
if (data) { if (data) {
const settings = { const settings = {
site_name: data.site_name || '', site_name: data.site_name || '',
@@ -779,7 +779,7 @@ function toggleWeekday(index: number) {
<div v-if="form.verify_method === 'email'" class="space-y-2 pt-4 border-t"> <div v-if="form.verify_method === 'email'" class="space-y-2 pt-4 border-t">
<UiLabel>邮箱配置</UiLabel> <UiLabel>邮箱配置</UiLabel>
<UiSelect v-model="form.email_config_id" @update:model-value="(val: number | null) => form.email_config_id = val"> <UiSelect v-model="form.email_config_id" @update:model-value="(val: any) => form.email_config_id = (val ? Number(val) : null)">
<UiSelectTrigger> <UiSelectTrigger>
<UiSelectValue placeholder="请选择邮箱配置" /> <UiSelectValue placeholder="请选择邮箱配置" />
</UiSelectTrigger> </UiSelectTrigger>
@@ -801,7 +801,7 @@ function toggleWeekday(index: number) {
<div v-if="form.verify_method === 'sms'" class="space-y-2 pt-4 border-t"> <div v-if="form.verify_method === 'sms'" class="space-y-2 pt-4 border-t">
<UiLabel>短信配置</UiLabel> <UiLabel>短信配置</UiLabel>
<UiSelect v-model="form.sms_config_id" @update:model-value="(val: number | null) => form.sms_config_id = val"> <UiSelect v-model="form.sms_config_id" @update:model-value="(val: any) => form.sms_config_id = (val ? Number(val) : null)">
<UiSelectTrigger> <UiSelectTrigger>
<UiSelectValue placeholder="请选择短信配置" /> <UiSelectValue placeholder="请选择短信配置" />
</UiSelectTrigger> </UiSelectTrigger>
@@ -875,7 +875,7 @@ function toggleWeekday(index: number) {
<div v-if="form.password_reset_method === 'email'" class="space-y-2 pt-4 border-t"> <div v-if="form.password_reset_method === 'email'" class="space-y-2 pt-4 border-t">
<UiLabel>邮箱配置</UiLabel> <UiLabel>邮箱配置</UiLabel>
<UiSelect v-model="form.password_reset_email_id" @update:model-value="(val: number | null) => form.password_reset_email_id = val"> <UiSelect v-model="form.password_reset_email_id" @update:model-value="(val: any) => form.password_reset_email_id = (val ? Number(val) : null)">
<UiSelectTrigger> <UiSelectTrigger>
<UiSelectValue placeholder="请选择邮箱配置" /> <UiSelectValue placeholder="请选择邮箱配置" />
</UiSelectTrigger> </UiSelectTrigger>
@@ -897,7 +897,7 @@ function toggleWeekday(index: number) {
<div v-if="form.password_reset_method === 'sms'" class="space-y-2 pt-4 border-t"> <div v-if="form.password_reset_method === 'sms'" class="space-y-2 pt-4 border-t">
<UiLabel>短信配置</UiLabel> <UiLabel>短信配置</UiLabel>
<UiSelect v-model="form.password_reset_sms_id" @update:model-value="(val: number | null) => form.password_reset_sms_id = val"> <UiSelect v-model="form.password_reset_sms_id" @update:model-value="(val: any) => form.password_reset_sms_id = (val ? Number(val) : null)">
<UiSelectTrigger> <UiSelectTrigger>
<UiSelectValue placeholder="请选择短信配置" /> <UiSelectValue placeholder="请选择短信配置" />
</UiSelectTrigger> </UiSelectTrigger>
+10 -2
View File
@@ -149,8 +149,16 @@ async function fetchCardTypes() {
async function fetchCards() { async function fetchCards() {
loading.value = true loading.value = true
try { try {
const data = await api.get<Card[]>('/dev/cards') const data = await api.get<any>('/dev/cards')
cards.value = Array.isArray(data) ? data : [] if (data && typeof data === 'object' && data.cards) {
cards.value = data.cards
}
else if (Array.isArray(data)) {
cards.value = data
}
else {
cards.value = []
}
} }
catch (error) { catch (error) {
console.error('获取卡密列表失败:', error) console.error('获取卡密列表失败:', error)
@@ -39,7 +39,7 @@ const selectedEncryption = computed(() => {
async function fetchEmailConfig() { async function fetchEmailConfig() {
loading.value = true loading.value = true
try { try {
const data = await api.get(`/dev/email-configs/${route.params.id}`) const data = await api.get<any>(`/dev/email-configs/${route.params.id}`)
if (data) { if (data) {
formData.value = { formData.value = {
name: data.name, name: data.name,
+71 -370
View File
@@ -48,9 +48,9 @@ const provinces = ref<Province[]>([])
const onlineTrendData = ref<number[]>([]) const onlineTrendData = ref<number[]>([])
const recentTickets = ref<Ticket[]>([]) const recentTickets = ref<Ticket[]>([])
const mapChart = ref<HTMLElement | null>(null) const distributionChart = ref<HTMLCanvasElement | null>(null)
const activityChart = ref<HTMLCanvasElement | null>(null) const activityChart = ref<HTMLCanvasElement | null>(null)
let chartInstance: any = null let distributionChartInstance: any = null
let activityChartInstance: any = null let activityChartInstance: any = null
const overseasUsers = computed(() => { const overseasUsers = computed(() => {
@@ -231,30 +231,77 @@ function fetchCurrentUser() {
} }
} }
async function initMapChart() { async function initDistributionChart() {
if (!mapChart.value) if (!distributionChart.value)
return return
try { try {
const [echarts, worldResponse] = await Promise.all([ const { default: Chart } = await import('chart.js/auto')
import('echarts'),
fetch('/world.json'),
])
if (!worldResponse.ok) { const ctx = distributionChart.value.getContext('2d')
throw new Error('Failed to load world map data') 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) distributionChartInstance = new Chart(ctx, {
type: 'doughnut',
updateMapOption() data: {
labels,
window.addEventListener('resize', () => { datasets: [{
chartInstance?.resize() 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) { catch (error) {
@@ -262,352 +309,6 @@ async function initMapChart() {
} }
} }
const worldNameMap: Record<string, string> = {
'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 `<div style="padding: 4px;">
<div style="font-weight: 600; margin-bottom: 6px; font-size: 13px;">${params.name}</div>
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 3px;">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#22c55e;"></span>
<span>${t('admin.online')}: ${params.data.online}</span>
</div>
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 3px;">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#ef4444;"></span>
<span>${t('admin.offline')}: ${params.data.offline}</span>
</div>
<div style="display: flex; align-items: center; gap: 6px;">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#3b82f6;"></span>
<span>${t('admin.total')}: ${params.data.count}</span>
</div>
</div>`
}
return `<div style="padding: 4px;">
<div style="font-weight: 600; margin-bottom: 4px; font-size: 13px;">${params.name}</div>
<div style="color: ${dark ? '#94a3b8' : '#64748b'};">${t('admin.noUserData')}</div>
</div>`
},
},
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() { function initActivityChart() {
if (!activityChart.value) if (!activityChart.value)
return return
@@ -719,20 +420,20 @@ watch(loading, async (newVal) => {
if (!newVal) { if (!newVal) {
await nextTick() await nextTick()
setTimeout(() => { setTimeout(() => {
initMapChart() initDistributionChart()
initActivityChart() initActivityChart()
}, 100) }, 100)
} }
}) })
watch(isDark, () => { watch(isDark, () => {
updateMapOption() initDistributionChart()
}) })
onUnmounted(() => { onUnmounted(() => {
if (chartInstance) { if (distributionChartInstance) {
chartInstance.dispose() distributionChartInstance.destroy()
chartInstance = null distributionChartInstance = null
} }
if (activityChartInstance) { if (activityChartInstance) {
activityChartInstance.destroy() activityChartInstance.destroy()
@@ -852,7 +553,7 @@ onUnmounted(() => {
</UiCardHeader> </UiCardHeader>
<UiCardContent> <UiCardContent>
<div class="relative h-[350px] rounded-lg overflow-hidden bg-card"> <div class="relative h-[350px] rounded-lg overflow-hidden bg-card">
<div ref="mapChart" class="w-full h-full" /> <canvas ref="distributionChart" />
</div> </div>
</UiCardContent> </UiCardContent>
</UiCard> </UiCard>
@@ -42,7 +42,7 @@ const selectedType = computed(() => {
async function fetchPaymentChannel() { async function fetchPaymentChannel() {
loading.value = true loading.value = true
try { try {
const data = await api.get(`/dev/payment-channels/${route.params.id}`) const data = await api.get<any>(`/dev/payment-channels/${route.params.id}`)
if (data) { if (data) {
formData.value = { formData.value = {
name: data.name, name: data.name,
@@ -60,7 +60,7 @@ const configPlaceholder = computed(() => {
async function fetchSmsConfig() { async function fetchSmsConfig() {
loading.value = true loading.value = true
try { try {
const data = await api.get(`/dev/sms-configs/${route.params.id}`) const data = await api.get<any>(`/dev/sms-configs/${route.params.id}`)
if (data) { if (data) {
formData.value = { formData.value = {
name: data.name, name: data.name,
@@ -67,7 +67,7 @@ const endpointPlaceholder = computed(() => {
async function fetchStorageConfig() { async function fetchStorageConfig() {
loading.value = true loading.value = true
try { try {
const data = await api.get(`/dev/storage-configs/${route.params.id}`) const data = await api.get<any>(`/dev/storage-configs/${route.params.id}`)
if (data) { if (data) {
formData.value = { formData.value = {
name: data.name || '', name: data.name || '',
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Bell, Database, Loader2, Settings, Shield, ToggleLeft, Upload } from 'lucide-vue-next' import { Bell, Database, Loader2, Settings, Shield, ToggleLeft, Trash2, Upload } from 'lucide-vue-next'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@@ -51,10 +51,23 @@ const notificationForm = ref({
notify_on_ticket: true, notify_on_ticket: true,
}) })
const cleanupForm = ref({
enable_auto_cleanup: false,
cleanup_interval_hours: 24,
captcha_retention_days: 1,
verify_code_retention_days: 7,
api_usage_retention_days: 30,
webhook_log_retention_days: 30,
device_session_retention_days: 7,
})
const cleanupLoading = ref(false)
const manualCleanupLoading = ref(false)
async function loadSettings() { async function loadSettings() {
loading.value = true loading.value = true
try { try {
const data = await api.get('/dev/system-settings') const data = await api.get<any>('/dev/system-settings')
if (data) { if (data) {
basicForm.value = { basicForm.value = {
site_name: data.site_name || '', site_name: data.site_name || '',
@@ -102,6 +115,24 @@ async function loadSettings() {
finally { finally {
loading.value = false loading.value = false
} }
try {
const cleanupData = await api.get<any>('/dev/system-settings/cleanup')
if (cleanupData) {
cleanupForm.value = {
enable_auto_cleanup: cleanupData.enable_auto_cleanup ?? false,
cleanup_interval_hours: cleanupData.cleanup_interval_hours || 24,
captcha_retention_days: cleanupData.captcha_retention_days || 1,
verify_code_retention_days: cleanupData.verify_code_retention_days || 7,
api_usage_retention_days: cleanupData.api_usage_retention_days || 30,
webhook_log_retention_days: cleanupData.webhook_log_retention_days || 30,
device_session_retention_days: cleanupData.device_session_retention_days || 7,
}
}
}
catch (error) {
// ignore
}
} }
async function saveSettings() { async function saveSettings() {
@@ -136,6 +167,34 @@ function updateGlobalSettings() {
window.dispatchEvent(new CustomEvent('system-settings-changed', { detail: settings })) window.dispatchEvent(new CustomEvent('system-settings-changed', { detail: settings }))
} }
async function saveCleanupSettings() {
cleanupLoading.value = true
try {
await api.put('/dev/system-settings/cleanup', cleanupForm.value)
toast.success('清理设置保存成功')
}
catch (error) {
toast.error('保存清理设置失败')
}
finally {
cleanupLoading.value = false
}
}
async function runManualCleanup() {
manualCleanupLoading.value = true
try {
await api.post('/dev/system-settings/cleanup/run')
toast.success('手动清理完成')
}
catch (error) {
toast.error('手动清理失败')
}
finally {
manualCleanupLoading.value = false
}
}
function triggerLogoUpload() { function triggerLogoUpload() {
logoInputRef.value?.click() logoInputRef.value?.click()
} }
@@ -152,7 +211,7 @@ async function handleLogoUpload(event: Event) {
formData.append('file', file) formData.append('file', file)
formData.append('type', 'logo') formData.append('type', 'logo')
const data = await api.postFormData('/dev/system-settings/upload', formData) const data = await api.postFormData<any>('/dev/system-settings/upload', formData)
basicForm.value.site_logo = data.url basicForm.value.site_logo = data.url
toast.success('Logo上传成功') toast.success('Logo上传成功')
} }
@@ -181,7 +240,7 @@ async function handleFaviconUpload(event: Event) {
formData.append('file', file) formData.append('file', file)
formData.append('type', 'favicon') formData.append('type', 'favicon')
const data = await api.postFormData('/dev/system-settings/upload', formData) const data = await api.postFormData<any>('/dev/system-settings/upload', formData)
basicForm.value.site_favicon = data.url basicForm.value.site_favicon = data.url
toast.success('图标上传成功') toast.success('图标上传成功')
} }
@@ -198,6 +257,7 @@ const tabs = [
{ id: 'basic', label: '基本设置', icon: Settings }, { id: 'basic', label: '基本设置', icon: Settings },
{ id: 'security', label: '安全设置', icon: Shield }, { id: 'security', label: '安全设置', icon: Shield },
{ id: 'backup', label: '备份设置', icon: Database }, { id: 'backup', label: '备份设置', icon: Database },
{ id: 'cleanup', label: '数据清理', icon: Trash2 },
{ id: 'feature', label: '功能设置', icon: ToggleLeft }, { id: 'feature', label: '功能设置', icon: ToggleLeft },
{ id: 'notification', label: '通知设置', icon: Bell }, { id: 'notification', label: '通知设置', icon: Bell },
] ]
@@ -603,6 +663,126 @@ onMounted(() => {
</UiCardContent> </UiCardContent>
</UiCard> </UiCard>
<UiCard v-show="activeTab === 'cleanup'">
<UiCardHeader>
<UiCardTitle>数据清理</UiCardTitle>
<UiCardDescription>配置过期数据自动清理规则释放数据库空间</UiCardDescription>
</UiCardHeader>
<UiCardContent class="space-y-6">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<UiLabel>启用自动清理</UiLabel>
<p class="text-sm text-muted-foreground">
定时自动清理过期数据
</p>
</div>
<UiSwitch v-model="cleanupForm.enable_auto_cleanup" />
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<UiLabel for="cleanup_interval_hours">清理间隔小时</UiLabel>
<UiInput
id="cleanup_interval_hours"
v-model.number="cleanupForm.cleanup_interval_hours"
type="number"
min="1"
max="168"
/>
<p class="text-xs text-muted-foreground">
每隔多少小时执行一次清理
</p>
</div>
<div class="space-y-2">
<UiLabel for="captcha_retention_days">验证码保留天数</UiLabel>
<UiInput
id="captcha_retention_days"
v-model.number="cleanupForm.captcha_retention_days"
type="number"
min="1"
max="30"
/>
<p class="text-xs text-muted-foreground">
图形验证码过期后保留天数
</p>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<UiLabel for="verify_code_retention_days">邮箱/短信验证码保留天数</UiLabel>
<UiInput
id="verify_code_retention_days"
v-model.number="cleanupForm.verify_code_retention_days"
type="number"
min="1"
max="90"
/>
<p class="text-xs text-muted-foreground">
已使用的邮箱/短信验证码保留天数
</p>
</div>
<div class="space-y-2">
<UiLabel for="api_usage_retention_days">API调用日志保留天数</UiLabel>
<UiInput
id="api_usage_retention_days"
v-model.number="cleanupForm.api_usage_retention_days"
type="number"
min="7"
max="365"
/>
<p class="text-xs text-muted-foreground">
API调用统计记录保留天数
</p>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<UiLabel for="webhook_log_retention_days">Webhook日志保留天数</UiLabel>
<UiInput
id="webhook_log_retention_days"
v-model.number="cleanupForm.webhook_log_retention_days"
type="number"
min="7"
max="365"
/>
<p class="text-xs text-muted-foreground">
Webhook发送日志保留天数
</p>
</div>
<div class="space-y-2">
<UiLabel for="device_session_retention_days">设备会话保留天数</UiLabel>
<UiInput
id="device_session_retention_days"
v-model.number="cleanupForm.device_session_retention_days"
type="number"
min="1"
max="90"
/>
<p class="text-xs text-muted-foreground">
过期的设备会话记录保留天数
</p>
</div>
</div>
<div class="flex items-center gap-4 border-t pt-4">
<UiButton :disabled="cleanupLoading" @click="saveCleanupSettings">
<Loader2 v-if="cleanupLoading" class="mr-2 h-4 w-4 animate-spin" />
保存清理设置
</UiButton>
<UiButton variant="outline" :disabled="manualCleanupLoading" @click="runManualCleanup">
<Loader2 v-if="manualCleanupLoading" class="mr-2 h-4 w-4 animate-spin" />
<Trash2 v-else class="mr-2 h-4 w-4" />
立即执行清理
</UiButton>
</div>
</UiCardContent>
</UiCard>
<div class="flex justify-end"> <div class="flex justify-end">
<UiButton :disabled="saving" @click="saveSettings"> <UiButton :disabled="saving" @click="saveSettings">
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" /> <Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
+1
View File
@@ -0,0 +1 @@
declare module 'vue3-flag-icons/styles'