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:
@@ -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"
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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++ {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,23 +725,36 @@ 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 app.BillingType == "balance" {
|
||||||
|
database.DB.Where("id = ?", appUser.ID).First(&appUser)
|
||||||
|
} else {
|
||||||
if err := database.DB.Save(&appUser).Error; err != nil {
|
if err := database.DB.Save(&appUser).Error; err != nil {
|
||||||
response.Error(c, 500, "更新失败")
|
response.Error(c, 500, "更新失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response.Success(c, appUser)
|
response.Success(c, appUser)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
declare module 'vue3-flag-icons/styles'
|
||||||
Reference in New Issue
Block a user