- 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
467 lines
14 KiB
Go
467 lines
14 KiB
Go
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_id:user_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
|
||
}
|