hi-server/internal/logic/admin/group/previewUserNodesLogic.go
EUForest 39310d5b9a 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
2026-03-08 23:22:38 +08:00

467 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}