Features:

- Node group CRUD operations with traffic-based filtering
  - Three grouping modes: average distribution, subscription-based, and traffic-based
  - Automatic and manual group recalculation with history tracking
  - Group assignment preview before applying changes
  - User subscription group locking to prevent automatic reassignment
  - Subscribe-to-group mapping configuration
  - Group calculation history and detailed reports
  - System configuration for group management (enabled/mode/auto_create)

  Database:
  - Add node_group table for group definitions
  - Add group_history and group_history_detail tables for tracking
  - Add node_group_ids (JSON) to nodes and subscribe tables
  - Add node_group_id and group_locked fields to user_subscribe table
  - Add migration files for schema changes
This commit is contained in:
EUForest
2026-03-08 23:22:38 +08:00
parent 7d46b31866
commit 39310d5b9a
72 changed files with 4682 additions and 282 deletions
@@ -0,0 +1,46 @@
package group
import (
"context"
"time"
"github.com/perfect-panel/server/internal/model/group"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
)
type CreateNodeGroupLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCreateNodeGroupLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateNodeGroupLogic {
return &CreateNodeGroupLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CreateNodeGroupLogic) CreateNodeGroup(req *types.CreateNodeGroupRequest) error {
// 创建节点组
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(),
}
if err := l.svcCtx.DB.Create(nodeGroup).Error; err != nil {
logger.Errorf("failed to create node group: %v", err)
return err
}
logger.Infof("created node group: node_group_id=%d", nodeGroup.Id)
return nil
}
@@ -0,0 +1,61 @@
package group
import (
"context"
"errors"
"fmt"
"github.com/perfect-panel/server/internal/model/group"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"gorm.io/gorm"
)
type DeleteNodeGroupLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDeleteNodeGroupLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteNodeGroupLogic {
return &DeleteNodeGroupLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *DeleteNodeGroupLogic) DeleteNodeGroup(req *types.DeleteNodeGroupRequest) error {
// 查询节点组信息
var nodeGroup group.NodeGroup
if err := l.svcCtx.DB.Where("id = ?", req.Id).First(&nodeGroup).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("node group not found")
}
logger.Errorf("failed to find node group: %v", err)
return err
}
// 检查是否有关联节点
var nodeCount int64
if err := l.svcCtx.DB.Table("nodes").Where("node_group_id = ?", nodeGroup.Id).Count(&nodeCount).Error; err != nil {
logger.Errorf("failed to count nodes in group: %v", err)
return err
}
if nodeCount > 0 {
return fmt.Errorf("cannot delete group with %d associated nodes, please migrate nodes first", nodeCount)
}
// 使用 GORM Transaction 删除节点组
return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
// 删除节点组
if err := tx.Where("id = ?", req.Id).Delete(&group.NodeGroup{}).Error; err != nil {
logger.Errorf("failed to delete node group: %v", err)
return err // 自动回滚
}
logger.Infof("deleted node group: id=%d", nodeGroup.Id)
return nil // 自动提交
})
}
@@ -0,0 +1,128 @@
package group
import (
"bytes"
"context"
"encoding/csv"
"fmt"
"github.com/perfect-panel/server/internal/model/group"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
)
type ExportGroupResultLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewExportGroupResultLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ExportGroupResultLogic {
return &ExportGroupResultLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
// ExportGroupResult 导出分组结果为 CSV
// 返回:CSV 数据(字节切片)、文件名、错误
func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultRequest) ([]byte, string, error) {
var records [][]string
// CSV 表头
records = append(records, []string{"用户ID", "节点组ID", "节点组名称"})
if req.HistoryId != nil {
// 导出指定历史的详细结果
// 1. 查询分组历史详情
var details []group.GroupHistoryDetail
if err := l.svcCtx.DB.Where("history_id = ?", *req.HistoryId).Find(&details).Error; err != nil {
logger.Errorf("failed to get group history details: %v", err)
return nil, "", err
}
// 2. 为每个组生成记录
for _, detail := range details {
// 从 UserData JSON 解析用户信息
type UserInfo struct {
Id int64 `json:"id"`
Email string `json:"email"`
}
var users []UserInfo
if err := l.svcCtx.DB.Raw("SELECT * FROM JSON_ARRAY(?)", detail.UserData).Scan(&users).Error; err != nil {
// 如果解析失败,尝试用标准 JSON 解析
logger.Errorf("failed to parse user data: %v", err)
continue
}
// 查询节点组名称
var nodeGroup group.NodeGroup
l.svcCtx.DB.Where("id = ?", detail.NodeGroupId).First(&nodeGroup)
// 为每个用户生成记录
for _, user := range users {
records = append(records, []string{
fmt.Sprintf("%d", user.Id),
fmt.Sprintf("%d", nodeGroup.Id),
nodeGroup.Name,
})
}
}
} else {
// 导出当前所有用户的分组情况
type UserNodeGroupInfo struct {
Id int64 `json:"id"`
NodeGroupId int64 `json:"node_group_id"`
}
var userSubscribes []UserNodeGroupInfo
if err := l.svcCtx.DB.Table("user_subscribe").
Select("DISTINCT user_id as id, node_group_id").
Where("node_group_id > ?", 0).
Find(&userSubscribes).Error; err != nil {
logger.Errorf("failed to get users: %v", err)
return nil, "", err
}
// 为每个用户生成记录
for _, us := range userSubscribes {
// 查询节点组信息
var nodeGroup group.NodeGroup
if err := l.svcCtx.DB.Where("id = ?", us.NodeGroupId).First(&nodeGroup).Error; err != nil {
logger.Errorf("failed to find node group: %v", err)
// 跳过该用户
continue
}
records = append(records, []string{
fmt.Sprintf("%d", us.Id),
fmt.Sprintf("%d", nodeGroup.Id),
nodeGroup.Name,
})
}
}
// 生成 CSV 数据
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
writer.WriteAll(records)
writer.Flush()
if err := writer.Error(); err != nil {
logger.Errorf("failed to write csv: %v", err)
return nil, "", err
}
// 添加 UTF-8 BOM
bom := []byte{0xEF, 0xBB, 0xBF}
csvData := buf.Bytes()
result := make([]byte, 0, len(bom)+len(csvData))
result = append(result, bom...)
result = append(result, csvData...)
// 生成文件名
filename := fmt.Sprintf("group_result_%d.csv", req.HistoryId)
return result, filename, nil
}
@@ -0,0 +1,125 @@
package group
import (
"context"
"encoding/json"
"github.com/perfect-panel/server/internal/model/group"
"github.com/perfect-panel/server/internal/model/system"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type GetGroupConfigLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Get group config
func NewGetGroupConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetGroupConfigLogic {
return &GetGroupConfigLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetGroupConfigLogic) GetGroupConfig(req *types.GetGroupConfigRequest) (resp *types.GetGroupConfigResponse, err error) {
// 读取基础配置
var enabledConfig system.System
var modeConfig system.System
var averageConfig system.System
var subscribeConfig system.System
var trafficConfig system.System
// 从 system_config 表读取配置
if err := l.svcCtx.DB.Where("`category` = 'group' and `key` = ?", "enabled").First(&enabledConfig).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
l.Errorw("failed to get group enabled config", logger.Field("error", err.Error()))
return nil, err
}
if err := l.svcCtx.DB.Where("`category` = 'group' and `key` = ?", "mode").First(&modeConfig).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
l.Errorw("failed to get group mode config", logger.Field("error", err.Error()))
return nil, err
}
// 读取 JSON 配置
config := make(map[string]interface{})
if err := l.svcCtx.DB.Where("`category` = 'group' and `key` = ?", "average_config").First(&averageConfig).Error; err == nil {
var averageCfg map[string]interface{}
if err := json.Unmarshal([]byte(averageConfig.Value), &averageCfg); err == nil {
config["average_config"] = averageCfg
}
}
if err := l.svcCtx.DB.Where("`category` = 'group' and `key` = ?", "subscribe_config").First(&subscribeConfig).Error; err == nil {
var subscribeCfg map[string]interface{}
if err := json.Unmarshal([]byte(subscribeConfig.Value), &subscribeCfg); err == nil {
config["subscribe_config"] = subscribeCfg
}
}
if err := l.svcCtx.DB.Where("`category` = 'group' and `key` = ?", "traffic_config").First(&trafficConfig).Error; err == nil {
var trafficCfg map[string]interface{}
if err := json.Unmarshal([]byte(trafficConfig.Value), &trafficCfg); err == nil {
config["traffic_config"] = trafficCfg
}
}
// 解析基础配置
enabled := enabledConfig.Value == "true"
mode := modeConfig.Value
if mode == "" {
mode = "average" // 默认模式
}
// 获取重算状态
state, err := l.getRecalculationState()
if err != nil {
l.Errorw("failed to get recalculation state", logger.Field("error", err.Error()))
// 继续执行,不影响配置获取
state = &types.RecalculationState{
State: "idle",
Progress: 0,
Total: 0,
}
}
resp = &types.GetGroupConfigResponse{
Enabled: enabled,
Mode: mode,
Config: config,
State: *state,
}
return resp, nil
}
// getRecalculationState 获取重算状态
func (l *GetGroupConfigLogic) getRecalculationState() (*types.RecalculationState, error) {
var history group.GroupHistory
err := l.svcCtx.DB.Order("id desc").First(&history).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &types.RecalculationState{
State: "idle",
Progress: 0,
Total: 0,
}, nil
}
return nil, err
}
state := &types.RecalculationState{
State: history.State,
Progress: history.TotalUsers,
Total: history.TotalUsers,
}
return state, nil
}
@@ -0,0 +1,109 @@
package group
import (
"context"
"encoding/json"
"errors"
"github.com/perfect-panel/server/internal/model/group"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"gorm.io/gorm"
)
type GetGroupHistoryDetailLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetGroupHistoryDetailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetGroupHistoryDetailLogic {
return &GetGroupHistoryDetailLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetGroupHistoryDetailLogic) GetGroupHistoryDetail(req *types.GetGroupHistoryDetailRequest) (resp *types.GetGroupHistoryDetailResponse, err error) {
// 查询分组历史记录
var history group.GroupHistory
if err := l.svcCtx.DB.Where("id = ?", req.Id).First(&history).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("group history not found")
}
logger.Errorf("failed to find group history: %v", err)
return nil, err
}
// 查询分组历史详情
var details []group.GroupHistoryDetail
if err := l.svcCtx.DB.Where("history_id = ?", req.Id).Find(&details).Error; err != nil {
logger.Errorf("failed to find group history details: %v", err)
return nil, err
}
// 转换时间格式
var startTime, endTime *int64
if history.StartTime != nil {
t := history.StartTime.Unix()
startTime = &t
}
if history.EndTime != nil {
t := history.EndTime.Unix()
endTime = &t
}
// 构建 GroupHistoryDetail
historyDetail := types.GroupHistoryDetail{
GroupHistory: types.GroupHistory{
Id: history.Id,
GroupMode: history.GroupMode,
TriggerType: history.TriggerType,
TotalUsers: history.TotalUsers,
SuccessCount: history.SuccessCount,
FailedCount: history.FailedCount,
StartTime: startTime,
EndTime: endTime,
ErrorLog: history.ErrorMessage,
CreatedAt: history.CreatedAt.Unix(),
},
}
// 如果有详情记录,构建 ConfigSnapshot
if len(details) > 0 {
configSnapshot := make(map[string]interface{})
configSnapshot["group_details"] = details
// 获取配置快照(从 system_config 读取)
var configValue string
if history.GroupMode == "average" {
l.svcCtx.DB.Table("system_config").
Where("`key` = ?", "group.average_config").
Select("value").
Scan(&configValue)
} else if history.GroupMode == "traffic" {
l.svcCtx.DB.Table("system_config").
Where("`key` = ?", "group.traffic_config").
Select("value").
Scan(&configValue)
}
// 解析 JSON 配置
if configValue != "" {
var config map[string]interface{}
if err := json.Unmarshal([]byte(configValue), &config); err == nil {
configSnapshot["config"] = config
}
}
historyDetail.ConfigSnapshot = configSnapshot
}
resp = &types.GetGroupHistoryDetailResponse{
GroupHistoryDetail: historyDetail,
}
return resp, nil
}
@@ -0,0 +1,87 @@
package group
import (
"context"
"github.com/perfect-panel/server/internal/model/group"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
)
type GetGroupHistoryLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetGroupHistoryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetGroupHistoryLogic {
return &GetGroupHistoryLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetGroupHistoryLogic) GetGroupHistory(req *types.GetGroupHistoryRequest) (resp *types.GetGroupHistoryResponse, err error) {
var histories []group.GroupHistory
var total int64
// 构建查询
query := l.svcCtx.DB.Model(&group.GroupHistory{})
// 添加过滤条件
if req.GroupMode != "" {
query = query.Where("group_mode = ?", req.GroupMode)
}
if req.TriggerType != "" {
query = query.Where("trigger_type = ?", req.TriggerType)
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
logger.Errorf("failed to count group histories: %v", err)
return nil, err
}
// 分页查询
offset := (req.Page - 1) * req.Size
if err := query.Order("id DESC").Offset(offset).Limit(req.Size).Find(&histories).Error; err != nil {
logger.Errorf("failed to find group histories: %v", err)
return nil, err
}
// 转换为响应格式
var list []types.GroupHistory
for _, h := range histories {
var startTime, endTime *int64
if h.StartTime != nil {
t := h.StartTime.Unix()
startTime = &t
}
if h.EndTime != nil {
t := h.EndTime.Unix()
endTime = &t
}
list = append(list, types.GroupHistory{
Id: h.Id,
GroupMode: h.GroupMode,
TriggerType: h.TriggerType,
TotalUsers: h.TotalUsers,
SuccessCount: h.SuccessCount,
FailedCount: h.FailedCount,
StartTime: startTime,
EndTime: endTime,
ErrorLog: h.ErrorMessage,
CreatedAt: h.CreatedAt.Unix(),
})
}
resp = &types.GetGroupHistoryResponse{
Total: total,
List: list,
}
return resp, nil
}
@@ -0,0 +1,89 @@
package group
import (
"context"
"github.com/perfect-panel/server/internal/model/group"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
)
type GetNodeGroupListLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetNodeGroupListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetNodeGroupListLogic {
return &GetNodeGroupListLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetNodeGroupListLogic) GetNodeGroupList(req *types.GetNodeGroupListRequest) (resp *types.GetNodeGroupListResponse, err error) {
var nodeGroups []group.NodeGroup
var total int64
// 构建查询
query := l.svcCtx.DB.Model(&group.NodeGroup{})
// 获取总数
if err := query.Count(&total).Error; err != nil {
logger.Errorf("failed to count node groups: %v", err)
return nil, err
}
// 分页查询
offset := (req.Page - 1) * req.Size
if err := query.Order("sort ASC").Offset(offset).Limit(req.Size).Find(&nodeGroups).Error; err != nil {
logger.Errorf("failed to find node groups: %v", err)
return nil, err
}
// 转换为响应格式
var list []types.NodeGroup
for _, ng := range nodeGroups {
// 统计该组的节点数
var nodeCount int64
l.svcCtx.DB.Table("nodes").Where("node_group_id = ?", ng.Id).Count(&nodeCount)
// 处理指针类型的字段
var forCalculation bool
if ng.ForCalculation != nil {
forCalculation = *ng.ForCalculation
} else {
forCalculation = true // 默认值
}
var minTrafficGB, maxTrafficGB int64
if ng.MinTrafficGB != nil {
minTrafficGB = *ng.MinTrafficGB
}
if ng.MaxTrafficGB != nil {
maxTrafficGB = *ng.MaxTrafficGB
}
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(),
})
}
resp = &types.GetNodeGroupListResponse{
Total: total,
List: list,
}
return resp, nil
}
@@ -0,0 +1,57 @@
package group
import (
"context"
"github.com/perfect-panel/server/internal/model/group"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type GetRecalculationStatusLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Get recalculation status
func NewGetRecalculationStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRecalculationStatusLogic {
return &GetRecalculationStatusLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetRecalculationStatusLogic) GetRecalculationStatus() (resp *types.RecalculationState, err error) {
// 返回最近的一条 GroupHistory 记录
var history group.GroupHistory
err = l.svcCtx.DB.Order("id desc").First(&history).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 如果没有历史记录,返回空闲状态
resp = &types.RecalculationState{
State: "idle",
Progress: 0,
Total: 0,
}
return resp, nil
}
l.Errorw("failed to get group history", logger.Field("error", err.Error()))
return nil, err
}
// 转换为 RecalculationState 格式
// Progress = 已处理的用户数(成功+失败),Total = 总用户数
processedUsers := history.SuccessCount + history.FailedCount
resp = &types.RecalculationState{
State: history.State,
Progress: processedUsers,
Total: history.TotalUsers,
}
return resp, nil
}
@@ -0,0 +1,71 @@
package group
import (
"context"
"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"
)
type GetSubscribeGroupMappingLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Get subscribe group mapping
func NewGetSubscribeGroupMappingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetSubscribeGroupMappingLogic {
return &GetSubscribeGroupMappingLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
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 {
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 {
l.Errorw("[GetSubscribeGroupMapping] failed to query node groups", logger.Field("error", err.Error()))
return nil, err
}
// 创建 node_group_id -> node_group_name 的映射
nodeGroupMap := make(map[int64]string)
for _, ng := range nodeGroups {
nodeGroupMap[ng.Id] = ng.Name
}
// 3. 构建映射结果:套餐 -> 默认节点组(一对一)
var mappingList []types.SubscribeGroupMappingItem
for _, sub := range subscribes {
// 获取套餐的默认节点组(node_group_ids 数组的第一个)
nodeGroupName := ""
if len(sub.NodeGroupIds) > 0 {
defaultNodeGroupId := sub.NodeGroupIds[0]
nodeGroupName = nodeGroupMap[defaultNodeGroupId]
}
mappingList = append(mappingList, types.SubscribeGroupMappingItem{
SubscribeName: sub.Name,
NodeGroupName: nodeGroupName,
})
}
resp = &types.GetSubscribeGroupMappingResponse{
List: mappingList,
}
return resp, nil
}
@@ -0,0 +1,466 @@
package group
import (
"context"
"encoding/json"
"fmt"
"strings"
"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"
"github.com/perfect-panel/server/pkg/tool"
)
type PreviewUserNodesLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewPreviewUserNodesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PreviewUserNodesLogic {
return &PreviewUserNodesLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequest) (resp *types.PreviewUserNodesResponse, err error) {
logger.Infof("[PreviewUserNodes] userId: %v", req.UserId)
// 1. 查询用户的所有有效订阅(只查询可用状态:0-Pending, 1-Active
type UserSubscribe struct {
Id int64
UserId int64
SubscribeId int64
NodeGroupId int64 // 用户订阅的 node_group_id(单个ID
}
var userSubscribes []UserSubscribe
err = l.svcCtx.DB.Table("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
if err != nil {
logger.Errorf("[PreviewUserNodes] failed to get user subscribes: %v", err)
return nil, err
}
if len(userSubscribes) == 0 {
logger.Infof("[PreviewUserNodes] no user subscribes found")
resp = &types.PreviewUserNodesResponse{
UserId: req.UserId,
NodeGroups: []types.NodeGroupItem{},
}
return resp, nil
}
logger.Infof("[PreviewUserNodes] found %v user subscribes", len(userSubscribes))
// 2. 按优先级获取 node_group_iduser_subscribe.node_group_id > subscribe.node_group_id > subscribe.node_group_ids[0]
// 收集所有订阅ID以便批量查询
subscribeIds := make([]int64, len(userSubscribes))
for i, us := range userSubscribes {
subscribeIds[i] = us.SubscribeId
}
// 批量查询订阅信息
type SubscribeInfo struct {
Id int64
NodeGroupId int64
NodeGroupIds string // JSON string
}
var subscribeInfos []SubscribeInfo
err = l.svcCtx.DB.Table("subscribe").
Select("id, node_group_id, node_group_ids").
Where("id IN ?", subscribeIds).
Find(&subscribeInfos).Error
if err != nil {
logger.Errorf("[PreviewUserNodes] failed to get subscribe infos: %v", err)
return nil, err
}
// 创建 subscribe_id -> SubscribeInfo 的映射
subInfoMap := make(map[int64]SubscribeInfo)
for _, si := range subscribeInfos {
subInfoMap[si.Id] = si
}
// 按优先级获取每个用户订阅的 node_group_id
var allNodeGroupIds []int64
for _, us := range userSubscribes {
nodeGroupId := int64(0)
// 优先级1: user_subscribe.node_group_id
if us.NodeGroupId != 0 {
nodeGroupId = us.NodeGroupId
logger.Debugf("[PreviewUserNodes] user_subscribe_id=%d using node_group_id=%d", us.Id, nodeGroupId)
} else {
// 优先级2: subscribe.node_group_id
subInfo, ok := subInfoMap[us.SubscribeId]
if ok {
if subInfo.NodeGroupId != 0 {
nodeGroupId = subInfo.NodeGroupId
logger.Debugf("[PreviewUserNodes] user_subscribe_id=%d using subscribe.node_group_id=%d", us.Id, nodeGroupId)
} else if subInfo.NodeGroupIds != "" && subInfo.NodeGroupIds != "null" && subInfo.NodeGroupIds != "[]" {
// 优先级3: subscribe.node_group_ids[0]
var nodeGroupIds []int64
if err := json.Unmarshal([]byte(subInfo.NodeGroupIds), &nodeGroupIds); err == nil && len(nodeGroupIds) > 0 {
nodeGroupId = nodeGroupIds[0]
logger.Debugf("[PreviewUserNodes] user_subscribe_id=%d using subscribe.node_group_ids[0]=%d", us.Id, nodeGroupId)
}
}
}
}
if nodeGroupId != 0 {
allNodeGroupIds = append(allNodeGroupIds, nodeGroupId)
}
}
// 去重
allNodeGroupIds = removeDuplicateInt64(allNodeGroupIds)
logger.Infof("[PreviewUserNodes] collected node_group_ids with priority: %v", allNodeGroupIds)
// 4. 判断分组功能是否启用
var groupEnabled string
l.svcCtx.DB.Table("system").
Where("`category` = ? AND `key` = ?", "group", "enabled").
Select("value").
Scan(&groupEnabled)
logger.Infof("[PreviewUserNodes] groupEnabled: %v", groupEnabled)
isGroupEnabled := groupEnabled == "true" || groupEnabled == "1"
var filteredNodes []node.Node
if isGroupEnabled {
// === 启用分组功能:通过用户订阅的 node_group_id 查询节点 ===
logger.Infof("[PreviewUserNodes] using group-based node filtering")
if len(allNodeGroupIds) == 0 {
logger.Infof("[PreviewUserNodes] no node groups found in user subscribes")
resp = &types.PreviewUserNodesResponse{
UserId: req.UserId,
NodeGroups: []types.NodeGroupItem{},
}
return resp, nil
}
// 5. 查询所有启用的节点
var dbNodes []node.Node
err = l.svcCtx.DB.Table("nodes").
Where("enabled = ?", true).
Find(&dbNodes).Error
if err != nil {
logger.Errorf("[PreviewUserNodes] failed to get nodes: %v", err)
return nil, err
}
// 6. 过滤出包含至少一个匹配节点组的节点
// node_group_ids 为空 = 公共节点,所有人可见
// node_group_ids 与订阅的 node_group_id 匹配 = 该节点可见
for _, n := range dbNodes {
// 公共节点(node_group_ids 为空),所有人可见
if len(n.NodeGroupIds) == 0 {
filteredNodes = append(filteredNodes, n)
continue
}
// 检查节点的 node_group_ids 是否与订阅的 node_group_id 有交集
for _, nodeGroupId := range n.NodeGroupIds {
if tool.Contains(allNodeGroupIds, nodeGroupId) {
filteredNodes = append(filteredNodes, n)
break
}
}
}
logger.Infof("[PreviewUserNodes] found %v nodes using group filter", len(filteredNodes))
} else {
// === 未启用分组功能:通过订阅的 node_tags 查询节点 ===
logger.Infof("[PreviewUserNodes] using tag-based node filtering")
// 5. 获取所有订阅的 subscribeId 列表
subscribeIds := make([]int64, len(userSubscribes))
for i, us := range userSubscribes {
subscribeIds[i] = us.SubscribeId
}
// 6. 查询这些订阅的 node_tags
type SubscribeNodeTags struct {
Id int64
NodeTags string
}
var subscribeNodeTagsList []SubscribeNodeTags
err = l.svcCtx.DB.Table("subscribe").
Where("id IN ?", subscribeIds).
Select("id, node_tags").
Find(&subscribeNodeTagsList).Error
if err != nil {
logger.Errorf("[PreviewUserNodes] failed to get subscribe node tags: %v", err)
return nil, err
}
// 7. 合并所有标签
var allTags []string
for _, snt := range subscribeNodeTagsList {
if snt.NodeTags != "" {
tags := strings.Split(snt.NodeTags, ",")
allTags = append(allTags, tags...)
}
}
// 去重
allTags = tool.RemoveDuplicateElements(allTags...)
// 去除空字符串
allTags = tool.RemoveStringElement(allTags, "")
logger.Infof("[PreviewUserNodes] merged tags from subscribes: %v", allTags)
if len(allTags) == 0 {
logger.Infof("[PreviewUserNodes] no tags found in subscribes")
resp = &types.PreviewUserNodesResponse{
UserId: req.UserId,
NodeGroups: []types.NodeGroupItem{},
}
return resp, nil
}
// 8. 查询所有启用的节点
var dbNodes []node.Node
err = l.svcCtx.DB.Table("nodes").
Where("enabled = ?", true).
Find(&dbNodes).Error
if err != nil {
logger.Errorf("[PreviewUserNodes] failed to get nodes: %v", err)
return nil, err
}
// 9. 过滤出包含至少一个匹配标签的节点
for _, n := range dbNodes {
if n.Tags == "" {
continue
}
nodeTags := strings.Split(n.Tags, ",")
// 检查是否有交集
for _, tag := range nodeTags {
if tag != "" && tool.Contains(allTags, tag) {
filteredNodes = append(filteredNodes, n)
break
}
}
}
logger.Infof("[PreviewUserNodes] found %v nodes using tag filter", len(filteredNodes))
}
// 10. 转换为 types.Node 并按节点组分组
type NodeWithGroup struct {
Node node.Node
NodeGroupIds []int64
}
nodesWithGroup := make([]NodeWithGroup, 0, len(filteredNodes))
for _, n := range filteredNodes {
nodesWithGroup = append(nodesWithGroup, NodeWithGroup{
Node: n,
NodeGroupIds: []int64(n.NodeGroupIds),
})
}
// 11. 按节点组分组节点
type NodeGroupMap struct {
Id int64
Nodes []types.Node
}
// 创建节点组映射:group_id -> nodes
groupMap := make(map[int64]*NodeGroupMap)
// 获取所有涉及的节点组ID
allGroupIds := make([]int64, 0)
for _, ng := range nodesWithGroup {
if len(ng.NodeGroupIds) > 0 {
// 如果节点属于节点组,按第一个节点组分组(或者可以按所有节点组)
// 这里使用节点的第一个节点组
firstGroupId := ng.NodeGroupIds[0]
if _, exists := groupMap[firstGroupId]; !exists {
groupMap[firstGroupId] = &NodeGroupMap{
Id: firstGroupId,
Nodes: []types.Node{},
}
allGroupIds = append(allGroupIds, firstGroupId)
}
// 转换节点
tags := []string{}
if ng.Node.Tags != "" {
tags = strings.Split(ng.Node.Tags, ",")
}
node := types.Node{
Id: ng.Node.Id,
Name: ng.Node.Name,
Tags: tags,
Port: ng.Node.Port,
Address: ng.Node.Address,
ServerId: ng.Node.ServerId,
Protocol: ng.Node.Protocol,
Enabled: ng.Node.Enabled,
Sort: ng.Node.Sort,
NodeGroupIds: []int64(ng.Node.NodeGroupIds),
CreatedAt: ng.Node.CreatedAt.Unix(),
UpdatedAt: ng.Node.UpdatedAt.Unix(),
}
groupMap[firstGroupId].Nodes = append(groupMap[firstGroupId].Nodes, node)
} else {
// 没有节点组的节点,使用 group_id = 0 作为"无节点组"分组
if _, exists := groupMap[0]; !exists {
groupMap[0] = &NodeGroupMap{
Id: 0,
Nodes: []types.Node{},
}
}
tags := []string{}
if ng.Node.Tags != "" {
tags = strings.Split(ng.Node.Tags, ",")
}
node := types.Node{
Id: ng.Node.Id,
Name: ng.Node.Name,
Tags: tags,
Port: ng.Node.Port,
Address: ng.Node.Address,
ServerId: ng.Node.ServerId,
Protocol: ng.Node.Protocol,
Enabled: ng.Node.Enabled,
Sort: ng.Node.Sort,
NodeGroupIds: []int64(ng.Node.NodeGroupIds),
CreatedAt: ng.Node.CreatedAt.Unix(),
UpdatedAt: ng.Node.UpdatedAt.Unix(),
}
groupMap[0].Nodes = append(groupMap[0].Nodes, node)
}
}
// 12. 查询节点组信息并构建响应
nodeGroupInfoMap := make(map[int64]string)
validGroupIds := make([]int64, 0) // 存储在数据库中实际存在的节点组ID
if len(allGroupIds) > 0 {
type NodeGroupInfo struct {
Id int64
Name string
}
var nodeGroupInfos []NodeGroupInfo
err = l.svcCtx.DB.Table("node_group").
Select("id, name").
Where("id IN ?", allGroupIds).
Find(&nodeGroupInfos).Error
if err != nil {
logger.Errorf("[PreviewUserNodes] failed to get node group infos: %v", err)
return nil, err
}
logger.Infof("[PreviewUserNodes] found %v node group infos from %v requested", len(nodeGroupInfos), len(allGroupIds))
// 创建节点组信息映射和有效节点组ID列表
for _, ngInfo := range nodeGroupInfos {
nodeGroupInfoMap[ngInfo.Id] = ngInfo.Name
validGroupIds = append(validGroupIds, ngInfo.Id)
logger.Debugf("[PreviewUserNodes] node_group[%d] = %s", ngInfo.Id, ngInfo.Name)
}
// 记录无效的节点组ID(节点有这个ID但数据库中不存在)
for _, requestedId := range allGroupIds {
found := false
for _, validId := range validGroupIds {
if requestedId == validId {
found = true
break
}
}
if !found {
logger.Infof("[PreviewUserNodes] node_group_id %d not found in database, treating as public nodes", requestedId)
}
}
}
// 13. 构建响应:根据有效节点组ID重新分组节点
nodeGroupItems := make([]types.NodeGroupItem, 0)
publicNodes := make([]types.Node, 0) // 公共节点(包括无效节点组和无节点组的节点)
// 遍历所有分组,重新分类节点
for groupId, gm := range groupMap {
if groupId == 0 {
// 本来就是无节点组的节点
publicNodes = append(publicNodes, gm.Nodes...)
continue
}
// 检查这个节点组ID是否有效(在数据库中存在)
isValid := false
for _, validId := range validGroupIds {
if groupId == validId {
isValid = true
break
}
}
if isValid {
// 节点组有效,添加到对应的分组
groupName := nodeGroupInfoMap[groupId]
if groupName == "" {
groupName = fmt.Sprintf("Group %d", groupId)
}
nodeGroupItems = append(nodeGroupItems, types.NodeGroupItem{
Id: groupId,
Name: groupName,
Nodes: gm.Nodes,
})
logger.Infof("[PreviewUserNodes] adding node group: id=%d, name=%s, nodes=%d", groupId, groupName, len(gm.Nodes))
} else {
// 节点组无效,节点归入公共节点组
logger.Infof("[PreviewUserNodes] node_group_id %d invalid, moving %d nodes to public group", groupId, len(gm.Nodes))
publicNodes = append(publicNodes, gm.Nodes...)
}
}
// 最后添加公共节点组(如果有)
if len(publicNodes) > 0 {
nodeGroupItems = append(nodeGroupItems, types.NodeGroupItem{
Id: 0,
Name: "",
Nodes: publicNodes,
})
logger.Infof("[PreviewUserNodes] adding public group: nodes=%d", len(publicNodes))
}
// 14. 返回结果
resp = &types.PreviewUserNodesResponse{
UserId: req.UserId,
NodeGroups: nodeGroupItems,
}
logger.Infof("[PreviewUserNodes] returning %v node groups for user %v", len(resp.NodeGroups), req.UserId)
return resp, nil
}
// removeDuplicateInt64 去重 []int64
func removeDuplicateInt64(slice []int64) []int64 {
keys := make(map[int64]bool)
var list []int64
for _, entry := range slice {
if !keys[entry] {
keys[entry] = true
list = append(list, entry)
}
}
return list
}
@@ -0,0 +1,814 @@
package group
import (
"context"
"encoding/json"
"time"
"github.com/perfect-panel/server/internal/model/group"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type RecalculateGroupLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Recalculate group
func NewRecalculateGroupLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RecalculateGroupLogic {
return &RecalculateGroupLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *RecalculateGroupLogic) RecalculateGroup(req *types.RecalculateGroupRequest) error {
// 验证 mode 参数
if req.Mode != "average" && req.Mode != "subscribe" && req.Mode != "traffic" {
return errors.New("invalid mode, must be one of: average, subscribe, traffic")
}
// 创建 GroupHistory 记录(state=pending
triggerType := req.TriggerType
if triggerType == "" {
triggerType = "manual" // 默认为手动触发
}
history := &group.GroupHistory{
GroupMode: req.Mode,
TriggerType: triggerType,
TotalUsers: 0,
SuccessCount: 0,
FailedCount: 0,
}
now := time.Now()
history.StartTime = &now
// 使用 GORM Transaction 执行分组重算
err := l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
// 创建历史记录
if err := tx.Create(history).Error; err != nil {
l.Errorw("failed to create group history", logger.Field("error", err.Error()))
return err
}
// 更新状态为 running
if err := tx.Model(history).Update("state", "running").Error; err != nil {
l.Errorw("failed to update history state to running", logger.Field("error", err.Error()))
return err
}
// 根据 mode 执行不同的分组算法
var affectedCount int
var err error
switch req.Mode {
case "average":
affectedCount, err = l.executeAverageGrouping(tx, history.Id)
if err != nil {
l.Errorw("failed to execute average grouping", logger.Field("error", err.Error()))
return err
}
case "subscribe":
affectedCount, err = l.executeSubscribeGrouping(tx, history.Id)
if err != nil {
l.Errorw("failed to execute subscribe grouping", logger.Field("error", err.Error()))
return err
}
case "traffic":
affectedCount, err = l.executeTrafficGrouping(tx, history.Id)
if err != nil {
l.Errorw("failed to execute traffic grouping", logger.Field("error", err.Error()))
return err
}
}
// 更新 GroupHistory 记录(state=completed, 统计成功/失败数)
endTime := time.Now()
updates := map[string]interface{}{
"state": "completed",
"total_users": affectedCount,
"success_count": affectedCount, // 暂时假设所有都成功
"failed_count": 0,
"end_time": endTime,
}
if err := tx.Model(history).Updates(updates).Error; err != nil {
l.Errorw("failed to update history state to completed", logger.Field("error", err.Error()))
return err
}
l.Infof("group recalculation completed: mode=%s, affected_users=%d", req.Mode, affectedCount)
return nil
})
if err != nil {
// 如果失败,更新历史记录状态为 failed
updateErr := l.svcCtx.DB.Model(history).Updates(map[string]interface{}{
"state": "failed",
"error_message": err.Error(),
"end_time": time.Now(),
}).Error
if updateErr != nil {
l.Errorw("failed to update history state to failed", logger.Field("error", updateErr.Error()))
}
return err
}
return nil
}
// getUserEmail 查询用户的邮箱
func (l *RecalculateGroupLogic) getUserEmail(tx *gorm.DB, userId int64) string {
type UserAuthMethod struct {
AuthIdentifier string `json:"auth_identifier"`
}
var authMethod UserAuthMethod
if err := tx.Table("user_auth_methods").
Select("auth_identifier").
Where("user_id = ? AND (auth_type = ? OR auth_type = ?)", userId, "email", "6").
First(&authMethod).Error; err != nil {
return ""
}
return authMethod.AuthIdentifier
}
// executeAverageGrouping 实现平均分组算法(随机分配节点组到用户订阅)
// 新逻辑:获取所有有效用户订阅,从订阅的节点组ID中随机选择一个,设置到用户订阅的 node_group_id 字段
func (l *RecalculateGroupLogic) executeAverageGrouping(tx *gorm.DB, historyId int64) (int, error) {
// 1. 查询所有有效且未锁定的用户订阅(status IN (0, 1)
type UserSubscribeInfo struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
SubscribeId int64 `json:"subscribe_id"`
}
var userSubscribes []UserSubscribeInfo
if err := tx.Table("user_subscribe").
Select("id, user_id, subscribe_id").
Where("group_locked = ? AND status IN (0, 1)", 0). // 只查询未锁定且有效的用户订阅
Scan(&userSubscribes).Error; err != nil {
return 0, err
}
if len(userSubscribes) == 0 {
l.Infof("average grouping: no valid and unlocked user subscribes found")
return 0, nil
}
l.Infof("average grouping: found %d valid and unlocked user subscribes", len(userSubscribes))
// 1.5 查询所有参与计算的节点组ID
var calculationNodeGroups []group.NodeGroup
if err := tx.Table("node_group").
Select("id").
Where("for_calculation = ?", true).
Scan(&calculationNodeGroups).Error; err != nil {
l.Errorw("failed to query calculation node groups", logger.Field("error", err.Error()))
return 0, err
}
// 创建参与计算的节点组ID集合(用于快速查找)
calculationNodeGroupIds := make(map[int64]bool)
for _, ng := range calculationNodeGroups {
calculationNodeGroupIds[ng.Id] = true
}
l.Infof("average grouping: found %d node groups with for_calculation=true", len(calculationNodeGroupIds))
// 2. 批量查询订阅的节点组ID信息
subscribeIds := make([]int64, len(userSubscribes))
for i, us := range userSubscribes {
subscribeIds[i] = us.SubscribeId
}
type SubscribeInfo struct {
Id int64 `json:"id"`
NodeGroupIds string `json:"node_group_ids"` // JSON string
}
var subscribeInfos []SubscribeInfo
if err := tx.Table("subscribe").
Select("id, node_group_ids").
Where("id IN ?", subscribeIds).
Find(&subscribeInfos).Error; err != nil {
l.Errorw("failed to query subscribe infos", logger.Field("error", err.Error()))
return 0, err
}
// 创建 subscribe_id -> SubscribeInfo 的映射
subInfoMap := make(map[int64]SubscribeInfo)
for _, si := range subscribeInfos {
subInfoMap[si.Id] = si
}
// 用于存储统计信息(按节点组ID统计用户数)
groupUsersMap := make(map[int64][]struct {
Id int64 `json:"id"`
Email string `json:"email"`
})
nodeGroupUserCount := make(map[int64]int) // node_group_id -> user_count
nodeGroupNodeCount := make(map[int64]int) // node_group_id -> node_count
// 3. 遍历所有用户订阅,按序平均分配节点组
affectedCount := 0
failedCount := 0
// 为每个订阅维护一个分配索引,用于按序循环分配
subscribeAllocationIndex := make(map[int64]int) // subscribe_id -> current_index
for _, us := range userSubscribes {
subInfo, ok := subInfoMap[us.SubscribeId]
if !ok {
l.Infow("subscribe not found",
logger.Field("user_subscribe_id", us.Id),
logger.Field("subscribe_id", us.SubscribeId))
failedCount++
continue
}
// 解析订阅的节点组ID列表,并过滤出参与计算的节点组
var nodeGroupIds []int64
if subInfo.NodeGroupIds != "" && subInfo.NodeGroupIds != "[]" {
var allNodeGroupIds []int64
if err := json.Unmarshal([]byte(subInfo.NodeGroupIds), &allNodeGroupIds); err != nil {
l.Errorw("failed to parse node_group_ids",
logger.Field("subscribe_id", subInfo.Id),
logger.Field("node_group_ids", subInfo.NodeGroupIds),
logger.Field("error", err.Error()))
failedCount++
continue
}
// 只保留参与计算的节点组
for _, ngId := range allNodeGroupIds {
if calculationNodeGroupIds[ngId] {
nodeGroupIds = append(nodeGroupIds, ngId)
}
}
if len(nodeGroupIds) == 0 && len(allNodeGroupIds) > 0 {
l.Debugw("all node_group_ids are not for calculation, setting to 0",
logger.Field("subscribe_id", subInfo.Id),
logger.Field("total_node_groups", len(allNodeGroupIds)))
}
}
// 如果没有节点组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").
Where("id = ?", us.Id).
Update("node_group_id", 0).Error; err != nil {
l.Errorw("failed to update user_subscribe node_group_id",
logger.Field("user_subscribe_id", us.Id),
logger.Field("error", err.Error()))
failedCount++
continue
}
}
// 按序选择节点组ID(循环轮询分配)
selectedNodeGroupId := int64(0)
if len(nodeGroupIds) > 0 {
// 获取当前订阅的分配索引
currentIndex := subscribeAllocationIndex[us.SubscribeId]
// 选择当前索引对应的节点组
selectedNodeGroupId = nodeGroupIds[currentIndex]
// 更新索引,循环使用(轮询)
subscribeAllocationIndex[us.SubscribeId] = (currentIndex + 1) % len(nodeGroupIds)
l.Debugf("assigning user_subscribe_id=%d (subscribe_id=%d) to node_group_id=%d (index=%d, total_options=%d, mode=sequential)",
us.Id, us.SubscribeId, selectedNodeGroupId, currentIndex, len(nodeGroupIds))
}
// 更新 user_subscribe 的 node_group_id 字段(单个ID
if err := tx.Table("user_subscribe").
Where("id = ?", us.Id).
Update("node_group_id", selectedNodeGroupId).Error; err != nil {
l.Errorw("failed to update user_subscribe node_group_id",
logger.Field("user_subscribe_id", us.Id),
logger.Field("error", err.Error()))
failedCount++
continue
}
// 只统计有节点组的用户
if selectedNodeGroupId > 0 {
// 查询用户邮箱,用于保存到历史记录
email := l.getUserEmail(tx, us.UserId)
groupUsersMap[selectedNodeGroupId] = append(groupUsersMap[selectedNodeGroupId], struct {
Id int64 `json:"id"`
Email string `json:"email"`
}{
Id: us.UserId,
Email: email,
})
nodeGroupUserCount[selectedNodeGroupId]++
}
affectedCount++
}
l.Infof("average grouping completed: affected=%d, failed=%d", affectedCount, failedCount)
// 4. 创建分组历史详情记录(按节点组ID统计)
for nodeGroupId, users := range groupUsersMap {
userCount := len(users)
if userCount == 0 {
continue
}
// 统计该节点组的节点数
var nodeCount int64 = 0
if nodeGroupId > 0 {
if err := tx.Table("nodes").
Where("JSON_CONTAINS(node_group_ids, ?)", nodeGroupId).
Count(&nodeCount).Error; err != nil {
l.Errorw("failed to count nodes",
logger.Field("node_group_id", nodeGroupId),
logger.Field("error", err.Error()))
}
}
nodeGroupNodeCount[nodeGroupId] = int(nodeCount)
// 序列化用户信息为 JSON
userDataJSON := "[]"
if jsonData, err := json.Marshal(users); err == nil {
userDataJSON = string(jsonData)
} else {
l.Errorw("failed to marshal user data",
logger.Field("node_group_id", nodeGroupId),
logger.Field("error", err.Error()))
}
// 创建历史详情(使用 node_group_id 作为分组标识)
detail := &group.GroupHistoryDetail{
HistoryId: historyId,
NodeGroupId: nodeGroupId,
UserCount: userCount,
NodeCount: int(nodeCount),
UserData: userDataJSON,
}
if err := tx.Create(detail).Error; err != nil {
l.Errorw("failed to create group history detail",
logger.Field("node_group_id", nodeGroupId),
logger.Field("error", err.Error()))
}
l.Infof("Average Group (node_group_id=%d): users=%d, nodes=%d",
nodeGroupId, userCount, nodeCount)
}
return affectedCount, nil
}
// executeSubscribeGrouping 实现基于订阅套餐的分组算法
// 逻辑:查询有效订阅 → 获取订阅的 node_group_ids → 取第一个 node_group_id(如果有) → 更新 user_subscribe.node_group_id
// 订阅过期的用户 → 设置 node_group_id 为 0
func (l *RecalculateGroupLogic) executeSubscribeGrouping(tx *gorm.DB, historyId int64) (int, error) {
// 1. 查询所有有效且未锁定的用户订阅(status IN (0, 1), group_locked = 0
type UserSubscribeInfo struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
SubscribeId int64 `json:"subscribe_id"`
}
var userSubscribes []UserSubscribeInfo
if err := tx.Table("user_subscribe").
Select("id, user_id, subscribe_id").
Where("group_locked = ? AND status IN (0, 1)", 0).
Scan(&userSubscribes).Error; err != nil {
l.Errorw("failed to query user subscribes", logger.Field("error", err.Error()))
return 0, err
}
if len(userSubscribes) == 0 {
l.Infof("subscribe grouping: no valid and unlocked user subscribes found")
return 0, nil
}
l.Infof("subscribe grouping: found %d valid and unlocked user subscribes", len(userSubscribes))
// 1.5 查询所有参与计算的节点组ID
var calculationNodeGroups []group.NodeGroup
if err := tx.Table("node_group").
Select("id").
Where("for_calculation = ?", true).
Scan(&calculationNodeGroups).Error; err != nil {
l.Errorw("failed to query calculation node groups", logger.Field("error", err.Error()))
return 0, err
}
// 创建参与计算的节点组ID集合(用于快速查找)
calculationNodeGroupIds := make(map[int64]bool)
for _, ng := range calculationNodeGroups {
calculationNodeGroupIds[ng.Id] = true
}
l.Infof("subscribe grouping: found %d node groups with for_calculation=true", len(calculationNodeGroupIds))
// 2. 批量查询订阅的节点组ID信息
subscribeIds := make([]int64, len(userSubscribes))
for i, us := range userSubscribes {
subscribeIds[i] = us.SubscribeId
}
type SubscribeInfo struct {
Id int64 `json:"id"`
NodeGroupIds string `json:"node_group_ids"` // JSON string
}
var subscribeInfos []SubscribeInfo
if err := tx.Table("subscribe").
Select("id, node_group_ids").
Where("id IN ?", subscribeIds).
Find(&subscribeInfos).Error; err != nil {
l.Errorw("failed to query subscribe infos", logger.Field("error", err.Error()))
return 0, err
}
// 创建 subscribe_id -> SubscribeInfo 的映射
subInfoMap := make(map[int64]SubscribeInfo)
for _, si := range subscribeInfos {
subInfoMap[si.Id] = si
}
// 用于存储统计信息(按节点组ID统计用户数)
type UserInfo struct {
Id int64 `json:"id"`
Email string `json:"email"`
}
groupUsersMap := make(map[int64][]UserInfo)
nodeGroupUserCount := make(map[int64]int) // node_group_id -> user_count
nodeGroupNodeCount := make(map[int64]int) // node_group_id -> node_count
// 3. 遍历所有用户订阅,取第一个节点组ID
affectedCount := 0
failedCount := 0
for _, us := range userSubscribes {
subInfo, ok := subInfoMap[us.SubscribeId]
if !ok {
l.Infow("subscribe not found",
logger.Field("user_subscribe_id", us.Id),
logger.Field("subscribe_id", us.SubscribeId))
failedCount++
continue
}
// 解析订阅的节点组ID列表,并过滤出参与计算的节点组
var nodeGroupIds []int64
if subInfo.NodeGroupIds != "" && subInfo.NodeGroupIds != "[]" {
var allNodeGroupIds []int64
if err := json.Unmarshal([]byte(subInfo.NodeGroupIds), &allNodeGroupIds); err != nil {
l.Errorw("failed to parse node_group_ids",
logger.Field("subscribe_id", subInfo.Id),
logger.Field("node_group_ids", subInfo.NodeGroupIds),
logger.Field("error", err.Error()))
failedCount++
continue
}
// 只保留参与计算的节点组
for _, ngId := range allNodeGroupIds {
if calculationNodeGroupIds[ngId] {
nodeGroupIds = append(nodeGroupIds, ngId)
}
}
if len(nodeGroupIds) == 0 && len(allNodeGroupIds) > 0 {
l.Debugw("all node_group_ids are not for calculation, setting to 0",
logger.Field("subscribe_id", subInfo.Id),
logger.Field("total_node_groups", len(allNodeGroupIds)))
}
}
// 取第一个参与计算的节点组ID(如果有),否则设置为 0
selectedNodeGroupId := int64(0)
if len(nodeGroupIds) > 0 {
selectedNodeGroupId = nodeGroupIds[0]
}
l.Debugf("assigning user_subscribe_id=%d (subscribe_id=%d) to node_group_id=%d (total_options=%d, selected_first)",
us.Id, us.SubscribeId, selectedNodeGroupId, len(nodeGroupIds))
// 更新 user_subscribe 的 node_group_id 字段
if err := tx.Table("user_subscribe").
Where("id = ?", us.Id).
Update("node_group_id", selectedNodeGroupId).Error; err != nil {
l.Errorw("failed to update user_subscribe node_group_id",
logger.Field("user_subscribe_id", us.Id),
logger.Field("error", err.Error()))
failedCount++
continue
}
// 只统计有节点组的用户
if selectedNodeGroupId > 0 {
// 查询用户邮箱,用于保存到历史记录
email := l.getUserEmail(tx, us.UserId)
groupUsersMap[selectedNodeGroupId] = append(groupUsersMap[selectedNodeGroupId], UserInfo{
Id: us.UserId,
Email: email,
})
nodeGroupUserCount[selectedNodeGroupId]++
}
affectedCount++
}
l.Infof("subscribe grouping completed: affected=%d, failed=%d", affectedCount, failedCount)
// 4. 处理订阅过期/失效的用户,设置 node_group_id 为 0
// 查询所有没有有效订阅且未锁定的用户订阅记录
var expiredUserSubscribes []struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
}
if err := tx.Raw(`
SELECT us.id, us.user_id
FROM user_subscribe as us
WHERE us.group_locked = 0
AND us.status NOT IN (0, 1)
`).Scan(&expiredUserSubscribes).Error; err != nil {
l.Errorw("failed to query expired user subscribes", logger.Field("error", err.Error()))
// 继续处理,不因为过期用户查询失败而影响
} else {
l.Infof("found %d expired user subscribes for subscribe-based grouping, will set node_group_id to 0", len(expiredUserSubscribes))
expiredAffectedCount := 0
for _, eu := range expiredUserSubscribes {
// 更新 user_subscribe 表的 node_group_id 字段到 0
if err := tx.Table("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",
logger.Field("user_subscribe_id", eu.Id),
logger.Field("error", err.Error()))
continue
}
expiredAffectedCount++
}
l.Infof("expired user subscribes grouping completed: affected=%d", expiredAffectedCount)
}
// 5. 创建分组历史详情记录(按节点组ID统计)
for nodeGroupId, users := range groupUsersMap {
userCount := len(users)
if userCount == 0 {
continue
}
// 统计该节点组的节点数
var nodeCount int64 = 0
if nodeGroupId > 0 {
if err := tx.Table("nodes").
Where("JSON_CONTAINS(node_group_ids, ?)", nodeGroupId).
Count(&nodeCount).Error; err != nil {
l.Errorw("failed to count nodes",
logger.Field("node_group_id", nodeGroupId),
logger.Field("error", err.Error()))
}
}
nodeGroupNodeCount[nodeGroupId] = int(nodeCount)
// 序列化用户信息为 JSON
userDataJSON := "[]"
if jsonData, err := json.Marshal(users); err == nil {
userDataJSON = string(jsonData)
} else {
l.Errorw("failed to marshal user data",
logger.Field("node_group_id", nodeGroupId),
logger.Field("error", err.Error()))
}
// 创建历史详情
detail := &group.GroupHistoryDetail{
HistoryId: historyId,
NodeGroupId: nodeGroupId,
UserCount: userCount,
NodeCount: int(nodeCount),
UserData: userDataJSON,
}
if err := tx.Create(detail).Error; err != nil {
l.Errorw("failed to create group history detail",
logger.Field("node_group_id", nodeGroupId),
logger.Field("error", err.Error()))
}
l.Infof("Subscribe Group (node_group_id=%d): users=%d, nodes=%d",
nodeGroupId, userCount, nodeCount)
}
return affectedCount, nil
}
// executeTrafficGrouping 实现基于流量的分组算法
// 逻辑:根据配置的流量范围,将用户分配到对应的用户组
func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId int64) (int, error) {
// 用于存储每个节点组的用户信息(id 和 email)
type UserInfo struct {
Id int64 `json:"id"`
Email string `json:"email"`
}
groupUsersMap := make(map[int64][]UserInfo) // node_group_id -> []UserInfo
// 1. 获取所有设置了流量区间的节点组
var nodeGroups []group.NodeGroup
if err := tx.Where("for_calculation = ?", true).
Where("(min_traffic_gb > 0 OR max_traffic_gb > 0)").
Find(&nodeGroups).Error; err != nil {
l.Errorw("failed to query node groups", logger.Field("error", err.Error()))
return 0, err
}
if len(nodeGroups) == 0 {
l.Infow("no node groups with traffic ranges configured")
return 0, nil
}
l.Infow("executeTrafficGrouping loaded node groups",
logger.Field("node_groups_count", len(nodeGroups)))
// 2. 查询所有有效且未锁定的用户订阅及其已用流量
type UserSubscribeInfo struct {
Id int64
UserId int64
Upload int64
Download int64
UsedTraffic int64 // 已用流量 = upload + download (bytes)
}
var userSubscribes []UserSubscribeInfo
if err := tx.Table("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 {
l.Errorw("failed to query user subscribes", logger.Field("error", err.Error()))
return 0, err
}
if len(userSubscribes) == 0 {
l.Infow("no valid and unlocked user subscribes found")
return 0, nil
}
l.Infow("found user subscribes for traffic-based grouping", logger.Field("count", len(userSubscribes)))
// 3. 根据流量范围分配节点组ID到用户订阅
affectedCount := 0
groupUserCount := make(map[int64]int) // node_group_id -> user_count
for _, us := range userSubscribes {
// 将字节转换为 GB
usedTrafficGB := float64(us.UsedTraffic) / (1024 * 1024 * 1024)
// 查找匹配的流量范围(使用左闭右开区间 [Min, Max))
var targetNodeGroupId int64 = 0
for _, ng := range nodeGroups {
if ng.MinTrafficGB == nil || ng.MaxTrafficGB == nil {
continue
}
minTraffic := float64(*ng.MinTrafficGB)
maxTraffic := float64(*ng.MaxTrafficGB)
// 检查是否在区间内 [min, max)
if usedTrafficGB >= minTraffic && usedTrafficGB < maxTraffic {
targetNodeGroupId = ng.Id
break
}
}
// 如果没有匹配到任何范围,targetNodeGroupId 保持为 0(不分配节点组)
// 更新 user_subscribe 的 node_group_id 字段
if err := tx.Table("user_subscribe").
Where("id = ?", us.Id).
Update("node_group_id", targetNodeGroupId).Error; err != nil {
l.Errorw("failed to update user subscribe node_group_id",
logger.Field("user_subscribe_id", us.Id),
logger.Field("target_node_group_id", targetNodeGroupId),
logger.Field("error", err.Error()))
continue
}
// 只有分配了节点组的用户才记录到历史
if targetNodeGroupId > 0 {
// 查询用户邮箱,用于保存到历史记录
email := l.getUserEmail(tx, us.UserId)
userInfo := UserInfo{
Id: us.UserId,
Email: email,
}
groupUsersMap[targetNodeGroupId] = append(groupUsersMap[targetNodeGroupId], userInfo)
groupUserCount[targetNodeGroupId]++
l.Debugf("assigned user subscribe %d (traffic: %.2fGB) to node group %d",
us.Id, usedTrafficGB, targetNodeGroupId)
} else {
l.Debugf("user subscribe %d (traffic: %.2fGB) not assigned to any node group",
us.Id, usedTrafficGB)
}
affectedCount++
}
l.Infof("traffic-based grouping completed: affected_subscribes=%d", affectedCount)
// 4. 创建分组历史详情记录(只统计有用户的节点组)
nodeGroupCount := make(map[int64]int) // node_group_id -> node_count
for _, ng := range nodeGroups {
nodeGroupCount[ng.Id] = 1 // 每个节点组计为1
}
for nodeGroupId, userCount := range groupUserCount {
userDataJSON, err := json.Marshal(groupUsersMap[nodeGroupId])
if err != nil {
l.Errorw("failed to marshal user data",
logger.Field("node_group_id", nodeGroupId),
logger.Field("error", err.Error()))
continue
}
detail := group.GroupHistoryDetail{
HistoryId: historyId,
NodeGroupId: nodeGroupId,
UserCount: userCount,
NodeCount: nodeGroupCount[nodeGroupId],
UserData: string(userDataJSON),
}
if err := tx.Create(&detail).Error; err != nil {
l.Errorw("failed to create group history detail",
logger.Field("history_id", historyId),
logger.Field("node_group_id", nodeGroupId),
logger.Field("error", err.Error()))
}
}
return affectedCount, nil
}
// containsIgnoreCase checks if a string contains another substring (case-insensitive)
func containsIgnoreCase(s, substr string) bool {
if len(substr) == 0 {
return true
}
if len(s) < len(substr) {
return false
}
// Simple case-insensitive contains check
sLower := toLower(s)
substrLower := toLower(substr)
return contains(sLower, substrLower)
}
// toLower converts a string to lowercase
func toLower(s string) string {
result := make([]rune, len(s))
for i, r := range s {
if r >= 'A' && r <= 'Z' {
result[i] = r + ('a' - 'A')
} else {
result[i] = r
}
}
return string(result)
}
// contains checks if a string contains another substring (case-sensitive)
func contains(s, substr string) bool {
return len(s) >= len(substr) && indexOf(s, substr) >= 0
}
// indexOf returns the index of the first occurrence of substr in s, or -1 if not found
func indexOf(s, substr string) int {
n := len(substr)
if n == 0 {
return 0
}
if n > len(s) {
return -1
}
// Simple string search
for i := 0; i <= len(s)-n; i++ {
if s[i:i+n] == substr {
return i
}
}
return -1
}
@@ -0,0 +1,82 @@
package group
import (
"context"
"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/system"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/logger"
)
type ResetGroupsLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewResetGroupsLogic Reset all groups (delete all node groups and reset related data)
func NewResetGroupsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ResetGroupsLogic {
return &ResetGroupsLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ResetGroupsLogic) ResetGroups() error {
// 1. Delete all node groups
err := l.svcCtx.DB.Where("1 = 1").Delete(&group.NodeGroup{}).Error
if err != nil {
l.Errorw("Failed to delete all node groups", logger.Field("error", err.Error()))
return err
}
l.Infow("Successfully deleted all node groups")
// 2. Clear node_group_ids for all subscribes (products)
err = l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("1 = 1").Update("node_group_ids", "[]").Error
if err != nil {
l.Errorw("Failed to clear subscribes' node_group_ids", logger.Field("error", err.Error()))
return err
}
l.Infow("Successfully cleared all subscribes' node_group_ids")
// 3. Clear node_group_ids for all nodes
err = l.svcCtx.DB.Model(&node.Node{}).Where("1 = 1").Update("node_group_ids", "[]").Error
if err != nil {
l.Errorw("Failed to clear nodes' node_group_ids", logger.Field("error", err.Error()))
return err
}
l.Infow("Successfully cleared all nodes' node_group_ids")
// 4. Clear group history
err = l.svcCtx.DB.Where("1 = 1").Delete(&group.GroupHistory{}).Error
if err != nil {
l.Errorw("Failed to clear group history", logger.Field("error", err.Error()))
// Non-critical error, continue anyway
} else {
l.Infow("Successfully cleared group history")
}
// 7. Clear group history details
err = l.svcCtx.DB.Where("1 = 1").Delete(&group.GroupHistoryDetail{}).Error
if err != nil {
l.Errorw("Failed to clear group history details", logger.Field("error", err.Error()))
// Non-critical error, continue anyway
} else {
l.Infow("Successfully cleared group history details")
}
// 5. Delete all group config settings
err = l.svcCtx.DB.Where("`category` = ?", "group").Delete(&system.System{}).Error
if err != nil {
l.Errorw("Failed to delete group config", logger.Field("error", err.Error()))
return err
}
l.Infow("Successfully deleted all group config settings")
l.Infow("Group reset completed successfully")
return nil
}
@@ -0,0 +1,188 @@
package group
import (
"context"
"encoding/json"
"github.com/perfect-panel/server/internal/model/system"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type UpdateGroupConfigLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Update group config
func NewUpdateGroupConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateGroupConfigLogic {
return &UpdateGroupConfigLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateGroupConfigLogic) UpdateGroupConfig(req *types.UpdateGroupConfigRequest) error {
// 验证 mode 是否为合法值
if req.Mode != "" {
if req.Mode != "average" && req.Mode != "subscribe" && req.Mode != "traffic" {
return errors.New("invalid mode, must be one of: average, subscribe, traffic")
}
}
// 使用 GORM Transaction 更新配置
err := l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
// 更新 enabled 配置(使用 Upsert 逻辑)
enabledValue := "false"
if req.Enabled {
enabledValue = "true"
}
result := tx.Model(&system.System{}).
Where("`category` = 'group' and `key` = ?", "enabled").
Update("value", enabledValue)
if result.Error != nil {
l.Errorw("failed to update group enabled config", logger.Field("error", result.Error.Error()))
return result.Error
}
// 如果没有更新任何行,说明记录不存在,需要插入
if result.RowsAffected == 0 {
if err := tx.Create(&system.System{
Category: "group",
Key: "enabled",
Value: enabledValue,
Desc: "Group Feature Enabled",
}).Error; err != nil {
l.Errorw("failed to create group enabled config", logger.Field("error", err.Error()))
return err
}
}
// 更新 mode 配置(使用 Upsert 逻辑)
if req.Mode != "" {
result := tx.Model(&system.System{}).
Where("`category` = 'group' and `key` = ?", "mode").
Update("value", req.Mode)
if result.Error != nil {
l.Errorw("failed to update group mode config", logger.Field("error", result.Error.Error()))
return result.Error
}
// 如果没有更新任何行,说明记录不存在,需要插入
if result.RowsAffected == 0 {
if err := tx.Create(&system.System{
Category: "group",
Key: "mode",
Value: req.Mode,
Desc: "Group Mode",
}).Error; err != nil {
l.Errorw("failed to create group mode config", logger.Field("error", err.Error()))
return err
}
}
}
// 更新 JSON 配置
if req.Config != nil {
// 更新 average_config
if averageConfig, ok := req.Config["average_config"]; ok {
jsonBytes, err := json.Marshal(averageConfig)
if err != nil {
l.Errorw("failed to marshal average_config", logger.Field("error", err.Error()))
return errors.Wrap(err, "failed to marshal average_config")
}
// 使用 Upsert 逻辑:先尝试 UPDATE,如果不存在则 INSERT
result := tx.Model(&system.System{}).
Where("`category` = 'group' and `key` = ?", "average_config").
Update("value", string(jsonBytes))
if result.Error != nil {
l.Errorw("failed to update group average_config", logger.Field("error", result.Error.Error()))
return result.Error
}
// 如果没有更新任何行,说明记录不存在,需要插入
if result.RowsAffected == 0 {
if err := tx.Create(&system.System{
Category: "group",
Key: "average_config",
Value: string(jsonBytes),
Desc: "Average Group Config",
}).Error; err != nil {
l.Errorw("failed to create group average_config", logger.Field("error", err.Error()))
return err
}
}
}
// 更新 subscribe_config
if subscribeConfig, ok := req.Config["subscribe_config"]; ok {
jsonBytes, err := json.Marshal(subscribeConfig)
if err != nil {
l.Errorw("failed to marshal subscribe_config", logger.Field("error", err.Error()))
return errors.Wrap(err, "failed to marshal subscribe_config")
}
// 使用 Upsert 逻辑:先尝试 UPDATE,如果不存在则 INSERT
result := tx.Model(&system.System{}).
Where("`category` = 'group' and `key` = ?", "subscribe_config").
Update("value", string(jsonBytes))
if result.Error != nil {
l.Errorw("failed to update group subscribe_config", logger.Field("error", result.Error.Error()))
return result.Error
}
// 如果没有更新任何行,说明记录不存在,需要插入
if result.RowsAffected == 0 {
if err := tx.Create(&system.System{
Category: "group",
Key: "subscribe_config",
Value: string(jsonBytes),
Desc: "Subscribe Group Config",
}).Error; err != nil {
l.Errorw("failed to create group subscribe_config", logger.Field("error", err.Error()))
return err
}
}
}
// 更新 traffic_config
if trafficConfig, ok := req.Config["traffic_config"]; ok {
jsonBytes, err := json.Marshal(trafficConfig)
if err != nil {
l.Errorw("failed to marshal traffic_config", logger.Field("error", err.Error()))
return errors.Wrap(err, "failed to marshal traffic_config")
}
// 使用 Upsert 逻辑:先尝试 UPDATE,如果不存在则 INSERT
result := tx.Model(&system.System{}).
Where("`category` = 'group' and `key` = ?", "traffic_config").
Update("value", string(jsonBytes))
if result.Error != nil {
l.Errorw("failed to update group traffic_config", logger.Field("error", result.Error.Error()))
return result.Error
}
// 如果没有更新任何行,说明记录不存在,需要插入
if result.RowsAffected == 0 {
if err := tx.Create(&system.System{
Category: "group",
Key: "traffic_config",
Value: string(jsonBytes),
Desc: "Traffic Group Config",
}).Error; err != nil {
l.Errorw("failed to create group traffic_config", logger.Field("error", err.Error()))
return err
}
}
}
}
return nil
})
if err != nil {
l.Errorw("failed to update group config", logger.Field("error", err.Error()))
return err
}
l.Infof("group config updated successfully: enabled=%v, mode=%s", req.Enabled, req.Mode)
return nil
}
@@ -0,0 +1,140 @@
package group
import (
"context"
"errors"
"time"
"github.com/perfect-panel/server/internal/model/group"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"gorm.io/gorm"
)
type UpdateNodeGroupLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateNodeGroupLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateNodeGroupLogic {
return &UpdateNodeGroupLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateNodeGroupLogic) UpdateNodeGroup(req *types.UpdateNodeGroupRequest) error {
// 检查节点组是否存在
var nodeGroup group.NodeGroup
if err := l.svcCtx.DB.Where("id = ?", req.Id).First(&nodeGroup).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("node group not found")
}
logger.Errorf("failed to find node group: %v", err)
return err
}
// 构建更新数据
updates := map[string]interface{}{
"updated_at": time.Now(),
}
if req.Name != "" {
updates["name"] = req.Name
}
if req.Description != "" {
updates["description"] = req.Description
}
if req.Sort != 0 {
updates["sort"] = req.Sort
}
if req.ForCalculation != nil {
updates["for_calculation"] = *req.ForCalculation
}
// 获取新的流量区间值
newMinTraffic := nodeGroup.MinTrafficGB
newMaxTraffic := nodeGroup.MaxTrafficGB
if req.MinTrafficGB != nil {
newMinTraffic = req.MinTrafficGB
updates["min_traffic_gb"] = *req.MinTrafficGB
}
if req.MaxTrafficGB != nil {
newMaxTraffic = req.MaxTrafficGB
updates["max_traffic_gb"] = *req.MaxTrafficGB
}
// 校验流量区间
if err := l.validateTrafficRange(int(req.Id), newMinTraffic, newMaxTraffic); err != nil {
return err
}
// 执行更新
if err := l.svcCtx.DB.Model(&nodeGroup).Updates(updates).Error; err != nil {
logger.Errorf("failed to update node group: %v", err)
return err
}
logger.Infof("updated node group: id=%d", req.Id)
return nil
}
// validateTrafficRange 校验流量区间:不能重叠、不能留空档、最小值不能大于最大值
func (l *UpdateNodeGroupLogic) validateTrafficRange(currentNodeGroupId int, newMin, newMax *int64) error {
// 处理指针值
minVal := int64(0)
maxVal := int64(0)
if newMin != nil {
minVal = *newMin
}
if newMax != nil {
maxVal = *newMax
}
// 检查最小值是否大于最大值
if minVal > maxVal {
return errors.New("minimum traffic cannot exceed maximum traffic")
}
// 如果两个值都为0,表示不参与流量分组,不需要校验
if minVal == 0 && maxVal == 0 {
return nil
}
// 查询所有其他设置了流量区间的节点组
var otherGroups []group.NodeGroup
if err := l.svcCtx.DB.
Where("id != ?", currentNodeGroupId).
Where("(min_traffic_gb > 0 OR max_traffic_gb > 0)").
Find(&otherGroups).Error; err != nil {
logger.Errorf("failed to query other node groups: %v", err)
return err
}
// 检查是否有重叠
for _, other := range otherGroups {
otherMin := int64(0)
otherMax := int64(0)
if other.MinTrafficGB != nil {
otherMin = *other.MinTrafficGB
}
if other.MaxTrafficGB != nil {
otherMax = *other.MaxTrafficGB
}
// 如果对方也没设置区间,跳过
if otherMin == 0 && otherMax == 0 {
continue
}
// 检查是否有重叠: 如果两个区间相交,就是重叠
// 不重叠的条件是: newMax <= otherMin OR newMin >= otherMax
if !(maxVal <= otherMin || minVal >= otherMax) {
return errors.New("traffic range overlaps with another node group")
}
}
return nil
}
@@ -29,13 +29,14 @@ func NewCreateNodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Create
func (l *CreateNodeLogic) CreateNode(req *types.CreateNodeRequest) error {
data := node.Node{
Name: req.Name,
Tags: tool.StringSliceToString(req.Tags),
Enabled: req.Enabled,
Port: req.Port,
Address: req.Address,
ServerId: req.ServerId,
Protocol: req.Protocol,
Name: req.Name,
Tags: tool.StringSliceToString(req.Tags),
Enabled: req.Enabled,
Port: req.Port,
Address: req.Address,
ServerId: req.ServerId,
Protocol: req.Protocol,
NodeGroupIds: node.JSONInt64Slice(req.NodeGroupIds),
}
err := l.svcCtx.NodeModel.InsertNode(l.ctx, &data)
if err != nil {
@@ -29,10 +29,17 @@ func NewFilterNodeListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Fi
}
func (l *FilterNodeListLogic) FilterNodeList(req *types.FilterNodeListRequest) (resp *types.FilterNodeListResponse, err error) {
// Convert NodeGroupId to []int64 for model
var nodeGroupIds []int64
if req.NodeGroupId != nil {
nodeGroupIds = []int64{*req.NodeGroupId}
}
total, data, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{
Page: req.Page,
Size: req.Size,
Search: req.Search,
Page: req.Page,
Size: req.Size,
Search: req.Search,
NodeGroupIds: nodeGroupIds,
})
if err != nil {
@@ -43,17 +50,18 @@ func (l *FilterNodeListLogic) FilterNodeList(req *types.FilterNodeListRequest) (
list := make([]types.Node, 0)
for _, datum := range data {
list = append(list, types.Node{
Id: datum.Id,
Name: datum.Name,
Tags: tool.RemoveDuplicateElements(strings.Split(datum.Tags, ",")...),
Port: datum.Port,
Address: datum.Address,
ServerId: datum.ServerId,
Protocol: datum.Protocol,
Enabled: datum.Enabled,
Sort: datum.Sort,
CreatedAt: datum.CreatedAt.UnixMilli(),
UpdatedAt: datum.UpdatedAt.UnixMilli(),
Id: datum.Id,
Name: datum.Name,
Tags: tool.RemoveDuplicateElements(strings.Split(datum.Tags, ",")...),
Port: datum.Port,
Address: datum.Address,
ServerId: datum.ServerId,
Protocol: datum.Protocol,
Enabled: datum.Enabled,
Sort: datum.Sort,
NodeGroupIds: []int64(datum.NodeGroupIds),
CreatedAt: datum.CreatedAt.UnixMilli(),
UpdatedAt: datum.UpdatedAt.UnixMilli(),
})
}
@@ -40,6 +40,7 @@ func (l *UpdateNodeLogic) UpdateNode(req *types.UpdateNodeRequest) error {
data.Address = req.Address
data.Protocol = req.Protocol
data.Enabled = req.Enabled
data.NodeGroupIds = node.JSONInt64Slice(req.NodeGroupIds)
err = l.svcCtx.NodeModel.UpdateNode(l.ctx, data)
if err != nil {
l.Errorw("[UpdateNode] Update Database Error: ", logger.Field("error", err.Error()))
@@ -50,6 +50,8 @@ func (l *CreateSubscribeLogic) CreateSubscribe(req *types.CreateSubscribeRequest
Quota: req.Quota,
Nodes: tool.Int64SliceToString(req.Nodes),
NodeTags: tool.StringSliceToString(req.NodeTags),
NodeGroupIds: subscribe.JSONInt64Slice(req.NodeGroupIds),
NodeGroupId: req.NodeGroupId,
Show: req.Show,
Sell: req.Sell,
Sort: 0,
@@ -30,12 +30,20 @@ func NewGetSubscribeListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *
}
func (l *GetSubscribeListLogic) GetSubscribeList(req *types.GetSubscribeListRequest) (resp *types.GetSubscribeListResponse, err error) {
total, list, err := l.svcCtx.SubscribeModel.FilterList(l.ctx, &subscribe.FilterParams{
// Build filter params
filterParams := &subscribe.FilterParams{
Page: int(req.Page),
Size: int(req.Size),
Language: req.Language,
Search: req.Search,
})
}
// Add NodeGroupId filter if provided
if req.NodeGroupId > 0 {
filterParams.NodeGroupId = &req.NodeGroupId
}
total, list, err := l.svcCtx.SubscribeModel.FilterList(l.ctx, filterParams)
if err != nil {
l.Logger.Error("[GetSubscribeListLogic] get subscribe list failed: ", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get subscribe list failed: %v", err.Error())
@@ -56,6 +64,14 @@ func (l *GetSubscribeListLogic) GetSubscribeList(req *types.GetSubscribeListRequ
}
sub.Nodes = tool.StringToInt64Slice(item.Nodes)
sub.NodeTags = strings.Split(item.NodeTags, ",")
// Handle NodeGroupIds - convert from JSONInt64Slice to []int64
if item.NodeGroupIds != nil {
sub.NodeGroupIds = []int64(item.NodeGroupIds)
} else {
sub.NodeGroupIds = []int64{}
}
// NodeGroupId is already int64, should be copied by DeepCopy
sub.NodeGroupId = item.NodeGroupId
resultList = append(resultList, sub)
}
@@ -58,6 +58,8 @@ func (l *UpdateSubscribeLogic) UpdateSubscribe(req *types.UpdateSubscribeRequest
Quota: req.Quota,
Nodes: tool.Int64SliceToString(req.Nodes),
NodeTags: tool.StringSliceToString(req.NodeTags),
NodeGroupIds: subscribe.JSONInt64Slice(req.NodeGroupIds),
NodeGroupId: req.NodeGroupId,
Show: req.Show,
Sell: req.Sell,
Sort: req.Sort,
@@ -6,6 +6,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/perfect-panel/server/internal/logic/admin/group"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
@@ -64,6 +65,7 @@ func (l *CreateUserSubscribeLogic) CreateUserSubscribe(req *types.CreateUserSubs
Upload: 0,
Token: uuidx.SubscribeToken(fmt.Sprintf("adminCreate:%d", time.Now().UnixMilli())),
UUID: uuid.New().String(),
NodeGroupId: sub.NodeGroupId,
Status: 1,
}
if err = l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub); err != nil {
@@ -71,6 +73,60 @@ func (l *CreateUserSubscribeLogic) CreateUserSubscribe(req *types.CreateUserSubs
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "InsertSubscribe error: %v", err.Error())
}
// Trigger user group recalculation (runs in background)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Check if group management is enabled
var groupEnabled string
err := l.svcCtx.DB.Table("system").
Where("`category` = ? AND `key` = ?", "group", "enabled").
Select("value").
Scan(&groupEnabled).Error
if err != nil || groupEnabled != "true" && groupEnabled != "1" {
l.Debugf("Group management not enabled, skipping recalculation")
return
}
// Get the configured grouping mode
var groupMode string
err = l.svcCtx.DB.Table("system").
Where("`category` = ? AND `key` = ?", "group", "mode").
Select("value").
Scan(&groupMode).Error
if err != nil {
l.Errorw("Failed to get group mode", logger.Field("error", err.Error()))
return
}
// Validate group mode
if groupMode != "average" && groupMode != "subscribe" && groupMode != "traffic" {
l.Debugf("Invalid group mode (current: %s), skipping", groupMode)
return
}
// Trigger group recalculation with the configured mode
logic := group.NewRecalculateGroupLogic(ctx, l.svcCtx)
req := &types.RecalculateGroupRequest{
Mode: groupMode,
}
if err := logic.RecalculateGroup(req); err != nil {
l.Errorw("Failed to recalculate user group",
logger.Field("user_id", userInfo.Id),
logger.Field("error", err.Error()),
)
return
}
l.Infow("Successfully recalculated user group after admin created subscription",
logger.Field("user_id", userInfo.Id),
logger.Field("subscribe_id", userSub.Id),
logger.Field("mode", groupMode),
)
}()
err = l.svcCtx.UserModel.UpdateUserCache(l.ctx, userInfo)
if err != nil {
l.Errorw("UpdateUserCache error", logger.Field("error", err.Error()))
@@ -81,5 +137,6 @@ func (l *CreateUserSubscribeLogic) CreateUserSubscribe(req *types.CreateUserSubs
if err != nil {
logger.Errorw("ClearSubscribe error", logger.Field("error", err.Error()))
}
return nil
}
@@ -120,7 +120,31 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
}
userInfo.Commission = req.Commission
}
tool.DeepCopy(userInfo, req)
// 只更新指定的字段,不使用 DeepCopy 避免零值覆盖
// 处理头像(只在提供时更新)
if req.Avatar != "" {
userInfo.Avatar = req.Avatar
}
// 处理推荐码(只在提供时更新)
if req.ReferCode != "" {
userInfo.ReferCode = req.ReferCode
}
// 处理推荐人ID(只在非零时更新)
if req.RefererId != 0 {
userInfo.RefererId = req.RefererId
}
// 处理启用状态(始终更新)
userInfo.Enable = &req.Enable
// 处理管理员状态(始终更新)
userInfo.IsAdmin = &req.IsAdmin
// 更新其他字段(只有在明确提供时才更新)
userInfo.OnlyFirstPurchase = &req.OnlyFirstPurchase
userInfo.ReferralPercentage = req.ReferralPercentage
@@ -53,6 +53,7 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs
Token: userSub.Token,
UUID: userSub.UUID,
Status: userSub.Status,
NodeGroupId: userSub.NodeGroupId,
})
if err != nil {
@@ -74,5 +75,6 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs
l.Errorf("ClearServerAllCache error: %v", err.Error())
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "failed to clear server cache: %v", err.Error())
}
return nil
}