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
This commit is contained in:
EUForest 2026-03-14 12:41:52 +08:00
parent 17163486f6
commit 06a2425474
34 changed files with 974 additions and 153 deletions

View File

@ -28,22 +28,30 @@ type (
} }
// CreateNodeGroupRequest // CreateNodeGroupRequest
CreateNodeGroupRequest { CreateNodeGroupRequest {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Description string `json:"description"` Description string `json:"description"`
Sort int `json:"sort"` Sort int `json:"sort"`
ForCalculation *bool `json:"for_calculation"` ForCalculation *bool `json:"for_calculation"`
MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"` IsExpiredGroup *bool `json:"is_expired_group"`
MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"` 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
UpdateNodeGroupRequest { UpdateNodeGroupRequest {
Id int64 `json:"id" validate:"required"` Id int64 `json:"id" validate:"required"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Sort int `json:"sort"` Sort int `json:"sort"`
ForCalculation *bool `json:"for_calculation"` ForCalculation *bool `json:"for_calculation"`
MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"` IsExpiredGroup *bool `json:"is_expired_group"`
MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"` 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
DeleteNodeGroupRequest { DeleteNodeGroupRequest {

View File

@ -50,6 +50,7 @@ type (
NodeTags []string `json:"node_tags"` NodeTags []string `json:"node_tags"`
NodeGroupIds []int64 `json:"node_group_ids,omitempty"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
NodeGroupId int64 `json:"node_group_id"` NodeGroupId int64 `json:"node_group_id"`
TrafficLimit []TrafficLimit `json:"traffic_limit"`
Show *bool `json:"show"` Show *bool `json:"show"`
Sell *bool `json:"sell"` Sell *bool `json:"sell"`
DeductionRatio int64 `json:"deduction_ratio"` DeductionRatio int64 `json:"deduction_ratio"`
@ -77,6 +78,7 @@ type (
NodeTags []string `json:"node_tags"` NodeTags []string `json:"node_tags"`
NodeGroupIds []int64 `json:"node_group_ids,omitempty"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
NodeGroupId int64 `json:"node_group_id"` NodeGroupId int64 `json:"node_group_id"`
TrafficLimit []TrafficLimit `json:"traffic_limit"`
Show *bool `json:"show"` Show *bool `json:"show"`
Sell *bool `json:"sell"` Sell *bool `json:"sell"`
Sort int64 `json:"sort"` Sort int64 `json:"sort"`

View File

@ -144,6 +144,22 @@ type (
HistoryContinuousDays int64 `json:"history_continuous_days"` HistoryContinuousDays int64 `json:"history_continuous_days"`
LongestSingleConnection int64 `json:"longest_single_connection"` 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 ( @server (
@ -271,6 +287,10 @@ service ppanel {
@doc "Delete Current User Account" @doc "Delete Current User Account"
@handler DeleteCurrentUserAccount @handler DeleteCurrentUserAccount
delete /current_user_account delete /current_user_account
@doc "Get User Traffic Statistics"
@handler GetUserTrafficStats
get /traffic_stats (GetUserTrafficStatsRequest) returns (GetUserTrafficStatsResponse)
} }
@server ( @server (

View File

@ -211,6 +211,12 @@ type (
Quantity int64 `json:"quantity"` Quantity int64 `json:"quantity"`
Discount float64 `json:"discount"` 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 { Subscribe {
Id int64 `json:"id"` Id int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -229,6 +235,7 @@ type (
NodeTags []string `json:"node_tags"` NodeTags []string `json:"node_tags"`
NodeGroupIds []int64 `json:"node_group_ids,omitempty"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
NodeGroupId int64 `json:"node_group_id"` NodeGroupId int64 `json:"node_group_id"`
TrafficLimit []TrafficLimit `json:"traffic_limit"`
Show bool `json:"show"` Show bool `json:"show"`
Sell bool `json:"sell"` Sell bool `json:"sell"`
Sort int64 `json:"sort"` Sort int64 `json:"sort"`
@ -486,6 +493,7 @@ type (
} }
UserSubscribe { UserSubscribe {
Id int64 `json:"id"` Id int64 `json:"id"`
IdStr string `json:"id_str"`
UserId int64 `json:"user_id"` UserId int64 `json:"user_id"`
OrderId int64 `json:"order_id"` OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"` SubscribeId int64 `json:"subscribe_id"`
@ -882,16 +890,20 @@ type (
// ===== 分组功能类型定义 ===== // ===== 分组功能类型定义 =====
// NodeGroup 节点组 // NodeGroup 节点组
NodeGroup { NodeGroup {
Id int64 `json:"id"` Id int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Sort int `json:"sort"` Sort int `json:"sort"`
ForCalculation bool `json:"for_calculation"` ForCalculation bool `json:"for_calculation"`
MinTrafficGB int64 `json:"min_traffic_gb,omitempty"` IsExpiredGroup bool `json:"is_expired_group"`
MaxTrafficGB int64 `json:"max_traffic_gb,omitempty"` ExpiredDaysLimit int `json:"expired_days_limit"`
NodeCount int64 `json:"node_count,omitempty"` MaxTrafficGBExpired int64 `json:"max_traffic_gb_expired,omitempty"`
CreatedAt int64 `json:"created_at"` SpeedLimit int `json:"speed_limit"`
UpdatedAt int64 `json:"updated_at"` 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 分组历史记录
GroupHistory { GroupHistory {

View File

@ -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`;

View File

@ -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`;

View File

@ -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`;

View File

@ -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;

View File

@ -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)
}
}

View File

@ -955,6 +955,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Reset User Subscribe Token // Reset User Subscribe Token
publicUserGroupRouter.PUT("/subscribe_token", publicUser.ResetUserSubscribeTokenHandler(serverCtx)) publicUserGroupRouter.PUT("/subscribe_token", publicUser.ResetUserSubscribeTokenHandler(serverCtx))
// Get User Traffic Statistics
publicUserGroupRouter.GET("/traffic_stats", publicUser.GetUserTrafficStatsHandler(serverCtx))
// Unbind Device // Unbind Device
publicUserGroupRouter.PUT("/unbind_device", publicUser.UnbindDeviceHandler(serverCtx)) publicUserGroupRouter.PUT("/unbind_device", publicUser.UnbindDeviceHandler(serverCtx))

View File

@ -2,6 +2,7 @@ package group
import ( import (
"context" "context"
"errors"
"time" "time"
"github.com/perfect-panel/server/internal/model/group" "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 { 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{ nodeGroup := &group.NodeGroup{
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
Sort: req.Sort, Sort: req.Sort,
ForCalculation: req.ForCalculation, ForCalculation: req.ForCalculation,
MinTrafficGB: req.MinTrafficGB, IsExpiredGroup: req.IsExpiredGroup,
MaxTrafficGB: req.MaxTrafficGB, MaxTrafficGBExpired: req.MaxTrafficGBExpired,
CreatedAt: time.Now(), MinTrafficGB: req.MinTrafficGB,
UpdatedAt: time.Now(), 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 { if err := l.svcCtx.DB.Create(nodeGroup).Error; err != nil {
logger.Errorf("failed to create node group: %v", err) logger.Errorf("failed to create node group: %v", err)
return err return err

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"github.com/perfect-panel/server/internal/model/group" "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/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
@ -37,9 +38,9 @@ func (l *DeleteNodeGroupLogic) DeleteNodeGroup(req *types.DeleteNodeGroupRequest
return err return err
} }
// 检查是否有关联节点 // 检查是否有关联节点使用JSON_CONTAINS查询node_group_ids数组
var nodeCount int64 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) logger.Errorf("failed to count nodes in group: %v", err)
return err return err
} }

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"github.com/perfect-panel/server/internal/model/group" "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/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
@ -77,7 +78,7 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR
NodeGroupId int64 `json:"node_group_id"` NodeGroupId int64 `json:"node_group_id"`
} }
var userSubscribes []UserNodeGroupInfo 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"). Select("DISTINCT user_id as id, node_group_id").
Where("node_group_id > ?", 0). Where("node_group_id > ?", 0).
Find(&userSubscribes).Error; err != nil { Find(&userSubscribes).Error; err != nil {

View File

@ -2,8 +2,10 @@ package group
import ( import (
"context" "context"
"fmt"
"github.com/perfect-panel/server/internal/model/group" "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/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
@ -46,9 +48,9 @@ func (l *GetNodeGroupListLogic) GetNodeGroupList(req *types.GetNodeGroupListRequ
// 转换为响应格式 // 转换为响应格式
var list []types.NodeGroup var list []types.NodeGroup
for _, ng := range nodeGroups { for _, ng := range nodeGroups {
// 统计该组的节点数 // 统计该组的节点数JSON数组查询
var nodeCount int64 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 var forCalculation bool
@ -58,25 +60,37 @@ func (l *GetNodeGroupListLogic) GetNodeGroupList(req *types.GetNodeGroupListRequ
forCalculation = true // 默认值 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 { if ng.MinTrafficGB != nil {
minTrafficGB = *ng.MinTrafficGB minTrafficGB = *ng.MinTrafficGB
} }
if ng.MaxTrafficGB != nil { if ng.MaxTrafficGB != nil {
maxTrafficGB = *ng.MaxTrafficGB maxTrafficGB = *ng.MaxTrafficGB
} }
if ng.MaxTrafficGBExpired != nil {
maxTrafficGBExpired = *ng.MaxTrafficGBExpired
}
list = append(list, types.NodeGroup{ list = append(list, types.NodeGroup{
Id: ng.Id, Id: ng.Id,
Name: ng.Name, Name: ng.Name,
Description: ng.Description, Description: ng.Description,
Sort: ng.Sort, Sort: ng.Sort,
ForCalculation: forCalculation, ForCalculation: forCalculation,
MinTrafficGB: minTrafficGB, IsExpiredGroup: isExpiredGroup,
MaxTrafficGB: maxTrafficGB, ExpiredDaysLimit: ng.ExpiredDaysLimit,
NodeCount: nodeCount, MaxTrafficGBExpired: maxTrafficGBExpired,
CreatedAt: ng.CreatedAt.Unix(), SpeedLimit: ng.SpeedLimit,
UpdatedAt: ng.UpdatedAt.Unix(), MinTrafficGB: minTrafficGB,
MaxTrafficGB: maxTrafficGB,
NodeCount: nodeCount,
CreatedAt: ng.CreatedAt.Unix(),
UpdatedAt: ng.UpdatedAt.Unix(),
}) })
} }

View File

@ -28,14 +28,14 @@ func NewGetSubscribeGroupMappingLogic(ctx context.Context, svcCtx *svc.ServiceCo
func (l *GetSubscribeGroupMappingLogic) GetSubscribeGroupMapping(req *types.GetSubscribeGroupMappingRequest) (resp *types.GetSubscribeGroupMappingResponse, err error) { func (l *GetSubscribeGroupMappingLogic) GetSubscribeGroupMapping(req *types.GetSubscribeGroupMappingRequest) (resp *types.GetSubscribeGroupMappingResponse, err error) {
// 1. 查询所有订阅套餐 // 1. 查询所有订阅套餐
var subscribes []subscribe.Subscribe 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())) l.Errorw("[GetSubscribeGroupMapping] failed to query subscribes", logger.Field("error", err.Error()))
return nil, err return nil, err
} }
// 2. 查询所有节点组 // 2. 查询所有节点组
var nodeGroups []group.NodeGroup 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())) l.Errorw("[GetSubscribeGroupMapping] failed to query node groups", logger.Field("error", err.Error()))
return nil, err return nil, err
} }

View File

@ -6,7 +6,10 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/perfect-panel/server/internal/model/group"
"github.com/perfect-panel/server/internal/model/node" "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/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
@ -38,7 +41,7 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
NodeGroupId int64 // 用户订阅的 node_group_id单个ID NodeGroupId int64 // 用户订阅的 node_group_id单个ID
} }
var userSubscribes []UserSubscribe 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"). Select("id, user_id, subscribe_id, node_group_id").
Where("user_id = ? AND status IN ?", req.UserId, []int8{0, 1}). Where("user_id = ? AND status IN ?", req.UserId, []int8{0, 1}).
Find(&userSubscribes).Error Find(&userSubscribes).Error
@ -74,7 +77,7 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
NodeTags string // 节点标签 NodeTags string // 节点标签
} }
var subscribeInfos []SubscribeInfo 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"). Select("id, node_group_id, node_group_ids, nodes, node_tags").
Where("id IN ?", subscribeIds). Where("id IN ?", subscribeIds).
Find(&subscribeInfos).Error Find(&subscribeInfos).Error
@ -149,15 +152,23 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
logger.Infof("[PreviewUserNodes] collected direct node_ids: %v", allDirectNodeIds) logger.Infof("[PreviewUserNodes] collected direct node_ids: %v", allDirectNodeIds)
// 4. 判断分组功能是否启用 // 4. 判断分组功能是否启用
var groupEnabled string type SystemConfig struct {
l.svcCtx.DB.Table("system"). 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"). Where("`category` = ? AND `key` = ?", "group", "enabled").
Select("value"). 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 var filteredNodes []node.Node
@ -177,7 +188,7 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
// 5. 查询所有启用的节点(只有当有节点组时才查询) // 5. 查询所有启用的节点(只有当有节点组时才查询)
if len(allNodeGroupIds) > 0 { if len(allNodeGroupIds) > 0 {
var dbNodes []node.Node var dbNodes []node.Node
err = l.svcCtx.DB.Table("nodes"). err = l.svcCtx.DB.Model(&node.Node{}).
Where("enabled = ?", true). Where("enabled = ?", true).
Find(&dbNodes).Error Find(&dbNodes).Error
if err != nil { if err != nil {
@ -238,7 +249,7 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
// 8. 查询所有启用的节点(只有当有 tags 时才查询) // 8. 查询所有启用的节点(只有当有 tags 时才查询)
if len(allTags) > 0 { if len(allTags) > 0 {
var dbNodes []node.Node var dbNodes []node.Node
err = l.svcCtx.DB.Table("nodes"). err = l.svcCtx.DB.Model(&node.Node{}).
Where("enabled = ?", true). Where("enabled = ?", true).
Find(&dbNodes).Error Find(&dbNodes).Error
if err != nil { if err != nil {
@ -370,7 +381,7 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
Name string Name string
} }
var nodeGroupInfos []NodeGroupInfo var nodeGroupInfos []NodeGroupInfo
err = l.svcCtx.DB.Table("node_group"). err = l.svcCtx.DB.Model(&group.NodeGroup{}).
Select("id, name"). Select("id, name").
Where("id IN ?", allGroupIds). Where("id IN ?", allGroupIds).
Find(&nodeGroupInfos).Error Find(&nodeGroupInfos).Error
@ -508,7 +519,7 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
if len(allDirectNodeIds) > 0 { if len(allDirectNodeIds) > 0 {
// 查询直接分配的节点详情 // 查询直接分配的节点详情
var directNodes []node.Node var directNodes []node.Node
err = l.svcCtx.DB.Table("nodes"). err = l.svcCtx.DB.Model(&node.Node{}).
Where("id IN ? AND enabled = ?", allDirectNodeIds, true). Where("id IN ? AND enabled = ?", allDirectNodeIds, true).
Find(&directNodes).Error Find(&directNodes).Error
if err != nil { if err != nil {

View File

@ -3,9 +3,13 @@ package group
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"time" "time"
"github.com/perfect-panel/server/internal/model/group" "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/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
@ -131,7 +135,7 @@ func (l *RecalculateGroupLogic) getUserEmail(tx *gorm.DB, userId int64) string {
} }
var authMethod UserAuthMethod var authMethod UserAuthMethod
if err := tx.Table("user_auth_methods"). if err := tx.Model(&user.AuthMethods{}).
Select("auth_identifier"). Select("auth_identifier").
Where("user_id = ? AND (auth_type = ? OR auth_type = ?)", userId, "email", "6"). Where("user_id = ? AND (auth_type = ? OR auth_type = ?)", userId, "email", "6").
First(&authMethod).Error; err != nil { First(&authMethod).Error; err != nil {
@ -152,7 +156,7 @@ func (l *RecalculateGroupLogic) executeAverageGrouping(tx *gorm.DB, historyId in
} }
var userSubscribes []UserSubscribeInfo var userSubscribes []UserSubscribeInfo
if err := tx.Table("user_subscribe"). if err := tx.Model(&user.Subscribe{}).
Select("id, user_id, subscribe_id"). Select("id, user_id, subscribe_id").
Where("group_locked = ? AND status IN (0, 1)", 0). // 只查询未锁定且有效的用户订阅 Where("group_locked = ? AND status IN (0, 1)", 0). // 只查询未锁定且有效的用户订阅
Scan(&userSubscribes).Error; err != nil { Scan(&userSubscribes).Error; err != nil {
@ -168,7 +172,7 @@ func (l *RecalculateGroupLogic) executeAverageGrouping(tx *gorm.DB, historyId in
// 1.5 查询所有参与计算的节点组ID // 1.5 查询所有参与计算的节点组ID
var calculationNodeGroups []group.NodeGroup var calculationNodeGroups []group.NodeGroup
if err := tx.Table("node_group"). if err := tx.Model(&group.NodeGroup{}).
Select("id"). Select("id").
Where("for_calculation = ?", true). Where("for_calculation = ?", true).
Scan(&calculationNodeGroups).Error; err != nil { 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 NodeGroupIds string `json:"node_group_ids"` // JSON string
} }
var subscribeInfos []SubscribeInfo var subscribeInfos []SubscribeInfo
if err := tx.Table("subscribe"). if err := tx.Model(&subscribe.Subscribe{}).
Select("id, node_group_ids"). Select("id, node_group_ids").
Where("id IN ?", subscribeIds). Where("id IN ?", subscribeIds).
Find(&subscribeInfos).Error; err != nil { Find(&subscribeInfos).Error; err != nil {
@ -261,10 +265,10 @@ func (l *RecalculateGroupLogic) executeAverageGrouping(tx *gorm.DB, historyId in
} }
} }
// 如果没有节点组ID跳过 // 如果没有节点组ID,跳过
if len(nodeGroupIds) == 0 { if len(nodeGroupIds) == 0 {
l.Debugf("no valid node_group_ids for subscribe_id=%d, setting to 0", subInfo.Id) 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). Where("id = ?", us.Id).
Update("node_group_id", 0).Error; err != nil { Update("node_group_id", 0).Error; err != nil {
l.Errorw("failed to update user_subscribe node_group_id", 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 // 更新 user_subscribe 的 node_group_id 字段单个ID
if err := tx.Table("user_subscribe"). if err := tx.Model(&user.Subscribe{}).
Where("id = ?", us.Id). Where("id = ?", us.Id).
Update("node_group_id", selectedNodeGroupId).Error; err != nil { Update("node_group_id", selectedNodeGroupId).Error; err != nil {
l.Errorw("failed to update user_subscribe node_group_id", 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 var nodeCount int64 = 0
if nodeGroupId > 0 { if nodeGroupId > 0 {
if err := tx.Table("nodes"). if err := tx.Model(&node.Node{}).
Where("JSON_CONTAINS(node_group_ids, ?)", nodeGroupId). Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroupId)).
Count(&nodeCount).Error; err != nil { Count(&nodeCount).Error; err != nil {
l.Errorw("failed to count nodes", l.Errorw("failed to count nodes",
logger.Field("node_group_id", nodeGroupId), logger.Field("node_group_id", nodeGroupId),
@ -383,7 +387,7 @@ func (l *RecalculateGroupLogic) executeSubscribeGrouping(tx *gorm.DB, historyId
} }
var userSubscribes []UserSubscribeInfo var userSubscribes []UserSubscribeInfo
if err := tx.Table("user_subscribe"). if err := tx.Model(&user.Subscribe{}).
Select("id, user_id, subscribe_id"). Select("id, user_id, subscribe_id").
Where("group_locked = ? AND status IN (0, 1)", 0). Where("group_locked = ? AND status IN (0, 1)", 0).
Scan(&userSubscribes).Error; err != nil { Scan(&userSubscribes).Error; err != nil {
@ -400,7 +404,7 @@ func (l *RecalculateGroupLogic) executeSubscribeGrouping(tx *gorm.DB, historyId
// 1.5 查询所有参与计算的节点组ID // 1.5 查询所有参与计算的节点组ID
var calculationNodeGroups []group.NodeGroup var calculationNodeGroups []group.NodeGroup
if err := tx.Table("node_group"). if err := tx.Model(&group.NodeGroup{}).
Select("id"). Select("id").
Where("for_calculation = ?", true). Where("for_calculation = ?", true).
Scan(&calculationNodeGroups).Error; err != nil { 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 NodeGroupIds string `json:"node_group_ids"` // JSON string
} }
var subscribeInfos []SubscribeInfo var subscribeInfos []SubscribeInfo
if err := tx.Table("subscribe"). if err := tx.Model(&subscribe.Subscribe{}).
Select("id, node_group_ids"). Select("id, node_group_ids").
Where("id IN ?", subscribeIds). Where("id IN ?", subscribeIds).
Find(&subscribeInfos).Error; err != nil { Find(&subscribeInfos).Error; err != nil {
@ -501,7 +505,7 @@ func (l *RecalculateGroupLogic) executeSubscribeGrouping(tx *gorm.DB, historyId
us.Id, us.SubscribeId, selectedNodeGroupId, len(nodeGroupIds)) us.Id, us.SubscribeId, selectedNodeGroupId, len(nodeGroupIds))
// 更新 user_subscribe 的 node_group_id 字段 // 更新 user_subscribe 的 node_group_id 字段
if err := tx.Table("user_subscribe"). if err := tx.Model(&user.Subscribe{}).
Where("id = ?", us.Id). Where("id = ?", us.Id).
Update("node_group_id", selectedNodeGroupId).Error; err != nil { Update("node_group_id", selectedNodeGroupId).Error; err != nil {
l.Errorw("failed to update user_subscribe node_group_id", l.Errorw("failed to update user_subscribe node_group_id",
@ -548,7 +552,7 @@ func (l *RecalculateGroupLogic) executeSubscribeGrouping(tx *gorm.DB, historyId
expiredAffectedCount := 0 expiredAffectedCount := 0
for _, eu := range expiredUserSubscribes { for _, eu := range expiredUserSubscribes {
// 更新 user_subscribe 表的 node_group_id 字段到 0 // 更新 user_subscribe 表的 node_group_id 字段到 0
if err := tx.Table("user_subscribe"). if err := tx.Model(&user.Subscribe{}).
Where("id = ?", eu.Id). Where("id = ?", eu.Id).
Update("node_group_id", 0).Error; err != nil { Update("node_group_id", 0).Error; err != nil {
l.Errorw("failed to update expired user subscribe node_group_id", 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 var nodeCount int64 = 0
if nodeGroupId > 0 { if nodeGroupId > 0 {
if err := tx.Table("nodes"). if err := tx.Model(&node.Node{}).
Where("JSON_CONTAINS(node_group_ids, ?)", nodeGroupId). Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroupId)).
Count(&nodeCount).Error; err != nil { Count(&nodeCount).Error; err != nil {
l.Errorw("failed to count nodes", l.Errorw("failed to count nodes",
logger.Field("node_group_id", nodeGroupId), logger.Field("node_group_id", nodeGroupId),
@ -652,7 +656,7 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in
} }
var userSubscribes []UserSubscribeInfo 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"). Select("id, user_id, upload, download, (upload + download) as used_traffic").
Where("group_locked = ? AND status IN (0, 1)", 0). // 只查询有效且未锁定的用户订阅 Where("group_locked = ? AND status IN (0, 1)", 0). // 只查询有效且未锁定的用户订阅
Scan(&userSubscribes).Error; err != nil { Scan(&userSubscribes).Error; err != nil {
@ -694,7 +698,7 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in
// 如果没有匹配到任何范围targetNodeGroupId 保持为 0不分配节点组 // 如果没有匹配到任何范围targetNodeGroupId 保持为 0不分配节点组
// 更新 user_subscribe 的 node_group_id 字段 // 更新 user_subscribe 的 node_group_id 字段
if err := tx.Table("user_subscribe"). if err := tx.Model(&user.Subscribe{}).
Where("id = ?", us.Id). Where("id = ?", us.Id).
Update("node_group_id", targetNodeGroupId).Error; err != nil { Update("node_group_id", targetNodeGroupId).Error; err != nil {
l.Errorw("failed to update user subscribe node_group_id", l.Errorw("failed to update user subscribe node_group_id",

View File

@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/perfect-panel/server/internal/model/group" "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/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
@ -37,6 +38,34 @@ func (l *UpdateNodeGroupLogic) UpdateNodeGroup(req *types.UpdateNodeGroupRequest
return err 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{}{ updates := map[string]interface{}{
"updated_at": time.Now(), "updated_at": time.Now(),
@ -53,6 +82,22 @@ func (l *UpdateNodeGroupLogic) UpdateNodeGroup(req *types.UpdateNodeGroupRequest
if req.ForCalculation != nil { if req.ForCalculation != nil {
updates["for_calculation"] = *req.ForCalculation 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 newMinTraffic := nodeGroup.MinTrafficGB

View File

@ -34,6 +34,12 @@ func (l *CreateSubscribeLogic) CreateSubscribe(req *types.CreateSubscribeRequest
val, _ := json.Marshal(req.Discount) val, _ := json.Marshal(req.Discount)
discount = string(val) discount = string(val)
} }
trafficLimit := ""
if len(req.TrafficLimit) > 0 {
val, _ := json.Marshal(req.TrafficLimit)
trafficLimit = string(val)
}
sub := &subscribe.Subscribe{ sub := &subscribe.Subscribe{
Id: 0, Id: 0,
Name: req.Name, Name: req.Name,
@ -52,6 +58,7 @@ func (l *CreateSubscribeLogic) CreateSubscribe(req *types.CreateSubscribeRequest
NodeTags: tool.StringSliceToString(req.NodeTags), NodeTags: tool.StringSliceToString(req.NodeTags),
NodeGroupIds: subscribe.JSONInt64Slice(req.NodeGroupIds), NodeGroupIds: subscribe.JSONInt64Slice(req.NodeGroupIds),
NodeGroupId: req.NodeGroupId, NodeGroupId: req.NodeGroupId,
TrafficLimit: trafficLimit,
Show: req.Show, Show: req.Show,
Sell: req.Sell, Sell: req.Sell,
Sort: 0, Sort: 0,

View File

@ -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)) 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.Nodes = tool.StringToInt64Slice(sub.Nodes)
resp.NodeTags = strings.Split(sub.NodeTags, ",") resp.NodeTags = strings.Split(sub.NodeTags, ",")
return resp, nil return resp, nil

View File

@ -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)) 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.Nodes = tool.StringToInt64Slice(item.Nodes)
sub.NodeTags = strings.Split(item.NodeTags, ",") sub.NodeTags = strings.Split(item.NodeTags, ",")
// Handle NodeGroupIds - convert from JSONInt64Slice to []int64 // Handle NodeGroupIds - convert from JSONInt64Slice to []int64

View File

@ -42,6 +42,12 @@ func (l *UpdateSubscribeLogic) UpdateSubscribe(req *types.UpdateSubscribeRequest
val, _ := json.Marshal(req.Discount) val, _ := json.Marshal(req.Discount)
discount = string(val) discount = string(val)
} }
trafficLimit := ""
if len(req.TrafficLimit) > 0 {
val, _ := json.Marshal(req.TrafficLimit)
trafficLimit = string(val)
}
sub := &subscribe.Subscribe{ sub := &subscribe.Subscribe{
Id: req.Id, Id: req.Id,
Name: req.Name, Name: req.Name,
@ -60,6 +66,7 @@ func (l *UpdateSubscribeLogic) UpdateSubscribe(req *types.UpdateSubscribeRequest
NodeTags: tool.StringSliceToString(req.NodeTags), NodeTags: tool.StringSliceToString(req.NodeTags),
NodeGroupIds: subscribe.JSONInt64Slice(req.NodeGroupIds), NodeGroupIds: subscribe.JSONInt64Slice(req.NodeGroupIds),
NodeGroupId: req.NodeGroupId, NodeGroupId: req.NodeGroupId,
TrafficLimit: trafficLimit,
Show: req.Show, Show: req.Show,
Sell: req.Sell, Sell: req.Sell,
Sort: req.Sort, Sort: req.Sort,

View File

@ -5,6 +5,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/perfect-panel/server/internal/model/group"
"github.com/perfect-panel/server/internal/model/node" "github.com/perfect-panel/server/internal/model/node"
"github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc" "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) { func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (userSubscribeNodes []*types.UserSubscribeNodeInfo, err error) {
userSubscribeNodes = make([]*types.UserSubscribeNodeInfo, 0) userSubscribeNodes = make([]*types.UserSubscribeNodeInfo, 0)
if l.isSubscriptionExpired(userSub) { if l.isSubscriptionExpired(userSub) {
return l.createExpiredServers(), nil return l.createExpiredServers(userSub), nil
} }
// Check if group management is enabled // 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 return userSub.ExpireTime.Unix() < time.Now().Unix() && userSub.ExpireTime.Unix() != 0
} }
func (l *QueryUserSubscribeNodeListLogic) createExpiredServers() []*types.UserSubscribeNodeInfo { func (l *QueryUserSubscribeNodeListLogic) createExpiredServers(userSub *user.Subscribe) []*types.UserSubscribeNodeInfo {
return nil // 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 { func (l *QueryUserSubscribeNodeListLogic) getFirstHostLine() string {

View File

@ -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
}

View File

@ -3,6 +3,7 @@ package user
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"strconv"
"time" "time"
"github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/constant"
@ -52,6 +53,9 @@ func (l *QueryUserSubscribeLogic) QueryUserSubscribe() (resp *types.QueryUserSub
var sub types.UserSubscribe var sub types.UserSubscribe
tool.DeepCopy(&sub, item) tool.DeepCopy(&sub, item)
// 填充 IdStr 字段,避免前端精度丢失
sub.IdStr = strconv.FormatInt(item.Id, 10)
// 解析Discount字段 避免在续订时只能续订一个月 // 解析Discount字段 避免在续订时只能续订一个月
if item.Subscribe != nil && item.Subscribe.Discount != "" { if item.Subscribe != nil && item.Subscribe.Discount != "" {
var discounts []types.SubscribeDiscount var discounts []types.SubscribeDiscount

View File

@ -4,10 +4,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "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/node"
"github.com/perfect-panel/server/internal/model/subscribe" "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/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
@ -151,14 +154,33 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
return nil, err return nil, err
} }
for _, datum := range data { for _, datum := range data {
if !l.shouldIncludeServerUser(datum, nodeGroupIds) {
continue
}
// 计算该用户的实际限速值(考虑按量限速规则)
effectiveSpeedLimit := l.calculateEffectiveSpeedLimit(sub, datum)
users = append(users, types.ServerUser{ users = append(users, types.ServerUser{
Id: datum.Id, Id: datum.Id,
UUID: datum.UUID, UUID: datum.UUID,
SpeedLimit: sub.SpeedLimit, SpeedLimit: effectiveSpeedLimit,
DeviceLimit: sub.DeviceLimit, 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 { if len(users) == 0 {
users = append(users, types.ServerUser{ users = append(users, types.ServerUser{
Id: 1, Id: 1,
@ -181,3 +203,175 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
} }
return resp, nil 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
}

View File

@ -8,6 +8,7 @@ import (
"github.com/perfect-panel/server/adapter" "github.com/perfect-panel/server/adapter"
"github.com/perfect-panel/server/internal/model/client" "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/log"
"github.com/perfect-panel/server/internal/model/node" "github.com/perfect-panel/server/internal/model/node"
"github.com/perfect-panel/server/internal/report" "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) { func (l *SubscribeLogic) getServers(userSub *user.Subscribe) ([]*node.Node, error) {
if l.isSubscriptionExpired(userSub) { 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 return l.createExpiredServers(), nil
} }
@ -422,3 +436,52 @@ func (l *SubscribeLogic) isGroupEnabled() bool {
} }
return value == "true" || value == "1" 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
}

View File

@ -8,15 +8,19 @@ import (
// NodeGroup 节点组模型 // NodeGroup 节点组模型
type NodeGroup struct { type NodeGroup struct {
Id int64 `gorm:"primaryKey"` Id int64 `gorm:"primaryKey"`
Name string `gorm:"type:varchar(255);not null;comment:Name"` Name string `gorm:"type:varchar(255);not null;comment:Name"`
Description string `gorm:"type:varchar(500);comment:Description"` Description string `gorm:"type:varchar(500);comment:Description"`
Sort int `gorm:"default:0;index:idx_sort;comment:Sort Order"` 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"` 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"` IsExpiredGroup *bool `gorm:"default:false;not null;index:idx_is_expired_group;comment:Is Expired Group"`
MaxTrafficGB *int64 `gorm:"default:0;comment:Maximum Traffic (GB) for this node group"` ExpiredDaysLimit int `gorm:"default:7;not null;comment:Expired days limit (days)"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` MaxTrafficGBExpired *int64 `gorm:"default:0;comment:Max traffic for expired users (GB)"`
UpdatedAt time.Time `gorm:"comment:Update Time"` 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 指定表名 // TableName 指定表名

View File

@ -71,6 +71,7 @@ type Subscribe struct {
NodeTags string `gorm:"type:varchar(255);comment:Node Tags"` NodeTags string `gorm:"type:varchar(255);comment:Node Tags"`
NodeGroupIds JSONInt64Slice `gorm:"type:json;comment:Node Group IDs (JSON array, multiple groups)"` 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)"` 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"` 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"` Sell *bool `gorm:"type:tinyint(1);not null;default:0;comment:Sell"`
Sort int64 `gorm:"type:int;not null;default:0;comment:Sort"` Sort int64 `gorm:"type:int;not null;default:0;comment:Sort"`

View File

@ -82,7 +82,7 @@ type customUserLogicModel interface {
FindOneSubscribeDetailsById(ctx context.Context, id int64) (*SubscribeDetails, error) FindOneSubscribeDetailsById(ctx context.Context, id int64) (*SubscribeDetails, error)
FindOneUserSubscribe(ctx context.Context, id int64) (*SubscribeDetails, error) FindOneUserSubscribe(ctx context.Context, id int64) (*SubscribeDetails, error)
FindUsersSubscribeBySubscribeId(ctx context.Context, subscribeId int64) ([]*Subscribe, 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) QueryResisterUserTotalByDate(ctx context.Context, date time.Time) (int64, error)
QueryResisterUserTotalByMonthly(ctx context.Context, date time.Time) (int64, error) QueryResisterUserTotalByMonthly(ctx context.Context, date time.Time) (int64, error)
QueryResisterUserTotal(ctx context.Context) (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...)...) }, 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) sub, err := m.FindOneSubscribe(ctx, id)
if err != nil { if err != nil {
return err return err
@ -198,10 +198,21 @@ func (m *customUserModel) UpdateUserSubscribeWithTraffic(ctx context.Context, id
if len(tx) > 0 { if len(tx) > 0 {
conn = 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), if isExpired {
}).Error // 过期期间,更新过期流量字段
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
}
}) })
} }

View File

@ -85,25 +85,27 @@ func (*User) TableName() string {
} }
type Subscribe struct { type Subscribe struct {
Id int64 `gorm:"primaryKey"` Id int64 `gorm:"primaryKey"`
UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"` UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"`
User User `gorm:"foreignKey:UserId;references:Id"` User User `gorm:"foreignKey:UserId;references:Id"`
OrderId int64 `gorm:"index:idx_order_id;not null;comment:Order 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"` 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)"` 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"` 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"` StartTime time.Time `gorm:"default:CURRENT_TIMESTAMP(3);not null;comment:Subscription Start Time"`
ExpireTime time.Time `gorm:"default:NULL;comment:Subscription Expire Time"` ExpireTime time.Time `gorm:"default:NULL;comment:Subscription Expire Time"`
FinishedAt *time.Time `gorm:"default:NULL;comment:Finished Time"` FinishedAt *time.Time `gorm:"default:NULL;comment:Finished Time"`
Traffic int64 `gorm:"default:0;comment:Traffic"` Traffic int64 `gorm:"default:0;comment:Traffic"`
Download int64 `gorm:"default:0;comment:Download Traffic"` Download int64 `gorm:"default:0;comment:Download Traffic"`
Upload int64 `gorm:"default:0;comment:Upload Traffic"` Upload int64 `gorm:"default:0;comment:Upload Traffic"`
Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"` ExpiredDownload int64 `gorm:"default:0;comment:Expired period download traffic (bytes)"`
UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"` ExpiredUpload int64 `gorm:"default:0;comment:Expired period upload traffic (bytes)"`
Status uint8 `gorm:"type:tinyint(1);default:0;comment:Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired 4: Deducted 5: stopped"` Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"`
Note string `gorm:"type:varchar(500);default:'';comment:User note for subscription"` UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"`
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` Status uint8 `gorm:"type:tinyint(1);default:0;comment:Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired 4: Deducted 5: stopped"`
UpdatedAt time.Time `gorm:"comment:Update Time"` 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 { func (*Subscribe) TableName() string {

View File

@ -321,12 +321,16 @@ type CreateDocumentRequest struct {
} }
type CreateNodeGroupRequest struct { type CreateNodeGroupRequest struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Description string `json:"description"` Description string `json:"description"`
Sort int `json:"sort"` Sort int `json:"sort"`
ForCalculation *bool `json:"for_calculation"` ForCalculation *bool `json:"for_calculation"`
MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"` IsExpiredGroup *bool `json:"is_expired_group"`
MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"` 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 { type CreateNodeRequest struct {
@ -432,6 +436,7 @@ type CreateSubscribeRequest struct {
NodeTags []string `json:"node_tags"` NodeTags []string `json:"node_tags"`
NodeGroupIds []int64 `json:"node_group_ids,omitempty"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
NodeGroupId int64 `json:"node_group_id"` NodeGroupId int64 `json:"node_group_id"`
TrafficLimit []TrafficLimit `json:"traffic_limit"`
Show *bool `json:"show"` Show *bool `json:"show"`
Sell *bool `json:"sell"` Sell *bool `json:"sell"`
DeductionRatio int64 `json:"deduction_ratio"` DeductionRatio int64 `json:"deduction_ratio"`
@ -502,6 +507,13 @@ type CurrencyConfig struct {
CurrencySymbol string `json:"currency_symbol"` 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 { type DeleteAdsRequest struct {
Id int64 `json:"id"` Id int64 `json:"id"`
} }
@ -1277,6 +1289,18 @@ type GetUserTicketListResponse struct {
List []Ticket `json:"list"` 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 GiftLog struct {
Type uint16 `json:"type"` Type uint16 `json:"type"`
UserId int64 `json:"user_id"` UserId int64 `json:"user_id"`
@ -1425,16 +1449,20 @@ type NodeDNS struct {
} }
type NodeGroup struct { type NodeGroup struct {
Id int64 `json:"id"` Id int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Sort int `json:"sort"` Sort int `json:"sort"`
ForCalculation bool `json:"for_calculation"` ForCalculation bool `json:"for_calculation"`
MinTrafficGB int64 `json:"min_traffic_gb,omitempty"` IsExpiredGroup bool `json:"is_expired_group"`
MaxTrafficGB int64 `json:"max_traffic_gb,omitempty"` ExpiredDaysLimit int `json:"expired_days_limit"`
NodeCount int64 `json:"node_count,omitempty"` MaxTrafficGBExpired int64 `json:"max_traffic_gb_expired,omitempty"`
CreatedAt int64 `json:"created_at"` SpeedLimit int `json:"speed_limit"`
UpdatedAt int64 `json:"updated_at"` 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 { type NodeGroupItem struct {
@ -2299,6 +2327,7 @@ type Subscribe struct {
NodeTags []string `json:"node_tags"` NodeTags []string `json:"node_tags"`
NodeGroupIds []int64 `json:"node_group_ids,omitempty"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
NodeGroupId int64 `json:"node_group_id"` NodeGroupId int64 `json:"node_group_id"`
TrafficLimit []TrafficLimit `json:"traffic_limit"`
Show bool `json:"show"` Show bool `json:"show"`
Sell bool `json:"sell"` Sell bool `json:"sell"`
Sort int64 `json:"sort"` Sort int64 `json:"sort"`
@ -2492,6 +2521,13 @@ type TosConfig struct {
TosContent string `json:"tos_content"` 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 { type TrafficLog struct {
Id int64 `json:"id"` Id int64 `json:"id"`
ServerId int64 `json:"server_id"` ServerId int64 `json:"server_id"`
@ -2629,13 +2665,17 @@ type UpdateGroupConfigRequest struct {
} }
type UpdateNodeGroupRequest struct { type UpdateNodeGroupRequest struct {
Id int64 `json:"id" validate:"required"` Id int64 `json:"id" validate:"required"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Sort int `json:"sort"` Sort int `json:"sort"`
ForCalculation *bool `json:"for_calculation"` ForCalculation *bool `json:"for_calculation"`
MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"` IsExpiredGroup *bool `json:"is_expired_group"`
MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"` 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 { type UpdateNodeRequest struct {
@ -2727,6 +2767,7 @@ type UpdateSubscribeRequest struct {
NodeTags []string `json:"node_tags"` NodeTags []string `json:"node_tags"`
NodeGroupIds []int64 `json:"node_group_ids,omitempty"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
NodeGroupId int64 `json:"node_group_id"` NodeGroupId int64 `json:"node_group_id"`
TrafficLimit []TrafficLimit `json:"traffic_limit"`
Show *bool `json:"show"` Show *bool `json:"show"`
Sell *bool `json:"sell"` Sell *bool `json:"sell"`
Sort int64 `json:"sort"` Sort int64 `json:"sort"`
@ -2907,6 +2948,7 @@ type UserStatisticsResponse struct {
type UserSubscribe struct { type UserSubscribe struct {
Id int64 `json:"id"` Id int64 `json:"id"`
IdStr string `json:"id_str"`
UserId int64 `json:"user_id"` UserId int64 `json:"user_id"`
OrderId int64 `json:"order_id"` OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"` SubscribeId int64 `json:"subscribe_id"`

View File

@ -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) { func (l *ActivateOrderLogic) createUserSubscription(ctx context.Context, orderInfo *order.Order, sub *subscribe.Subscribe) (*user.Subscribe, error) {
now := time.Now() now := time.Now()
userSub := &user.Subscribe{ userSub := &user.Subscribe{
UserId: orderInfo.UserId, UserId: orderInfo.UserId,
OrderId: orderInfo.Id, OrderId: orderInfo.Id,
SubscribeId: orderInfo.SubscribeId, SubscribeId: orderInfo.SubscribeId,
StartTime: now, StartTime: now,
ExpireTime: tool.AddTime(sub.UnitTime, orderInfo.Quantity, now), ExpireTime: tool.AddTime(sub.UnitTime, orderInfo.Quantity, now),
Traffic: sub.Traffic, Traffic: sub.Traffic,
Download: 0, Download: 0,
Upload: 0, Upload: 0,
Token: uuidx.SubscribeToken(orderInfo.OrderNo), ExpiredDownload: 0,
UUID: uuid.New().String(), ExpiredUpload: 0,
Status: 1, Token: uuidx.SubscribeToken(orderInfo.OrderNo),
NodeGroupId: sub.NodeGroupId, // Inherit node_group_id from subscription plan UUID: uuid.New().String(),
Status: 1,
NodeGroupId: sub.NodeGroupId, // Inherit node_group_id from subscription plan
} }
// Check quota limit before creating subscription (final safeguard) // 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.ExpireTime = tool.AddTime(sub.UnitTime, orderInfo.Quantity, userSub.ExpireTime)
userSub.Status = 1 userSub.Status = 1
// 续费时重置过期流量字段
userSub.ExpiredDownload = 0
userSub.ExpiredUpload = 0
if err := l.svc.UserModel.UpdateSubscribe(ctx, userSub); err != nil { if err := l.svc.UserModel.UpdateSubscribe(ctx, userSub); err != nil {
logger.WithContext(ctx).Error("Update user subscribe failed", logger.Field("error", err.Error())) 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 // Reset traffic
userSub.Download = 0 userSub.Download = 0
userSub.Upload = 0 userSub.Upload = 0
userSub.ExpiredDownload = 0
userSub.ExpiredUpload = 0
userSub.Status = 1 userSub.Status = 1
if err := l.svc.UserModel.UpdateSubscribe(ctx, userSub); err != nil { if err := l.svc.UserModel.UpdateSubscribe(ctx, userSub); err != nil {

View File

@ -98,11 +98,13 @@ func (l *TrafficStatisticsLogic) ProcessTask(ctx context.Context, task *asynq.Ta
// update user subscribe with log // update user subscribe with log
d := int64(float32(log.Download) * ratio * realTimeMultiplier) d := int64(float32(log.Download) * ratio * realTimeMultiplier)
u := int64(float32(log.Upload) * 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.WithContext(ctx).Error("[TrafficStatistics] Update user subscribe with log failed",
logger.Field("sid", log.SID), logger.Field("sid", log.SID),
logger.Field("download", float32(log.Download)*ratio), logger.Field("download", float32(log.Download)*ratio),
logger.Field("upload", float32(log.Upload)*ratio), logger.Field("upload", float32(log.Upload)*ratio),
logger.Field("is_expired", isExpired),
logger.Field("error", err.Error()), logger.Field("error", err.Error()),
) )
continue continue