From 06a2425474a8c600d77b7940c94d71a9da13b6f6 Mon Sep 17 00:00:00 2001 From: EUForest Date: Sat, 14 Mar 2026 12:41:52 +0800 Subject: [PATCH] feat(subscribe): add traffic limit rules and user traffic stats - Add subscribe traffic_limit schema and migration\n- Support traffic_limit in admin create/update and list/details\n- Apply traffic_limit when building server user list speed limits\n- Add public user traffic stats API --- apis/admin/group.api | 34 +-- apis/admin/subscribe.api | 2 + apis/public/user.api | 20 ++ apis/types.api | 32 ++- .../02133_add_expired_node_group.down.sql | 12 ++ .../02133_add_expired_node_group.up.sql | 14 ++ .../02134_subscribe_traffic_limit.down.sql | 6 + .../02134_subscribe_traffic_limit.up.sql | 22 ++ .../public/user/getUserTrafficStatsHandler.go | 26 +++ internal/handler/routes.go | 3 + .../logic/admin/group/createNodeGroupLogic.go | 51 ++++- .../logic/admin/group/deleteNodeGroupLogic.go | 5 +- .../admin/group/exportGroupResultLogic.go | 3 +- .../admin/group/getNodeGroupListLogic.go | 40 ++-- .../group/getSubscribeGroupMappingLogic.go | 4 +- .../admin/group/previewUserNodesLogic.go | 33 ++- .../admin/group/recalculateGroupLogic.go | 40 ++-- .../logic/admin/group/updateNodeGroupLogic.go | 45 ++++ .../admin/subscribe/createSubscribeLogic.go | 7 + .../subscribe/getSubscribeDetailsLogic.go | 6 + .../admin/subscribe/getSubscribeListLogic.go | 6 + .../admin/subscribe/updateSubscribeLogic.go | 7 + .../queryUserSubscribeNodeListLogic.go | 97 ++++++++- .../public/user/getUserTrafficStatsLogic.go | 138 ++++++++++++ .../public/user/queryUserSubscribeLogic.go | 4 + .../logic/server/getServerUserListLogic.go | 198 +++++++++++++++++- internal/logic/subscribe/subscribeLogic.go | 63 ++++++ internal/model/group/node_group.go | 22 +- internal/model/subscribe/subscribe.go | 1 + internal/model/user/model.go | 23 +- internal/model/user/user.go | 40 ++-- internal/types/types.go | 88 ++++++-- queue/logic/order/activateOrderLogic.go | 31 +-- queue/logic/traffic/trafficStatisticsLogic.go | 4 +- 34 files changed, 974 insertions(+), 153 deletions(-) create mode 100644 initialize/migrate/database/02133_add_expired_node_group.down.sql create mode 100644 initialize/migrate/database/02133_add_expired_node_group.up.sql create mode 100644 initialize/migrate/database/02134_subscribe_traffic_limit.down.sql create mode 100644 initialize/migrate/database/02134_subscribe_traffic_limit.up.sql create mode 100644 internal/handler/public/user/getUserTrafficStatsHandler.go create mode 100644 internal/logic/public/user/getUserTrafficStatsLogic.go diff --git a/apis/admin/group.api b/apis/admin/group.api index e229fc4..de4aad9 100644 --- a/apis/admin/group.api +++ b/apis/admin/group.api @@ -28,22 +28,30 @@ type ( } // CreateNodeGroupRequest CreateNodeGroupRequest { - Name string `json:"name" validate:"required"` - Description string `json:"description"` - Sort int `json:"sort"` - ForCalculation *bool `json:"for_calculation"` - MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"` - MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"` + Name string `json:"name" validate:"required"` + Description string `json:"description"` + Sort int `json:"sort"` + ForCalculation *bool `json:"for_calculation"` + IsExpiredGroup *bool `json:"is_expired_group"` + ExpiredDaysLimit *int `json:"expired_days_limit"` + MaxTrafficGBExpired *int64 `json:"max_traffic_gb_expired,omitempty"` + SpeedLimit *int `json:"speed_limit"` + MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"` + MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"` } // UpdateNodeGroupRequest UpdateNodeGroupRequest { - Id int64 `json:"id" validate:"required"` - Name string `json:"name"` - Description string `json:"description"` - Sort int `json:"sort"` - ForCalculation *bool `json:"for_calculation"` - MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"` - MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"` + Id int64 `json:"id" validate:"required"` + Name string `json:"name"` + Description string `json:"description"` + Sort int `json:"sort"` + ForCalculation *bool `json:"for_calculation"` + IsExpiredGroup *bool `json:"is_expired_group"` + ExpiredDaysLimit *int `json:"expired_days_limit"` + MaxTrafficGBExpired *int64 `json:"max_traffic_gb_expired,omitempty"` + SpeedLimit *int `json:"speed_limit"` + MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"` + MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"` } // DeleteNodeGroupRequest DeleteNodeGroupRequest { diff --git a/apis/admin/subscribe.api b/apis/admin/subscribe.api index 881f021..8a662b8 100644 --- a/apis/admin/subscribe.api +++ b/apis/admin/subscribe.api @@ -50,6 +50,7 @@ type ( NodeTags []string `json:"node_tags"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"` NodeGroupId int64 `json:"node_group_id"` + TrafficLimit []TrafficLimit `json:"traffic_limit"` Show *bool `json:"show"` Sell *bool `json:"sell"` DeductionRatio int64 `json:"deduction_ratio"` @@ -77,6 +78,7 @@ type ( NodeTags []string `json:"node_tags"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"` NodeGroupId int64 `json:"node_group_id"` + TrafficLimit []TrafficLimit `json:"traffic_limit"` Show *bool `json:"show"` Sell *bool `json:"sell"` Sort int64 `json:"sort"` diff --git a/apis/public/user.api b/apis/public/user.api index a6eb50f..426cf93 100644 --- a/apis/public/user.api +++ b/apis/public/user.api @@ -144,6 +144,22 @@ type ( HistoryContinuousDays int64 `json:"history_continuous_days"` LongestSingleConnection int64 `json:"longest_single_connection"` } + GetUserTrafficStatsRequest { + UserSubscribeId string `form:"user_subscribe_id" validate:"required"` + Days int `form:"days" validate:"required,oneof=7 30"` + } + DailyTrafficStats { + Date string `json:"date"` + Upload int64 `json:"upload"` + Download int64 `json:"download"` + Total int64 `json:"total"` + } + GetUserTrafficStatsResponse { + List []DailyTrafficStats `json:"list"` + TotalUpload int64 `json:"total_upload"` + TotalDownload int64 `json:"total_download"` + TotalTraffic int64 `json:"total_traffic"` + } ) @server ( @@ -271,6 +287,10 @@ service ppanel { @doc "Delete Current User Account" @handler DeleteCurrentUserAccount delete /current_user_account + + @doc "Get User Traffic Statistics" + @handler GetUserTrafficStats + get /traffic_stats (GetUserTrafficStatsRequest) returns (GetUserTrafficStatsResponse) } @server ( diff --git a/apis/types.api b/apis/types.api index 4b9c7f6..827e038 100644 --- a/apis/types.api +++ b/apis/types.api @@ -211,6 +211,12 @@ type ( Quantity int64 `json:"quantity"` Discount float64 `json:"discount"` } + TrafficLimit { + StatType string `json:"stat_type"` + StatValue int64 `json:"stat_value"` + TrafficUsage int64 `json:"traffic_usage"` + SpeedLimit int64 `json:"speed_limit"` + } Subscribe { Id int64 `json:"id"` Name string `json:"name"` @@ -229,6 +235,7 @@ type ( NodeTags []string `json:"node_tags"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"` NodeGroupId int64 `json:"node_group_id"` + TrafficLimit []TrafficLimit `json:"traffic_limit"` Show bool `json:"show"` Sell bool `json:"sell"` Sort int64 `json:"sort"` @@ -486,6 +493,7 @@ type ( } UserSubscribe { Id int64 `json:"id"` + IdStr string `json:"id_str"` UserId int64 `json:"user_id"` OrderId int64 `json:"order_id"` SubscribeId int64 `json:"subscribe_id"` @@ -882,16 +890,20 @@ type ( // ===== 分组功能类型定义 ===== // NodeGroup 节点组 NodeGroup { - Id int64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Sort int `json:"sort"` - ForCalculation bool `json:"for_calculation"` - MinTrafficGB int64 `json:"min_traffic_gb,omitempty"` - MaxTrafficGB int64 `json:"max_traffic_gb,omitempty"` - NodeCount int64 `json:"node_count,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + Id int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Sort int `json:"sort"` + ForCalculation bool `json:"for_calculation"` + IsExpiredGroup bool `json:"is_expired_group"` + ExpiredDaysLimit int `json:"expired_days_limit"` + MaxTrafficGBExpired int64 `json:"max_traffic_gb_expired,omitempty"` + SpeedLimit int `json:"speed_limit"` + MinTrafficGB int64 `json:"min_traffic_gb,omitempty"` + MaxTrafficGB int64 `json:"max_traffic_gb,omitempty"` + NodeCount int64 `json:"node_count,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } // GroupHistory 分组历史记录 GroupHistory { diff --git a/initialize/migrate/database/02133_add_expired_node_group.down.sql b/initialize/migrate/database/02133_add_expired_node_group.down.sql new file mode 100644 index 0000000..aeacd0e --- /dev/null +++ b/initialize/migrate/database/02133_add_expired_node_group.down.sql @@ -0,0 +1,12 @@ +-- 回滚 user_subscribe 表的过期流量字段 +ALTER TABLE `user_subscribe` +DROP COLUMN `expired_upload`, +DROP COLUMN `expired_download`; + +-- 回滚 node_group 表的过期节点组字段 +ALTER TABLE `node_group` +DROP INDEX `idx_is_expired_group`, +DROP COLUMN `speed_limit`, +DROP COLUMN `max_traffic_gb_expired`, +DROP COLUMN `expired_days_limit`, +DROP COLUMN `is_expired_group`; diff --git a/initialize/migrate/database/02133_add_expired_node_group.up.sql b/initialize/migrate/database/02133_add_expired_node_group.up.sql new file mode 100644 index 0000000..283f9a5 --- /dev/null +++ b/initialize/migrate/database/02133_add_expired_node_group.up.sql @@ -0,0 +1,14 @@ +-- 为 node_group 表添加过期节点组相关字段 +ALTER TABLE `node_group` +ADD COLUMN `is_expired_group` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Is Expired Group: 0=normal, 1=expired group' AFTER `for_calculation`, +ADD COLUMN `expired_days_limit` int NOT NULL DEFAULT 7 COMMENT 'Expired days limit (days)' AFTER `is_expired_group`, +ADD COLUMN `max_traffic_gb_expired` bigint DEFAULT 0 COMMENT 'Max traffic for expired users (GB)' AFTER `expired_days_limit`, +ADD COLUMN `speed_limit` int NOT NULL DEFAULT 0 COMMENT 'Speed limit (KB/s)' AFTER `max_traffic_gb_expired`; + +-- 添加索引 +ALTER TABLE `node_group` ADD INDEX `idx_is_expired_group` (`is_expired_group`); + +-- 为 user_subscribe 表添加过期流量统计字段 +ALTER TABLE `user_subscribe` +ADD COLUMN `expired_download` bigint NOT NULL DEFAULT 0 COMMENT 'Expired period download traffic (bytes)' AFTER `upload`, +ADD COLUMN `expired_upload` bigint NOT NULL DEFAULT 0 COMMENT 'Expired period upload traffic (bytes)' AFTER `expired_download`; diff --git a/initialize/migrate/database/02134_subscribe_traffic_limit.down.sql b/initialize/migrate/database/02134_subscribe_traffic_limit.down.sql new file mode 100644 index 0000000..4fdf319 --- /dev/null +++ b/initialize/migrate/database/02134_subscribe_traffic_limit.down.sql @@ -0,0 +1,6 @@ +-- Purpose: Rollback traffic_limit rules from subscribe +-- Author: Claude Code +-- Date: 2026-03-12 + +-- ===== Remove traffic_limit column from subscribe table ===== +ALTER TABLE `subscribe` DROP COLUMN IF EXISTS `traffic_limit`; diff --git a/initialize/migrate/database/02134_subscribe_traffic_limit.up.sql b/initialize/migrate/database/02134_subscribe_traffic_limit.up.sql new file mode 100644 index 0000000..18a9f30 --- /dev/null +++ b/initialize/migrate/database/02134_subscribe_traffic_limit.up.sql @@ -0,0 +1,22 @@ +-- Purpose: Add traffic_limit rules to subscribe +-- Author: Claude Code +-- Date: 2026-03-12 + +-- ===== Add traffic_limit column to subscribe table ===== +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'subscribe' + AND COLUMN_NAME = 'traffic_limit' +); + +SET @sql = IF( + @column_exists = 0, + 'ALTER TABLE `subscribe` ADD COLUMN `traffic_limit` TEXT NULL COMMENT ''Traffic Limit Rules (JSON)'' AFTER `node_group_id`', + 'SELECT ''Column traffic_limit already exists in subscribe table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/internal/handler/public/user/getUserTrafficStatsHandler.go b/internal/handler/public/user/getUserTrafficStatsHandler.go new file mode 100644 index 0000000..5c3c7dd --- /dev/null +++ b/internal/handler/public/user/getUserTrafficStatsHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Get User Traffic Statistics +func GetUserTrafficStatsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetUserTrafficStatsRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewGetUserTrafficStatsLogic(c.Request.Context(), svcCtx) + resp, err := l.GetUserTrafficStats(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index dbb6c06..e3995be 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -955,6 +955,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Reset User Subscribe Token publicUserGroupRouter.PUT("/subscribe_token", publicUser.ResetUserSubscribeTokenHandler(serverCtx)) + // Get User Traffic Statistics + publicUserGroupRouter.GET("/traffic_stats", publicUser.GetUserTrafficStatsHandler(serverCtx)) + // Unbind Device publicUserGroupRouter.PUT("/unbind_device", publicUser.UnbindDeviceHandler(serverCtx)) diff --git a/internal/logic/admin/group/createNodeGroupLogic.go b/internal/logic/admin/group/createNodeGroupLogic.go index 2d361d6..9e68c10 100644 --- a/internal/logic/admin/group/createNodeGroupLogic.go +++ b/internal/logic/admin/group/createNodeGroupLogic.go @@ -2,6 +2,7 @@ package group import ( "context" + "errors" "time" "github.com/perfect-panel/server/internal/model/group" @@ -25,17 +26,51 @@ func NewCreateNodeGroupLogic(ctx context.Context, svcCtx *svc.ServiceContext) *C } func (l *CreateNodeGroupLogic) CreateNodeGroup(req *types.CreateNodeGroupRequest) error { + // 验证:系统中只能有一个过期节点组 + if req.IsExpiredGroup != nil && *req.IsExpiredGroup { + var count int64 + err := l.svcCtx.DB.Model(&group.NodeGroup{}). + Where("is_expired_group = ?", true). + Count(&count).Error + if err != nil { + logger.Errorf("failed to check expired group count: %v", err) + return err + } + if count > 0 { + return errors.New("system already has an expired node group, cannot create multiple") + } + } + // 创建节点组 nodeGroup := &group.NodeGroup{ - Name: req.Name, - Description: req.Description, - Sort: req.Sort, - ForCalculation: req.ForCalculation, - MinTrafficGB: req.MinTrafficGB, - MaxTrafficGB: req.MaxTrafficGB, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + Name: req.Name, + Description: req.Description, + Sort: req.Sort, + ForCalculation: req.ForCalculation, + IsExpiredGroup: req.IsExpiredGroup, + MaxTrafficGBExpired: req.MaxTrafficGBExpired, + MinTrafficGB: req.MinTrafficGB, + MaxTrafficGB: req.MaxTrafficGB, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } + + // 设置过期节点组的默认值 + if req.IsExpiredGroup != nil && *req.IsExpiredGroup { + // 过期节点组不参与分组计算 + falseValue := false + nodeGroup.ForCalculation = &falseValue + + if req.ExpiredDaysLimit != nil { + nodeGroup.ExpiredDaysLimit = *req.ExpiredDaysLimit + } else { + nodeGroup.ExpiredDaysLimit = 7 // 默认7天 + } + if req.SpeedLimit != nil { + nodeGroup.SpeedLimit = *req.SpeedLimit + } + } + if err := l.svcCtx.DB.Create(nodeGroup).Error; err != nil { logger.Errorf("failed to create node group: %v", err) return err diff --git a/internal/logic/admin/group/deleteNodeGroupLogic.go b/internal/logic/admin/group/deleteNodeGroupLogic.go index 947dc49..16c89d4 100644 --- a/internal/logic/admin/group/deleteNodeGroupLogic.go +++ b/internal/logic/admin/group/deleteNodeGroupLogic.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/perfect-panel/server/internal/model/group" + "github.com/perfect-panel/server/internal/model/node" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" @@ -37,9 +38,9 @@ func (l *DeleteNodeGroupLogic) DeleteNodeGroup(req *types.DeleteNodeGroupRequest return err } - // 检查是否有关联节点 + // 检查是否有关联节点(使用JSON_CONTAINS查询node_group_ids数组) var nodeCount int64 - if err := l.svcCtx.DB.Table("nodes").Where("node_group_id = ?", nodeGroup.Id).Count(&nodeCount).Error; err != nil { + if err := l.svcCtx.DB.Model(&node.Node{}).Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroup.Id)).Count(&nodeCount).Error; err != nil { logger.Errorf("failed to count nodes in group: %v", err) return err } diff --git a/internal/logic/admin/group/exportGroupResultLogic.go b/internal/logic/admin/group/exportGroupResultLogic.go index ef2183f..a84befa 100644 --- a/internal/logic/admin/group/exportGroupResultLogic.go +++ b/internal/logic/admin/group/exportGroupResultLogic.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/perfect-panel/server/internal/model/group" + "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" @@ -77,7 +78,7 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR NodeGroupId int64 `json:"node_group_id"` } var userSubscribes []UserNodeGroupInfo - if err := l.svcCtx.DB.Table("user_subscribe"). + if err := l.svcCtx.DB.Model(&user.Subscribe{}). Select("DISTINCT user_id as id, node_group_id"). Where("node_group_id > ?", 0). Find(&userSubscribes).Error; err != nil { diff --git a/internal/logic/admin/group/getNodeGroupListLogic.go b/internal/logic/admin/group/getNodeGroupListLogic.go index abd1a6a..9595393 100644 --- a/internal/logic/admin/group/getNodeGroupListLogic.go +++ b/internal/logic/admin/group/getNodeGroupListLogic.go @@ -2,8 +2,10 @@ package group import ( "context" + "fmt" "github.com/perfect-panel/server/internal/model/group" + "github.com/perfect-panel/server/internal/model/node" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" @@ -46,9 +48,9 @@ func (l *GetNodeGroupListLogic) GetNodeGroupList(req *types.GetNodeGroupListRequ // 转换为响应格式 var list []types.NodeGroup for _, ng := range nodeGroups { - // 统计该组的节点数 + // 统计该组的节点数(JSON数组查询) var nodeCount int64 - l.svcCtx.DB.Table("nodes").Where("node_group_id = ?", ng.Id).Count(&nodeCount) + l.svcCtx.DB.Model(&node.Node{}).Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", ng.Id)).Count(&nodeCount) // 处理指针类型的字段 var forCalculation bool @@ -58,25 +60,37 @@ func (l *GetNodeGroupListLogic) GetNodeGroupList(req *types.GetNodeGroupListRequ forCalculation = true // 默认值 } - var minTrafficGB, maxTrafficGB int64 + var isExpiredGroup bool + if ng.IsExpiredGroup != nil { + isExpiredGroup = *ng.IsExpiredGroup + } + + var minTrafficGB, maxTrafficGB, maxTrafficGBExpired int64 if ng.MinTrafficGB != nil { minTrafficGB = *ng.MinTrafficGB } if ng.MaxTrafficGB != nil { maxTrafficGB = *ng.MaxTrafficGB } + if ng.MaxTrafficGBExpired != nil { + maxTrafficGBExpired = *ng.MaxTrafficGBExpired + } list = append(list, types.NodeGroup{ - Id: ng.Id, - Name: ng.Name, - Description: ng.Description, - Sort: ng.Sort, - ForCalculation: forCalculation, - MinTrafficGB: minTrafficGB, - MaxTrafficGB: maxTrafficGB, - NodeCount: nodeCount, - CreatedAt: ng.CreatedAt.Unix(), - UpdatedAt: ng.UpdatedAt.Unix(), + Id: ng.Id, + Name: ng.Name, + Description: ng.Description, + Sort: ng.Sort, + ForCalculation: forCalculation, + IsExpiredGroup: isExpiredGroup, + ExpiredDaysLimit: ng.ExpiredDaysLimit, + MaxTrafficGBExpired: maxTrafficGBExpired, + SpeedLimit: ng.SpeedLimit, + MinTrafficGB: minTrafficGB, + MaxTrafficGB: maxTrafficGB, + NodeCount: nodeCount, + CreatedAt: ng.CreatedAt.Unix(), + UpdatedAt: ng.UpdatedAt.Unix(), }) } diff --git a/internal/logic/admin/group/getSubscribeGroupMappingLogic.go b/internal/logic/admin/group/getSubscribeGroupMappingLogic.go index fb3ed90..cd26305 100644 --- a/internal/logic/admin/group/getSubscribeGroupMappingLogic.go +++ b/internal/logic/admin/group/getSubscribeGroupMappingLogic.go @@ -28,14 +28,14 @@ func NewGetSubscribeGroupMappingLogic(ctx context.Context, svcCtx *svc.ServiceCo func (l *GetSubscribeGroupMappingLogic) GetSubscribeGroupMapping(req *types.GetSubscribeGroupMappingRequest) (resp *types.GetSubscribeGroupMappingResponse, err error) { // 1. 查询所有订阅套餐 var subscribes []subscribe.Subscribe - if err := l.svcCtx.DB.Table("subscribe").Find(&subscribes).Error; err != nil { + if err := l.svcCtx.DB.Model(&subscribe.Subscribe{}).Find(&subscribes).Error; err != nil { l.Errorw("[GetSubscribeGroupMapping] failed to query subscribes", logger.Field("error", err.Error())) return nil, err } // 2. 查询所有节点组 var nodeGroups []group.NodeGroup - if err := l.svcCtx.DB.Table("node_group").Find(&nodeGroups).Error; err != nil { + if err := l.svcCtx.DB.Model(&group.NodeGroup{}).Find(&nodeGroups).Error; err != nil { l.Errorw("[GetSubscribeGroupMapping] failed to query node groups", logger.Field("error", err.Error())) return nil, err } diff --git a/internal/logic/admin/group/previewUserNodesLogic.go b/internal/logic/admin/group/previewUserNodesLogic.go index 3b889df..2122da5 100644 --- a/internal/logic/admin/group/previewUserNodesLogic.go +++ b/internal/logic/admin/group/previewUserNodesLogic.go @@ -6,7 +6,10 @@ import ( "fmt" "strings" + "github.com/perfect-panel/server/internal/model/group" "github.com/perfect-panel/server/internal/model/node" + "github.com/perfect-panel/server/internal/model/subscribe" + "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" @@ -38,7 +41,7 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ NodeGroupId int64 // 用户订阅的 node_group_id(单个ID) } var userSubscribes []UserSubscribe - err = l.svcCtx.DB.Table("user_subscribe"). + err = l.svcCtx.DB.Model(&user.Subscribe{}). Select("id, user_id, subscribe_id, node_group_id"). Where("user_id = ? AND status IN ?", req.UserId, []int8{0, 1}). Find(&userSubscribes).Error @@ -74,7 +77,7 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ NodeTags string // 节点标签 } var subscribeInfos []SubscribeInfo - err = l.svcCtx.DB.Table("subscribe"). + err = l.svcCtx.DB.Model(&subscribe.Subscribe{}). Select("id, node_group_id, node_group_ids, nodes, node_tags"). Where("id IN ?", subscribeIds). Find(&subscribeInfos).Error @@ -149,15 +152,23 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ logger.Infof("[PreviewUserNodes] collected direct node_ids: %v", allDirectNodeIds) // 4. 判断分组功能是否启用 - var groupEnabled string - l.svcCtx.DB.Table("system"). + type SystemConfig struct { + Value string + } + var config SystemConfig + l.svcCtx.DB.Model(&struct { + Category string `gorm:"column:category"` + Key string `gorm:"column:key"` + Value string `gorm:"column:value"` + }{}). + Table("system"). Where("`category` = ? AND `key` = ?", "group", "enabled"). Select("value"). - Scan(&groupEnabled) + Scan(&config) - logger.Infof("[PreviewUserNodes] groupEnabled: %v", groupEnabled) + logger.Infof("[PreviewUserNodes] groupEnabled: %v", config.Value) - isGroupEnabled := groupEnabled == "true" || groupEnabled == "1" + isGroupEnabled := config.Value == "true" || config.Value == "1" var filteredNodes []node.Node @@ -177,7 +188,7 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ // 5. 查询所有启用的节点(只有当有节点组时才查询) if len(allNodeGroupIds) > 0 { var dbNodes []node.Node - err = l.svcCtx.DB.Table("nodes"). + err = l.svcCtx.DB.Model(&node.Node{}). Where("enabled = ?", true). Find(&dbNodes).Error if err != nil { @@ -238,7 +249,7 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ // 8. 查询所有启用的节点(只有当有 tags 时才查询) if len(allTags) > 0 { var dbNodes []node.Node - err = l.svcCtx.DB.Table("nodes"). + err = l.svcCtx.DB.Model(&node.Node{}). Where("enabled = ?", true). Find(&dbNodes).Error if err != nil { @@ -370,7 +381,7 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ Name string } var nodeGroupInfos []NodeGroupInfo - err = l.svcCtx.DB.Table("node_group"). + err = l.svcCtx.DB.Model(&group.NodeGroup{}). Select("id, name"). Where("id IN ?", allGroupIds). Find(&nodeGroupInfos).Error @@ -508,7 +519,7 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ if len(allDirectNodeIds) > 0 { // 查询直接分配的节点详情 var directNodes []node.Node - err = l.svcCtx.DB.Table("nodes"). + err = l.svcCtx.DB.Model(&node.Node{}). Where("id IN ? AND enabled = ?", allDirectNodeIds, true). Find(&directNodes).Error if err != nil { diff --git a/internal/logic/admin/group/recalculateGroupLogic.go b/internal/logic/admin/group/recalculateGroupLogic.go index d43557c..cb16188 100644 --- a/internal/logic/admin/group/recalculateGroupLogic.go +++ b/internal/logic/admin/group/recalculateGroupLogic.go @@ -3,9 +3,13 @@ package group import ( "context" "encoding/json" + "fmt" "time" "github.com/perfect-panel/server/internal/model/group" + "github.com/perfect-panel/server/internal/model/node" + "github.com/perfect-panel/server/internal/model/subscribe" + "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" @@ -131,7 +135,7 @@ func (l *RecalculateGroupLogic) getUserEmail(tx *gorm.DB, userId int64) string { } var authMethod UserAuthMethod - if err := tx.Table("user_auth_methods"). + if err := tx.Model(&user.AuthMethods{}). Select("auth_identifier"). Where("user_id = ? AND (auth_type = ? OR auth_type = ?)", userId, "email", "6"). First(&authMethod).Error; err != nil { @@ -152,7 +156,7 @@ func (l *RecalculateGroupLogic) executeAverageGrouping(tx *gorm.DB, historyId in } var userSubscribes []UserSubscribeInfo - if err := tx.Table("user_subscribe"). + if err := tx.Model(&user.Subscribe{}). Select("id, user_id, subscribe_id"). Where("group_locked = ? AND status IN (0, 1)", 0). // 只查询未锁定且有效的用户订阅 Scan(&userSubscribes).Error; err != nil { @@ -168,7 +172,7 @@ func (l *RecalculateGroupLogic) executeAverageGrouping(tx *gorm.DB, historyId in // 1.5 查询所有参与计算的节点组ID var calculationNodeGroups []group.NodeGroup - if err := tx.Table("node_group"). + if err := tx.Model(&group.NodeGroup{}). Select("id"). Where("for_calculation = ?", true). Scan(&calculationNodeGroups).Error; err != nil { @@ -195,7 +199,7 @@ func (l *RecalculateGroupLogic) executeAverageGrouping(tx *gorm.DB, historyId in NodeGroupIds string `json:"node_group_ids"` // JSON string } var subscribeInfos []SubscribeInfo - if err := tx.Table("subscribe"). + if err := tx.Model(&subscribe.Subscribe{}). Select("id, node_group_ids"). Where("id IN ?", subscribeIds). Find(&subscribeInfos).Error; err != nil { @@ -261,10 +265,10 @@ func (l *RecalculateGroupLogic) executeAverageGrouping(tx *gorm.DB, historyId in } } - // 如果没有节点组ID,跳过 + // 如果没有节点组ID,跳过 if len(nodeGroupIds) == 0 { l.Debugf("no valid node_group_ids for subscribe_id=%d, setting to 0", subInfo.Id) - if err := tx.Table("user_subscribe"). + if err := tx.Model(&user.Subscribe{}). Where("id = ?", us.Id). Update("node_group_id", 0).Error; err != nil { l.Errorw("failed to update user_subscribe node_group_id", @@ -290,7 +294,7 @@ func (l *RecalculateGroupLogic) executeAverageGrouping(tx *gorm.DB, historyId in } // 更新 user_subscribe 的 node_group_id 字段(单个ID) - if err := tx.Table("user_subscribe"). + if err := tx.Model(&user.Subscribe{}). Where("id = ?", us.Id). Update("node_group_id", selectedNodeGroupId).Error; err != nil { l.Errorw("failed to update user_subscribe node_group_id", @@ -329,8 +333,8 @@ func (l *RecalculateGroupLogic) executeAverageGrouping(tx *gorm.DB, historyId in // 统计该节点组的节点数 var nodeCount int64 = 0 if nodeGroupId > 0 { - if err := tx.Table("nodes"). - Where("JSON_CONTAINS(node_group_ids, ?)", nodeGroupId). + if err := tx.Model(&node.Node{}). + Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroupId)). Count(&nodeCount).Error; err != nil { l.Errorw("failed to count nodes", logger.Field("node_group_id", nodeGroupId), @@ -383,7 +387,7 @@ func (l *RecalculateGroupLogic) executeSubscribeGrouping(tx *gorm.DB, historyId } var userSubscribes []UserSubscribeInfo - if err := tx.Table("user_subscribe"). + if err := tx.Model(&user.Subscribe{}). Select("id, user_id, subscribe_id"). Where("group_locked = ? AND status IN (0, 1)", 0). Scan(&userSubscribes).Error; err != nil { @@ -400,7 +404,7 @@ func (l *RecalculateGroupLogic) executeSubscribeGrouping(tx *gorm.DB, historyId // 1.5 查询所有参与计算的节点组ID var calculationNodeGroups []group.NodeGroup - if err := tx.Table("node_group"). + if err := tx.Model(&group.NodeGroup{}). Select("id"). Where("for_calculation = ?", true). Scan(&calculationNodeGroups).Error; err != nil { @@ -427,7 +431,7 @@ func (l *RecalculateGroupLogic) executeSubscribeGrouping(tx *gorm.DB, historyId NodeGroupIds string `json:"node_group_ids"` // JSON string } var subscribeInfos []SubscribeInfo - if err := tx.Table("subscribe"). + if err := tx.Model(&subscribe.Subscribe{}). Select("id, node_group_ids"). Where("id IN ?", subscribeIds). Find(&subscribeInfos).Error; err != nil { @@ -501,7 +505,7 @@ func (l *RecalculateGroupLogic) executeSubscribeGrouping(tx *gorm.DB, historyId us.Id, us.SubscribeId, selectedNodeGroupId, len(nodeGroupIds)) // 更新 user_subscribe 的 node_group_id 字段 - if err := tx.Table("user_subscribe"). + if err := tx.Model(&user.Subscribe{}). Where("id = ?", us.Id). Update("node_group_id", selectedNodeGroupId).Error; err != nil { l.Errorw("failed to update user_subscribe node_group_id", @@ -548,7 +552,7 @@ func (l *RecalculateGroupLogic) executeSubscribeGrouping(tx *gorm.DB, historyId expiredAffectedCount := 0 for _, eu := range expiredUserSubscribes { // 更新 user_subscribe 表的 node_group_id 字段到 0 - if err := tx.Table("user_subscribe"). + if err := tx.Model(&user.Subscribe{}). Where("id = ?", eu.Id). Update("node_group_id", 0).Error; err != nil { l.Errorw("failed to update expired user subscribe node_group_id", @@ -573,8 +577,8 @@ func (l *RecalculateGroupLogic) executeSubscribeGrouping(tx *gorm.DB, historyId // 统计该节点组的节点数 var nodeCount int64 = 0 if nodeGroupId > 0 { - if err := tx.Table("nodes"). - Where("JSON_CONTAINS(node_group_ids, ?)", nodeGroupId). + if err := tx.Model(&node.Node{}). + Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroupId)). Count(&nodeCount).Error; err != nil { l.Errorw("failed to count nodes", logger.Field("node_group_id", nodeGroupId), @@ -652,7 +656,7 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in } var userSubscribes []UserSubscribeInfo - if err := tx.Table("user_subscribe"). + if err := tx.Model(&user.Subscribe{}). Select("id, user_id, upload, download, (upload + download) as used_traffic"). Where("group_locked = ? AND status IN (0, 1)", 0). // 只查询有效且未锁定的用户订阅 Scan(&userSubscribes).Error; err != nil { @@ -694,7 +698,7 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in // 如果没有匹配到任何范围,targetNodeGroupId 保持为 0(不分配节点组) // 更新 user_subscribe 的 node_group_id 字段 - if err := tx.Table("user_subscribe"). + if err := tx.Model(&user.Subscribe{}). Where("id = ?", us.Id). Update("node_group_id", targetNodeGroupId).Error; err != nil { l.Errorw("failed to update user subscribe node_group_id", diff --git a/internal/logic/admin/group/updateNodeGroupLogic.go b/internal/logic/admin/group/updateNodeGroupLogic.go index eb299d5..b7d6fa4 100644 --- a/internal/logic/admin/group/updateNodeGroupLogic.go +++ b/internal/logic/admin/group/updateNodeGroupLogic.go @@ -6,6 +6,7 @@ import ( "time" "github.com/perfect-panel/server/internal/model/group" + "github.com/perfect-panel/server/internal/model/subscribe" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" @@ -37,6 +38,34 @@ func (l *UpdateNodeGroupLogic) UpdateNodeGroup(req *types.UpdateNodeGroupRequest return err } + // 验证:系统中只能有一个过期节点组 + if req.IsExpiredGroup != nil && *req.IsExpiredGroup { + var count int64 + err := l.svcCtx.DB.Model(&group.NodeGroup{}). + Where("is_expired_group = ? AND id != ?", true, req.Id). + Count(&count).Error + if err != nil { + logger.Errorf("failed to check expired group count: %v", err) + return err + } + if count > 0 { + return errors.New("system already has an expired node group, cannot create multiple") + } + + // 验证:被订阅商品设置为默认节点组的不能设置为过期节点组 + var subscribeCount int64 + err = l.svcCtx.DB.Model(&subscribe.Subscribe{}). + Where("node_group_id = ?", req.Id). + Count(&subscribeCount).Error + if err != nil { + logger.Errorf("failed to check subscribe usage: %v", err) + return err + } + if subscribeCount > 0 { + return errors.New("this node group is used as default node group in subscription products, cannot set as expired group") + } + } + // 构建更新数据 updates := map[string]interface{}{ "updated_at": time.Now(), @@ -53,6 +82,22 @@ func (l *UpdateNodeGroupLogic) UpdateNodeGroup(req *types.UpdateNodeGroupRequest if req.ForCalculation != nil { updates["for_calculation"] = *req.ForCalculation } + if req.IsExpiredGroup != nil { + updates["is_expired_group"] = *req.IsExpiredGroup + // 过期节点组不参与分组计算 + if *req.IsExpiredGroup { + updates["for_calculation"] = false + } + } + if req.ExpiredDaysLimit != nil { + updates["expired_days_limit"] = *req.ExpiredDaysLimit + } + if req.MaxTrafficGBExpired != nil { + updates["max_traffic_gb_expired"] = *req.MaxTrafficGBExpired + } + if req.SpeedLimit != nil { + updates["speed_limit"] = *req.SpeedLimit + } // 获取新的流量区间值 newMinTraffic := nodeGroup.MinTrafficGB diff --git a/internal/logic/admin/subscribe/createSubscribeLogic.go b/internal/logic/admin/subscribe/createSubscribeLogic.go index 3aeb751..999b3a3 100644 --- a/internal/logic/admin/subscribe/createSubscribeLogic.go +++ b/internal/logic/admin/subscribe/createSubscribeLogic.go @@ -34,6 +34,12 @@ func (l *CreateSubscribeLogic) CreateSubscribe(req *types.CreateSubscribeRequest val, _ := json.Marshal(req.Discount) discount = string(val) } + + trafficLimit := "" + if len(req.TrafficLimit) > 0 { + val, _ := json.Marshal(req.TrafficLimit) + trafficLimit = string(val) + } sub := &subscribe.Subscribe{ Id: 0, Name: req.Name, @@ -52,6 +58,7 @@ func (l *CreateSubscribeLogic) CreateSubscribe(req *types.CreateSubscribeRequest NodeTags: tool.StringSliceToString(req.NodeTags), NodeGroupIds: subscribe.JSONInt64Slice(req.NodeGroupIds), NodeGroupId: req.NodeGroupId, + TrafficLimit: trafficLimit, Show: req.Show, Sell: req.Sell, Sort: 0, diff --git a/internal/logic/admin/subscribe/getSubscribeDetailsLogic.go b/internal/logic/admin/subscribe/getSubscribeDetailsLogic.go index 6defdf1..fc29938 100644 --- a/internal/logic/admin/subscribe/getSubscribeDetailsLogic.go +++ b/internal/logic/admin/subscribe/getSubscribeDetailsLogic.go @@ -42,6 +42,12 @@ func (l *GetSubscribeDetailsLogic) GetSubscribeDetails(req *types.GetSubscribeDe l.Logger.Error("[GetSubscribeDetailsLogic] JSON unmarshal failed: ", logger.Field("error", err.Error()), logger.Field("discount", sub.Discount)) } } + if sub.TrafficLimit != "" { + err = json.Unmarshal([]byte(sub.TrafficLimit), &resp.TrafficLimit) + if err != nil { + l.Logger.Error("[GetSubscribeDetailsLogic] JSON unmarshal failed: ", logger.Field("error", err.Error()), logger.Field("traffic_limit", sub.TrafficLimit)) + } + } resp.Nodes = tool.StringToInt64Slice(sub.Nodes) resp.NodeTags = strings.Split(sub.NodeTags, ",") return resp, nil diff --git a/internal/logic/admin/subscribe/getSubscribeListLogic.go b/internal/logic/admin/subscribe/getSubscribeListLogic.go index 130d682..6cf6ba6 100644 --- a/internal/logic/admin/subscribe/getSubscribeListLogic.go +++ b/internal/logic/admin/subscribe/getSubscribeListLogic.go @@ -62,6 +62,12 @@ func (l *GetSubscribeListLogic) GetSubscribeList(req *types.GetSubscribeListRequ l.Logger.Error("[GetSubscribeListLogic] JSON unmarshal failed: ", logger.Field("error", err.Error()), logger.Field("discount", item.Discount)) } } + if item.TrafficLimit != "" { + err = json.Unmarshal([]byte(item.TrafficLimit), &sub.TrafficLimit) + if err != nil { + l.Logger.Error("[GetSubscribeListLogic] JSON unmarshal failed: ", logger.Field("error", err.Error()), logger.Field("traffic_limit", item.TrafficLimit)) + } + } sub.Nodes = tool.StringToInt64Slice(item.Nodes) sub.NodeTags = strings.Split(item.NodeTags, ",") // Handle NodeGroupIds - convert from JSONInt64Slice to []int64 diff --git a/internal/logic/admin/subscribe/updateSubscribeLogic.go b/internal/logic/admin/subscribe/updateSubscribeLogic.go index a60a6a0..4aef109 100644 --- a/internal/logic/admin/subscribe/updateSubscribeLogic.go +++ b/internal/logic/admin/subscribe/updateSubscribeLogic.go @@ -42,6 +42,12 @@ func (l *UpdateSubscribeLogic) UpdateSubscribe(req *types.UpdateSubscribeRequest val, _ := json.Marshal(req.Discount) discount = string(val) } + + trafficLimit := "" + if len(req.TrafficLimit) > 0 { + val, _ := json.Marshal(req.TrafficLimit) + trafficLimit = string(val) + } sub := &subscribe.Subscribe{ Id: req.Id, Name: req.Name, @@ -60,6 +66,7 @@ func (l *UpdateSubscribeLogic) UpdateSubscribe(req *types.UpdateSubscribeRequest NodeTags: tool.StringSliceToString(req.NodeTags), NodeGroupIds: subscribe.JSONInt64Slice(req.NodeGroupIds), NodeGroupId: req.NodeGroupId, + TrafficLimit: trafficLimit, Show: req.Show, Sell: req.Sell, Sort: req.Sort, diff --git a/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go b/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go index b98f90d..493c43d 100644 --- a/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go +++ b/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/perfect-panel/server/internal/model/group" "github.com/perfect-panel/server/internal/model/node" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" @@ -88,7 +89,7 @@ func (l *QueryUserSubscribeNodeListLogic) QueryUserSubscribeNodeList() (resp *ty func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (userSubscribeNodes []*types.UserSubscribeNodeInfo, err error) { userSubscribeNodes = make([]*types.UserSubscribeNodeInfo, 0) if l.isSubscriptionExpired(userSub) { - return l.createExpiredServers(), nil + return l.createExpiredServers(userSub), nil } // Check if group management is enabled @@ -312,8 +313,98 @@ func (l *QueryUserSubscribeNodeListLogic) isSubscriptionExpired(userSub *user.Su return userSub.ExpireTime.Unix() < time.Now().Unix() && userSub.ExpireTime.Unix() != 0 } -func (l *QueryUserSubscribeNodeListLogic) createExpiredServers() []*types.UserSubscribeNodeInfo { - return nil +func (l *QueryUserSubscribeNodeListLogic) createExpiredServers(userSub *user.Subscribe) []*types.UserSubscribeNodeInfo { + // 1. 查询过期节点组 + var expiredGroup group.NodeGroup + err := l.svcCtx.DB.Where("is_expired_group = ?", true).First(&expiredGroup).Error + if err != nil { + l.Debugw("no expired node group configured", logger.Field("error", err)) + return nil + } + + // 2. 检查用户是否在过期天数限制内 + expiredDays := int(time.Since(userSub.ExpireTime).Hours() / 24) + if expiredDays > expiredGroup.ExpiredDaysLimit { + l.Debugf("user subscription expired %d days, exceeds limit %d days", expiredDays, expiredGroup.ExpiredDaysLimit) + return nil + } + + // 3. 检查用户已使用流量是否超过限制(仅使用过期期间的流量) + if expiredGroup.MaxTrafficGBExpired != nil && *expiredGroup.MaxTrafficGBExpired > 0 { + usedTrafficGB := (userSub.ExpiredDownload + userSub.ExpiredUpload) / (1024 * 1024 * 1024) + if usedTrafficGB >= *expiredGroup.MaxTrafficGBExpired { + l.Debugf("user expired traffic %d GB, exceeds expired group limit %d GB", usedTrafficGB, *expiredGroup.MaxTrafficGBExpired) + return nil + } + } + + // 4. 查询过期节点组的节点 + enable := true + _, nodes, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{ + Page: 0, + Size: 1000, + NodeGroupIds: []int64{expiredGroup.Id}, + Enabled: &enable, + }) + if err != nil { + l.Errorw("failed to query expired group nodes", logger.Field("error", err)) + return nil + } + + if len(nodes) == 0 { + l.Debug("no nodes found in expired group") + return nil + } + + // 5. 查询服务器信息 + var serverMapIds = make(map[int64]*node.Server) + for _, n := range nodes { + serverMapIds[n.ServerId] = nil + } + var serverIds []int64 + for k := range serverMapIds { + serverIds = append(serverIds, k) + } + + servers, err := l.svcCtx.NodeModel.QueryServerList(l.ctx, serverIds) + if err != nil { + l.Errorw("failed to query servers", logger.Field("error", err)) + return nil + } + + for _, s := range servers { + serverMapIds[s.Id] = s + } + + // 6. 构建节点列表 + userSubscribeNodes := make([]*types.UserSubscribeNodeInfo, 0, len(nodes)) + for _, n := range nodes { + server := serverMapIds[n.ServerId] + if server == nil { + continue + } + userSubscribeNode := &types.UserSubscribeNodeInfo{ + Id: n.Id, + Name: n.Name, + Uuid: userSub.UUID, + Protocol: n.Protocol, + Protocols: server.Protocols, + Port: n.Port, + Address: n.Address, + Tags: strings.Split(n.Tags, ","), + Country: server.Country, + City: server.City, + Latitude: server.Latitude, + Longitude: server.Longitude, + LongitudeCenter: server.LongitudeCenter, + LatitudeCenter: server.LatitudeCenter, + CreatedAt: n.CreatedAt.Unix(), + } + userSubscribeNodes = append(userSubscribeNodes, userSubscribeNode) + } + + l.Infof("returned %d nodes from expired group for user %d (expired %d days)", len(userSubscribeNodes), userSub.UserId, expiredDays) + return userSubscribeNodes } func (l *QueryUserSubscribeNodeListLogic) getFirstHostLine() string { diff --git a/internal/logic/public/user/getUserTrafficStatsLogic.go b/internal/logic/public/user/getUserTrafficStatsLogic.go new file mode 100644 index 0000000..e46cb4b --- /dev/null +++ b/internal/logic/public/user/getUserTrafficStatsLogic.go @@ -0,0 +1,138 @@ +package user + +import ( + "context" + "strconv" + "time" + + "gorm.io/gorm" + + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetUserTrafficStatsLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Get User Traffic Statistics +func NewGetUserTrafficStatsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUserTrafficStatsLogic { + return &GetUserTrafficStatsLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetUserTrafficStatsLogic) GetUserTrafficStats(req *types.GetUserTrafficStatsRequest) (resp *types.GetUserTrafficStatsResponse, err error) { + // 获取当前用户 + u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) + if !ok { + logger.Error("current user is not found in context") + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") + } + + // 将字符串 ID 转换为 int64 + userSubscribeId, err := strconv.ParseInt(req.UserSubscribeId, 10, 64) + if err != nil { + l.Errorw("[GetUserTrafficStats] Invalid User Subscribe ID:", + logger.Field("user_subscribe_id", req.UserSubscribeId), + logger.Field("err", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid subscription ID") + } + + // 验证订阅归属权 - 直接查询 user_subscribe 表 + var userSubscribe struct { + Id int64 + UserId int64 + } + err = l.svcCtx.DB.WithContext(l.ctx). + Table("user_subscribe"). + Select("id, user_id"). + Where("id = ?", userSubscribeId). + First(&userSubscribe).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + l.Errorw("[GetUserTrafficStats] User Subscribe Not Found:", + logger.Field("user_subscribe_id", userSubscribeId), + logger.Field("user_id", u.Id)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Subscription not found") + } + l.Errorw("[GetUserTrafficStats] Query User Subscribe Error:", logger.Field("err", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Query User Subscribe Error") + } + + if userSubscribe.UserId != u.Id { + l.Errorw("[GetUserTrafficStats] User Subscribe Access Denied:", + logger.Field("user_subscribe_id", userSubscribeId), + logger.Field("subscribe_user_id", userSubscribe.UserId), + logger.Field("current_user_id", u.Id)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") + } + + // 计算时间范围 + now := time.Now() + startDate := now.AddDate(0, 0, -req.Days+1) + startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, time.Local) + + // 初始化响应 + resp = &types.GetUserTrafficStatsResponse{ + List: make([]types.DailyTrafficStats, 0, req.Days), + TotalUpload: 0, + TotalDownload: 0, + TotalTraffic: 0, + } + + // 按天查询流量数据 + for i := 0; i < req.Days; i++ { + currentDate := startDate.AddDate(0, 0, i) + dayStart := time.Date(currentDate.Year(), currentDate.Month(), currentDate.Day(), 0, 0, 0, 0, time.Local) + dayEnd := dayStart.Add(24 * time.Hour).Add(-time.Nanosecond) + + // 查询当天流量 + var dailyTraffic struct { + Upload int64 + Download int64 + } + + // 直接使用 model 的查询方法 + err := l.svcCtx.DB.WithContext(l.ctx). + Table("traffic_log"). + Select("COALESCE(SUM(upload), 0) as upload, COALESCE(SUM(download), 0) as download"). + Where("user_id = ? AND subscribe_id = ? AND timestamp BETWEEN ? AND ?", + u.Id, userSubscribeId, dayStart, dayEnd). + Scan(&dailyTraffic).Error + + if err != nil { + l.Errorw("[GetUserTrafficStats] Query Daily Traffic Error:", + logger.Field("date", currentDate.Format("2006-01-02")), + logger.Field("err", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Query Traffic Error") + } + + // 添加到结果列表 + total := dailyTraffic.Upload + dailyTraffic.Download + resp.List = append(resp.List, types.DailyTrafficStats{ + Date: currentDate.Format("2006-01-02"), + Upload: dailyTraffic.Upload, + Download: dailyTraffic.Download, + Total: total, + }) + + // 累加总计 + resp.TotalUpload += dailyTraffic.Upload + resp.TotalDownload += dailyTraffic.Download + } + + resp.TotalTraffic = resp.TotalUpload + resp.TotalDownload + + return resp, nil +} diff --git a/internal/logic/public/user/queryUserSubscribeLogic.go b/internal/logic/public/user/queryUserSubscribeLogic.go index 55e3770..218f851 100644 --- a/internal/logic/public/user/queryUserSubscribeLogic.go +++ b/internal/logic/public/user/queryUserSubscribeLogic.go @@ -3,6 +3,7 @@ package user import ( "context" "encoding/json" + "strconv" "time" "github.com/perfect-panel/server/pkg/constant" @@ -52,6 +53,9 @@ func (l *QueryUserSubscribeLogic) QueryUserSubscribe() (resp *types.QueryUserSub var sub types.UserSubscribe tool.DeepCopy(&sub, item) + // 填充 IdStr 字段,避免前端精度丢失 + sub.IdStr = strconv.FormatInt(item.Id, 10) + // 解析Discount字段 避免在续订时只能续订一个月 if item.Subscribe != nil && item.Subscribe.Discount != "" { var discounts []types.SubscribeDiscount diff --git a/internal/logic/server/getServerUserListLogic.go b/internal/logic/server/getServerUserListLogic.go index 6d51326..8a871d0 100644 --- a/internal/logic/server/getServerUserListLogic.go +++ b/internal/logic/server/getServerUserListLogic.go @@ -4,10 +4,13 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/model/group" "github.com/perfect-panel/server/internal/model/node" "github.com/perfect-panel/server/internal/model/subscribe" + "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" @@ -133,7 +136,7 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR return nil, err } } - + if len(subs) == 0 { return &types.GetServerUserListResponse{ Users: []types.ServerUser{ @@ -151,14 +154,33 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR return nil, err } for _, datum := range data { + if !l.shouldIncludeServerUser(datum, nodeGroupIds) { + continue + } + + // 计算该用户的实际限速值(考虑按量限速规则) + effectiveSpeedLimit := l.calculateEffectiveSpeedLimit(sub, datum) + users = append(users, types.ServerUser{ Id: datum.Id, UUID: datum.UUID, - SpeedLimit: sub.SpeedLimit, + SpeedLimit: effectiveSpeedLimit, DeviceLimit: sub.DeviceLimit, }) } } + + // 处理过期订阅用户:如果当前节点属于过期节点组,添加符合条件的过期用户 + if len(nodeGroupIds) > 0 { + expiredUsers, expiredSpeedLimit := l.getExpiredUsers(nodeGroupIds) + for i := range expiredUsers { + if expiredSpeedLimit > 0 { + expiredUsers[i].SpeedLimit = expiredSpeedLimit + } + } + users = append(users, expiredUsers...) + } + if len(users) == 0 { users = append(users, types.ServerUser{ Id: 1, @@ -181,3 +203,175 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR } return resp, nil } + +func (l *GetServerUserListLogic) shouldIncludeServerUser(userSub *user.Subscribe, serverNodeGroupIds []int64) bool { + if userSub == nil { + return false + } + + if userSub.ExpireTime.Unix() == 0 || userSub.ExpireTime.After(time.Now()) { + return true + } + + return l.canUseExpiredNodeGroup(userSub, serverNodeGroupIds) +} + +func (l *GetServerUserListLogic) getExpiredUsers(serverNodeGroupIds []int64) ([]types.ServerUser, int64) { + var expiredGroup group.NodeGroup + if err := l.svcCtx.DB.Where("is_expired_group = ?", true).First(&expiredGroup).Error; err != nil { + return nil, 0 + } + + if !tool.Contains(serverNodeGroupIds, expiredGroup.Id) { + return nil, 0 + } + + var expiredSubs []*user.Subscribe + if err := l.svcCtx.DB.Where("status = ?", 3).Find(&expiredSubs).Error; err != nil { + l.Errorw("query expired subscriptions failed", logger.Field("error", err.Error())) + return nil, 0 + } + + users := make([]types.ServerUser, 0) + seen := make(map[int64]bool) + for _, userSub := range expiredSubs { + if !l.checkExpiredUserEligibility(userSub, &expiredGroup) { + continue + } + if seen[userSub.Id] { + continue + } + seen[userSub.Id] = true + users = append(users, types.ServerUser{ + Id: userSub.Id, + UUID: userSub.UUID, + }) + } + + return users, int64(expiredGroup.SpeedLimit) +} + +func (l *GetServerUserListLogic) checkExpiredUserEligibility(userSub *user.Subscribe, expiredGroup *group.NodeGroup) bool { + expiredDays := int(time.Since(userSub.ExpireTime).Hours() / 24) + if expiredDays > expiredGroup.ExpiredDaysLimit { + return false + } + + if expiredGroup.MaxTrafficGBExpired != nil && *expiredGroup.MaxTrafficGBExpired > 0 { + usedTrafficGB := (userSub.ExpiredDownload + userSub.ExpiredUpload) / (1024 * 1024 * 1024) + if usedTrafficGB >= *expiredGroup.MaxTrafficGBExpired { + return false + } + } + + return true +} + +func (l *GetServerUserListLogic) canUseExpiredNodeGroup(userSub *user.Subscribe, serverNodeGroupIds []int64) bool { + var expiredGroup group.NodeGroup + if err := l.svcCtx.DB.Where("is_expired_group = ?", true).First(&expiredGroup).Error; err != nil { + return false + } + + if !tool.Contains(serverNodeGroupIds, expiredGroup.Id) { + return false + } + + expiredDays := int(time.Since(userSub.ExpireTime).Hours() / 24) + if expiredDays > expiredGroup.ExpiredDaysLimit { + return false + } + + if expiredGroup.MaxTrafficGBExpired != nil && *expiredGroup.MaxTrafficGBExpired > 0 { + usedTrafficGB := (userSub.ExpiredDownload + userSub.ExpiredUpload) / (1024 * 1024 * 1024) + if usedTrafficGB >= *expiredGroup.MaxTrafficGBExpired { + return false + } + } + + return true +} + +// calculateEffectiveSpeedLimit 计算用户的实际限速值(考虑按量限速规则) +func (l *GetServerUserListLogic) calculateEffectiveSpeedLimit(sub *subscribe.Subscribe, userSub *user.Subscribe) int64 { + baseSpeedLimit := sub.SpeedLimit + + // 解析 traffic_limit 规则 + if sub.TrafficLimit == "" { + return baseSpeedLimit + } + + var trafficLimitRules []types.TrafficLimit + if err := json.Unmarshal([]byte(sub.TrafficLimit), &trafficLimitRules); err != nil { + l.Errorw("[calculateEffectiveSpeedLimit] Failed to unmarshal traffic_limit", + logger.Field("error", err.Error()), + logger.Field("traffic_limit", sub.TrafficLimit)) + return baseSpeedLimit + } + + if len(trafficLimitRules) == 0 { + return baseSpeedLimit + } + + // 查询用户指定时段的流量使用情况 + now := time.Now() + for _, rule := range trafficLimitRules { + var startTime, endTime time.Time + + if rule.StatType == "hour" { + // 按小时统计:根据 StatValue 计算时间范围(往前推 N 小时) + if rule.StatValue <= 0 { + continue + } + // 从当前时间往前推 StatValue 小时 + startTime = now.Add(-time.Duration(rule.StatValue) * time.Hour) + endTime = now + } else if rule.StatType == "day" { + // 按天统计:根据 StatValue 计算时间范围(往前推 N 天) + if rule.StatValue <= 0 { + continue + } + // 从当前时间往前推 StatValue 天 + startTime = now.AddDate(0, 0, -int(rule.StatValue)) + endTime = now + } else { + continue + } + + // 查询该时段的流量使用 + var usedTraffic struct { + Upload int64 + Download int64 + } + err := l.svcCtx.DB.WithContext(l.ctx.Request.Context()). + Table("traffic_log"). + Select("COALESCE(SUM(upload), 0) as upload, COALESCE(SUM(download), 0) as download"). + Where("user_id = ? AND subscribe_id = ? AND timestamp >= ? AND timestamp < ?", + userSub.UserId, userSub.Id, startTime, endTime). + Scan(&usedTraffic).Error + + if err != nil { + l.Errorw("[calculateEffectiveSpeedLimit] Failed to query traffic usage", + logger.Field("error", err.Error()), + logger.Field("user_id", userSub.UserId), + logger.Field("subscribe_id", userSub.Id)) + continue + } + + // 计算已使用流量(GB) + usedGB := float64(usedTraffic.Upload+usedTraffic.Download) / (1024 * 1024 * 1024) + + // 如果已使用流量达到或超过阈值,应用限速 + if usedGB >= float64(rule.TrafficUsage) { + // 如果规则限速大于0,应用该限速 + if rule.SpeedLimit > 0 { + // 如果基础限速为0(无限速)或规则限速更严格,使用规则限速 + if baseSpeedLimit == 0 || rule.SpeedLimit < baseSpeedLimit { + return rule.SpeedLimit + } + } + } + } + + return baseSpeedLimit +} diff --git a/internal/logic/subscribe/subscribeLogic.go b/internal/logic/subscribe/subscribeLogic.go index 823f377..7b88160 100644 --- a/internal/logic/subscribe/subscribeLogic.go +++ b/internal/logic/subscribe/subscribeLogic.go @@ -8,6 +8,7 @@ import ( "github.com/perfect-panel/server/adapter" "github.com/perfect-panel/server/internal/model/client" + "github.com/perfect-panel/server/internal/model/group" "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/node" "github.com/perfect-panel/server/internal/report" @@ -206,6 +207,19 @@ func (l *SubscribeLogic) logSubscribeActivity(subscribeStatus bool, userSub *use func (l *SubscribeLogic) getServers(userSub *user.Subscribe) ([]*node.Node, error) { if l.isSubscriptionExpired(userSub) { + // 尝试获取过期节点组的节点 + expiredNodes, err := l.getExpiredGroupNodes(userSub) + if err != nil { + l.Errorw("[Generate Subscribe]get expired group nodes error", logger.Field("error", err.Error())) + return l.createExpiredServers(), nil + } + // 如果有符合条件的过期节点组节点,返回它们 + if len(expiredNodes) > 0 { + l.Debugf("[Generate Subscribe]user %d can use expired node group, nodes count: %d", userSub.UserId, len(expiredNodes)) + return expiredNodes, nil + } + // 否则返回假的过期节点 + l.Debugf("[Generate Subscribe]user %d cannot use expired node group, return fake expired nodes", userSub.UserId) return l.createExpiredServers(), nil } @@ -422,3 +436,52 @@ func (l *SubscribeLogic) isGroupEnabled() bool { } return value == "true" || value == "1" } + +// getExpiredGroupNodes 获取过期节点组的节点 +func (l *SubscribeLogic) getExpiredGroupNodes(userSub *user.Subscribe) ([]*node.Node, error) { + // 1. 查询过期节点组 + var expiredGroup group.NodeGroup + err := l.svc.DB.Where("is_expired_group = ?", true).First(&expiredGroup).Error + if err != nil { + l.Debugw("[SubscribeLogic]no expired node group configured", logger.Field("error", err.Error())) + return nil, err + } + + // 2. 检查用户是否在过期天数限制内 + expiredDays := int(time.Since(userSub.ExpireTime).Hours() / 24) + if expiredDays > expiredGroup.ExpiredDaysLimit { + l.Debugf("[SubscribeLogic]user %d subscription expired %d days, exceeds limit %d days", userSub.UserId, expiredDays, expiredGroup.ExpiredDaysLimit) + return nil, nil + } + + // 3. 检查用户已使用流量是否超过限制(仅使用过期期间的流量) + if expiredGroup.MaxTrafficGBExpired != nil && *expiredGroup.MaxTrafficGBExpired > 0 { + usedTrafficGB := (userSub.ExpiredDownload + userSub.ExpiredUpload) / (1024 * 1024 * 1024) + if usedTrafficGB >= *expiredGroup.MaxTrafficGBExpired { + l.Debugf("[SubscribeLogic]user %d expired traffic %d GB, exceeds expired group limit %d GB", userSub.UserId, usedTrafficGB, *expiredGroup.MaxTrafficGBExpired) + return nil, nil + } + } + + // 4. 查询过期节点组的节点 + enable := true + _, nodes, err := l.svc.NodeModel.FilterNodeList(l.ctx.Request.Context(), &node.FilterNodeParams{ + Page: 0, + Size: 1000, + NodeGroupIds: []int64{expiredGroup.Id}, + Enabled: &enable, + Preload: true, + }) + if err != nil { + l.Errorw("[SubscribeLogic]failed to query expired group nodes", logger.Field("error", err.Error())) + return nil, err + } + + if len(nodes) == 0 { + l.Debug("[SubscribeLogic]no nodes found in expired group") + return nil, nil + } + + l.Infof("[SubscribeLogic]returned %d nodes from expired group for user %d (expired %d days)", len(nodes), userSub.UserId, expiredDays) + return nodes, nil +} diff --git a/internal/model/group/node_group.go b/internal/model/group/node_group.go index 644580a..a2fe3ee 100644 --- a/internal/model/group/node_group.go +++ b/internal/model/group/node_group.go @@ -8,15 +8,19 @@ import ( // NodeGroup 节点组模型 type NodeGroup struct { - Id int64 `gorm:"primaryKey"` - Name string `gorm:"type:varchar(255);not null;comment:Name"` - Description string `gorm:"type:varchar(500);comment:Description"` - Sort int `gorm:"default:0;index:idx_sort;comment:Sort Order"` - ForCalculation *bool `gorm:"default:true;not null;comment:For Calculation: whether this node group participates in grouping calculation"` - MinTrafficGB *int64 `gorm:"default:0;comment:Minimum Traffic (GB) for this node group"` - MaxTrafficGB *int64 `gorm:"default:0;comment:Maximum Traffic (GB) for this node group"` - CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` - UpdatedAt time.Time `gorm:"comment:Update Time"` + Id int64 `gorm:"primaryKey"` + Name string `gorm:"type:varchar(255);not null;comment:Name"` + Description string `gorm:"type:varchar(500);comment:Description"` + Sort int `gorm:"default:0;index:idx_sort;comment:Sort Order"` + ForCalculation *bool `gorm:"default:true;not null;comment:For Calculation: whether this node group participates in grouping calculation"` + IsExpiredGroup *bool `gorm:"default:false;not null;index:idx_is_expired_group;comment:Is Expired Group"` + ExpiredDaysLimit int `gorm:"default:7;not null;comment:Expired days limit (days)"` + MaxTrafficGBExpired *int64 `gorm:"default:0;comment:Max traffic for expired users (GB)"` + SpeedLimit int `gorm:"default:0;not null;comment:Speed limit (KB/s)"` + MinTrafficGB *int64 `gorm:"default:0;comment:Minimum Traffic (GB) for this node group"` + MaxTrafficGB *int64 `gorm:"default:0;comment:Maximum Traffic (GB) for this node group"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` } // TableName 指定表名 diff --git a/internal/model/subscribe/subscribe.go b/internal/model/subscribe/subscribe.go index af58598..d689d16 100644 --- a/internal/model/subscribe/subscribe.go +++ b/internal/model/subscribe/subscribe.go @@ -71,6 +71,7 @@ type Subscribe struct { NodeTags string `gorm:"type:varchar(255);comment:Node Tags"` NodeGroupIds JSONInt64Slice `gorm:"type:json;comment:Node Group IDs (JSON array, multiple groups)"` NodeGroupId int64 `gorm:"default:0;index:idx_node_group_id;comment:Default Node Group ID (single ID)"` + TrafficLimit string `gorm:"type:text;comment:Traffic Limit Rules"` Show *bool `gorm:"type:tinyint(1);not null;default:0;comment:Show portal page"` Sell *bool `gorm:"type:tinyint(1);not null;default:0;comment:Sell"` Sort int64 `gorm:"type:int;not null;default:0;comment:Sort"` diff --git a/internal/model/user/model.go b/internal/model/user/model.go index 0cf3502..7de4c0c 100644 --- a/internal/model/user/model.go +++ b/internal/model/user/model.go @@ -82,7 +82,7 @@ type customUserLogicModel interface { FindOneSubscribeDetailsById(ctx context.Context, id int64) (*SubscribeDetails, error) FindOneUserSubscribe(ctx context.Context, id int64) (*SubscribeDetails, error) FindUsersSubscribeBySubscribeId(ctx context.Context, subscribeId int64) ([]*Subscribe, error) - UpdateUserSubscribeWithTraffic(ctx context.Context, id, download, upload int64, tx ...*gorm.DB) error + UpdateUserSubscribeWithTraffic(ctx context.Context, id, download, upload int64, isExpired bool, tx ...*gorm.DB) error QueryResisterUserTotalByDate(ctx context.Context, date time.Time) (int64, error) QueryResisterUserTotalByMonthly(ctx context.Context, date time.Time) (int64, error) QueryResisterUserTotal(ctx context.Context) (int64, error) @@ -181,7 +181,7 @@ func (m *customUserModel) BatchDeleteUser(ctx context.Context, ids []int64, tx . }, m.batchGetCacheKeys(users...)...) } -func (m *customUserModel) UpdateUserSubscribeWithTraffic(ctx context.Context, id, download, upload int64, tx ...*gorm.DB) error { +func (m *customUserModel) UpdateUserSubscribeWithTraffic(ctx context.Context, id, download, upload int64, isExpired bool, tx ...*gorm.DB) error { sub, err := m.FindOneSubscribe(ctx, id) if err != nil { return err @@ -198,10 +198,21 @@ func (m *customUserModel) UpdateUserSubscribeWithTraffic(ctx context.Context, id if len(tx) > 0 { conn = tx[0] } - return conn.Model(&Subscribe{}).Where("id = ?", id).Updates(map[string]interface{}{ - "download": gorm.Expr("download + ?", download), - "upload": gorm.Expr("upload + ?", upload), - }).Error + + // 根据订阅状态更新对应的流量字段 + if isExpired { + // 过期期间,更新过期流量字段 + return conn.Model(&Subscribe{}).Where("id = ?", id).Updates(map[string]interface{}{ + "expired_download": gorm.Expr("expired_download + ?", download), + "expired_upload": gorm.Expr("expired_upload + ?", upload), + }).Error + } else { + // 正常期间,更新正常流量字段 + return conn.Model(&Subscribe{}).Where("id = ?", id).Updates(map[string]interface{}{ + "download": gorm.Expr("download + ?", download), + "upload": gorm.Expr("upload + ?", upload), + }).Error + } }) } diff --git a/internal/model/user/user.go b/internal/model/user/user.go index 3976468..cbc659d 100644 --- a/internal/model/user/user.go +++ b/internal/model/user/user.go @@ -85,25 +85,27 @@ func (*User) TableName() string { } type Subscribe struct { - Id int64 `gorm:"primaryKey"` - UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"` - User User `gorm:"foreignKey:UserId;references:Id"` - OrderId int64 `gorm:"index:idx_order_id;not null;comment:Order ID"` - SubscribeId int64 `gorm:"index:idx_subscribe_id;not null;comment:Subscription ID"` - NodeGroupId int64 `gorm:"index:idx_node_group_id;not null;default:0;comment:Node Group ID (single ID)"` - GroupLocked *bool `gorm:"type:tinyint(1);not null;default:0;comment:Group Locked"` - StartTime time.Time `gorm:"default:CURRENT_TIMESTAMP(3);not null;comment:Subscription Start Time"` - ExpireTime time.Time `gorm:"default:NULL;comment:Subscription Expire Time"` - FinishedAt *time.Time `gorm:"default:NULL;comment:Finished Time"` - Traffic int64 `gorm:"default:0;comment:Traffic"` - Download int64 `gorm:"default:0;comment:Download Traffic"` - Upload int64 `gorm:"default:0;comment:Upload Traffic"` - Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"` - UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"` - Status uint8 `gorm:"type:tinyint(1);default:0;comment:Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired 4: Deducted 5: stopped"` - Note string `gorm:"type:varchar(500);default:'';comment:User note for subscription"` - CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` - UpdatedAt time.Time `gorm:"comment:Update Time"` + Id int64 `gorm:"primaryKey"` + UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"` + User User `gorm:"foreignKey:UserId;references:Id"` + OrderId int64 `gorm:"index:idx_order_id;not null;comment:Order ID"` + SubscribeId int64 `gorm:"index:idx_subscribe_id;not null;comment:Subscription ID"` + NodeGroupId int64 `gorm:"index:idx_node_group_id;not null;default:0;comment:Node Group ID (single ID)"` + GroupLocked *bool `gorm:"type:tinyint(1);not null;default:0;comment:Group Locked"` + StartTime time.Time `gorm:"default:CURRENT_TIMESTAMP(3);not null;comment:Subscription Start Time"` + ExpireTime time.Time `gorm:"default:NULL;comment:Subscription Expire Time"` + FinishedAt *time.Time `gorm:"default:NULL;comment:Finished Time"` + Traffic int64 `gorm:"default:0;comment:Traffic"` + Download int64 `gorm:"default:0;comment:Download Traffic"` + Upload int64 `gorm:"default:0;comment:Upload Traffic"` + ExpiredDownload int64 `gorm:"default:0;comment:Expired period download traffic (bytes)"` + ExpiredUpload int64 `gorm:"default:0;comment:Expired period upload traffic (bytes)"` + Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"` + UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"` + Status uint8 `gorm:"type:tinyint(1);default:0;comment:Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired 4: Deducted 5: stopped"` + Note string `gorm:"type:varchar(500);default:'';comment:User note for subscription"` + CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` } func (*Subscribe) TableName() string { diff --git a/internal/types/types.go b/internal/types/types.go index f1ee1fc..88fa181 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -321,12 +321,16 @@ type CreateDocumentRequest struct { } type CreateNodeGroupRequest struct { - Name string `json:"name" validate:"required"` - Description string `json:"description"` - Sort int `json:"sort"` - ForCalculation *bool `json:"for_calculation"` - MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"` - MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"` + Name string `json:"name" validate:"required"` + Description string `json:"description"` + Sort int `json:"sort"` + ForCalculation *bool `json:"for_calculation"` + IsExpiredGroup *bool `json:"is_expired_group"` + ExpiredDaysLimit *int `json:"expired_days_limit"` + MaxTrafficGBExpired *int64 `json:"max_traffic_gb_expired,omitempty"` + SpeedLimit *int `json:"speed_limit"` + MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"` + MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"` } type CreateNodeRequest struct { @@ -432,6 +436,7 @@ type CreateSubscribeRequest struct { NodeTags []string `json:"node_tags"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"` NodeGroupId int64 `json:"node_group_id"` + TrafficLimit []TrafficLimit `json:"traffic_limit"` Show *bool `json:"show"` Sell *bool `json:"sell"` DeductionRatio int64 `json:"deduction_ratio"` @@ -502,6 +507,13 @@ type CurrencyConfig struct { CurrencySymbol string `json:"currency_symbol"` } +type DailyTrafficStats struct { + Date string `json:"date"` + Upload int64 `json:"upload"` + Download int64 `json:"download"` + Total int64 `json:"total"` +} + type DeleteAdsRequest struct { Id int64 `json:"id"` } @@ -1277,6 +1289,18 @@ type GetUserTicketListResponse struct { List []Ticket `json:"list"` } +type GetUserTrafficStatsRequest struct { + UserSubscribeId string `form:"user_subscribe_id" validate:"required"` + Days int `form:"days" validate:"required,oneof=7 30"` +} + +type GetUserTrafficStatsResponse struct { + List []DailyTrafficStats `json:"list"` + TotalUpload int64 `json:"total_upload"` + TotalDownload int64 `json:"total_download"` + TotalTraffic int64 `json:"total_traffic"` +} + type GiftLog struct { Type uint16 `json:"type"` UserId int64 `json:"user_id"` @@ -1425,16 +1449,20 @@ type NodeDNS struct { } type NodeGroup struct { - Id int64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Sort int `json:"sort"` - ForCalculation bool `json:"for_calculation"` - MinTrafficGB int64 `json:"min_traffic_gb,omitempty"` - MaxTrafficGB int64 `json:"max_traffic_gb,omitempty"` - NodeCount int64 `json:"node_count,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + Id int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Sort int `json:"sort"` + ForCalculation bool `json:"for_calculation"` + IsExpiredGroup bool `json:"is_expired_group"` + ExpiredDaysLimit int `json:"expired_days_limit"` + MaxTrafficGBExpired int64 `json:"max_traffic_gb_expired,omitempty"` + SpeedLimit int `json:"speed_limit"` + MinTrafficGB int64 `json:"min_traffic_gb,omitempty"` + MaxTrafficGB int64 `json:"max_traffic_gb,omitempty"` + NodeCount int64 `json:"node_count,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } type NodeGroupItem struct { @@ -2299,6 +2327,7 @@ type Subscribe struct { NodeTags []string `json:"node_tags"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"` NodeGroupId int64 `json:"node_group_id"` + TrafficLimit []TrafficLimit `json:"traffic_limit"` Show bool `json:"show"` Sell bool `json:"sell"` Sort int64 `json:"sort"` @@ -2492,6 +2521,13 @@ type TosConfig struct { TosContent string `json:"tos_content"` } +type TrafficLimit struct { + StatType string `json:"stat_type"` + StatValue int64 `json:"stat_value"` + TrafficUsage int64 `json:"traffic_usage"` + SpeedLimit int64 `json:"speed_limit"` +} + type TrafficLog struct { Id int64 `json:"id"` ServerId int64 `json:"server_id"` @@ -2629,13 +2665,17 @@ type UpdateGroupConfigRequest struct { } type UpdateNodeGroupRequest struct { - Id int64 `json:"id" validate:"required"` - Name string `json:"name"` - Description string `json:"description"` - Sort int `json:"sort"` - ForCalculation *bool `json:"for_calculation"` - MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"` - MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"` + Id int64 `json:"id" validate:"required"` + Name string `json:"name"` + Description string `json:"description"` + Sort int `json:"sort"` + ForCalculation *bool `json:"for_calculation"` + IsExpiredGroup *bool `json:"is_expired_group"` + ExpiredDaysLimit *int `json:"expired_days_limit"` + MaxTrafficGBExpired *int64 `json:"max_traffic_gb_expired,omitempty"` + SpeedLimit *int `json:"speed_limit"` + MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"` + MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"` } type UpdateNodeRequest struct { @@ -2727,6 +2767,7 @@ type UpdateSubscribeRequest struct { NodeTags []string `json:"node_tags"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"` NodeGroupId int64 `json:"node_group_id"` + TrafficLimit []TrafficLimit `json:"traffic_limit"` Show *bool `json:"show"` Sell *bool `json:"sell"` Sort int64 `json:"sort"` @@ -2907,6 +2948,7 @@ type UserStatisticsResponse struct { type UserSubscribe struct { Id int64 `json:"id"` + IdStr string `json:"id_str"` UserId int64 `json:"user_id"` OrderId int64 `json:"order_id"` SubscribeId int64 `json:"subscribe_id"` diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index 24a03e2..8cae8fa 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -351,18 +351,20 @@ func (l *ActivateOrderLogic) getSubscribeInfo(ctx context.Context, subscribeId i func (l *ActivateOrderLogic) createUserSubscription(ctx context.Context, orderInfo *order.Order, sub *subscribe.Subscribe) (*user.Subscribe, error) { now := time.Now() userSub := &user.Subscribe{ - UserId: orderInfo.UserId, - OrderId: orderInfo.Id, - SubscribeId: orderInfo.SubscribeId, - StartTime: now, - ExpireTime: tool.AddTime(sub.UnitTime, orderInfo.Quantity, now), - Traffic: sub.Traffic, - Download: 0, - Upload: 0, - Token: uuidx.SubscribeToken(orderInfo.OrderNo), - UUID: uuid.New().String(), - Status: 1, - NodeGroupId: sub.NodeGroupId, // Inherit node_group_id from subscription plan + UserId: orderInfo.UserId, + OrderId: orderInfo.Id, + SubscribeId: orderInfo.SubscribeId, + StartTime: now, + ExpireTime: tool.AddTime(sub.UnitTime, orderInfo.Quantity, now), + Traffic: sub.Traffic, + Download: 0, + Upload: 0, + ExpiredDownload: 0, + ExpiredUpload: 0, + Token: uuidx.SubscribeToken(orderInfo.OrderNo), + UUID: uuid.New().String(), + Status: 1, + NodeGroupId: sub.NodeGroupId, // Inherit node_group_id from subscription plan } // Check quota limit before creating subscription (final safeguard) @@ -650,6 +652,9 @@ func (l *ActivateOrderLogic) updateSubscriptionForRenewal(ctx context.Context, u userSub.ExpireTime = tool.AddTime(sub.UnitTime, orderInfo.Quantity, userSub.ExpireTime) userSub.Status = 1 + // 续费时重置过期流量字段 + userSub.ExpiredDownload = 0 + userSub.ExpiredUpload = 0 if err := l.svc.UserModel.UpdateSubscribe(ctx, userSub); err != nil { logger.WithContext(ctx).Error("Update user subscribe failed", logger.Field("error", err.Error())) @@ -674,6 +679,8 @@ func (l *ActivateOrderLogic) ResetTraffic(ctx context.Context, orderInfo *order. // Reset traffic userSub.Download = 0 userSub.Upload = 0 + userSub.ExpiredDownload = 0 + userSub.ExpiredUpload = 0 userSub.Status = 1 if err := l.svc.UserModel.UpdateSubscribe(ctx, userSub); err != nil { diff --git a/queue/logic/traffic/trafficStatisticsLogic.go b/queue/logic/traffic/trafficStatisticsLogic.go index 37614cb..a89df57 100644 --- a/queue/logic/traffic/trafficStatisticsLogic.go +++ b/queue/logic/traffic/trafficStatisticsLogic.go @@ -98,11 +98,13 @@ func (l *TrafficStatisticsLogic) ProcessTask(ctx context.Context, task *asynq.Ta // update user subscribe with log d := int64(float32(log.Download) * ratio * realTimeMultiplier) u := int64(float32(log.Upload) * ratio * realTimeMultiplier) - if err := l.svc.UserModel.UpdateUserSubscribeWithTraffic(ctx, sub.Id, d, u); err != nil { + isExpired := now.After(sub.ExpireTime) + if err := l.svc.UserModel.UpdateUserSubscribeWithTraffic(ctx, sub.Id, d, u, isExpired); err != nil { logger.WithContext(ctx).Error("[TrafficStatistics] Update user subscribe with log failed", logger.Field("sid", log.SID), logger.Field("download", float32(log.Download)*ratio), logger.Field("upload", float32(log.Upload)*ratio), + logger.Field("is_expired", isExpired), logger.Field("error", err.Error()), ) continue