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
+4
View File
@@ -3,6 +3,7 @@ package handler
import (
"github.com/hibiken/asynq"
"github.com/perfect-panel/server/internal/svc"
groupLogic "github.com/perfect-panel/server/queue/logic/group"
orderLogic "github.com/perfect-panel/server/queue/logic/order"
smslogic "github.com/perfect-panel/server/queue/logic/sms"
"github.com/perfect-panel/server/queue/logic/subscription"
@@ -43,4 +44,7 @@ func RegisterHandlers(mux *asynq.ServeMux, serverCtx *svc.ServiceContext) {
// ForthwithQuotaTask
mux.Handle(types.ForthwithQuotaTask, task.NewQuotaTaskLogic(serverCtx))
// SchedulerRecalculateGroup
mux.Handle(types.SchedulerRecalculateGroup, groupLogic.NewRecalculateGroupLogic(serverCtx))
}
@@ -0,0 +1,87 @@
package group
import (
"context"
"time"
"github.com/perfect-panel/server/internal/logic/admin/group"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/hibiken/asynq"
)
type RecalculateGroupLogic struct {
svc *svc.ServiceContext
}
func NewRecalculateGroupLogic(svc *svc.ServiceContext) *RecalculateGroupLogic {
return &RecalculateGroupLogic{
svc: svc,
}
}
func (l *RecalculateGroupLogic) ProcessTask(ctx context.Context, t *asynq.Task) error {
logger.Infof("[RecalculateGroup] Starting scheduled group recalculation: %s", time.Now().Format("2006-01-02 15:04:05"))
// 1. Check if group management is enabled
var enabledConfig struct {
Value string `gorm:"column:value"`
}
err := l.svc.DB.Table("system").
Where("`category` = ? AND `key` = ?", "group", "enabled").
Select("value").
First(&enabledConfig).Error
if err != nil {
logger.Errorw("[RecalculateGroup] Failed to read group enabled config", logger.Field("error", err.Error()))
return err
}
// If not enabled, skip execution
if enabledConfig.Value != "true" && enabledConfig.Value != "1" {
logger.Debugf("[RecalculateGroup] Group management is not enabled, skipping")
return nil
}
// 2. Get grouping mode
var modeConfig struct {
Value string `gorm:"column:value"`
}
err = l.svc.DB.Table("system").
Where("`category` = ? AND `key` = ?", "group", "mode").
Select("value").
First(&modeConfig).Error
if err != nil {
logger.Errorw("[RecalculateGroup] Failed to read group mode config", logger.Field("error", err.Error()))
return err
}
mode := modeConfig.Value
if mode == "" {
mode = "average" // default mode
}
// 3. Only execute if mode is "traffic"
if mode != "traffic" {
logger.Debugf("[RecalculateGroup] Group mode is not 'traffic' (current: %s), skipping", mode)
return nil
}
// 4. Execute traffic-based grouping
logger.Infof("[RecalculateGroup] Executing traffic-based grouping")
logic := group.NewRecalculateGroupLogic(ctx, l.svc)
req := &types.RecalculateGroupRequest{
Mode: "traffic",
TriggerType: "scheduled",
}
if err := logic.RecalculateGroup(req); err != nil {
logger.Errorw("[RecalculateGroup] Failed to execute traffic grouping", logger.Field("error", err.Error()))
return err
}
logger.Infof("[RecalculateGroup] Successfully completed traffic-based grouping: %s", time.Now().Format("2006-01-02 15:04:05"))
return nil
}
+71 -3
View File
@@ -9,6 +9,7 @@ import (
"strconv"
"time"
"github.com/perfect-panel/server/internal/logic/admin/group"
"github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/logger"
@@ -22,9 +23,10 @@ import (
"github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/uuidx"
"github.com/perfect-panel/server/queue/types"
queueTypes "github.com/perfect-panel/server/queue/types"
"gorm.io/gorm"
)
@@ -93,8 +95,8 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
}
// parsePayload unMarshals the task payload into a structured format
func (l *ActivateOrderLogic) parsePayload(ctx context.Context, payload []byte) (*types.ForthwithActivateOrderPayload, error) {
var p types.ForthwithActivateOrderPayload
func (l *ActivateOrderLogic) parsePayload(ctx context.Context, payload []byte) (*queueTypes.ForthwithActivateOrderPayload, error) {
var p queueTypes.ForthwithActivateOrderPayload
if err := json.Unmarshal(payload, &p); err != nil {
logger.WithContext(ctx).Error("[ActivateOrderLogic] Unmarshal payload failed",
logger.Field("error", err.Error()),
@@ -196,6 +198,9 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
return err
}
// Trigger user group recalculation (runs in background)
l.triggerUserGroupRecalculation(ctx, userInfo.Id)
// Handle commission in separate goroutine to avoid blocking
go l.handleCommission(context.Background(), userInfo, orderInfo)
@@ -357,6 +362,7 @@ func (l *ActivateOrderLogic) createUserSubscription(ctx context.Context, orderIn
Token: uuidx.SubscribeToken(orderInfo.OrderNo),
UUID: uuid.New().String(),
Status: 1,
NodeGroupId: sub.NodeGroupId, // Inherit node_group_id from subscription plan
}
// Check quota limit before creating subscription (final safeguard)
@@ -505,6 +511,63 @@ func (l *ActivateOrderLogic) clearServerCache(ctx context.Context, sub *subscrib
}
}
// triggerUserGroupRecalculation triggers user group recalculation after subscription changes
// This runs asynchronously in background to avoid blocking the main order processing flow
func (l *ActivateOrderLogic) triggerUserGroupRecalculation(ctx context.Context, userId int64) {
go func() {
// Use a new context with timeout for group recalculation
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Check if group management is enabled
var groupEnabled string
err := l.svc.DB.Table("system").
Where("`category` = ? AND `key` = ?", "group", "enabled").
Select("value").
Scan(&groupEnabled).Error
if err != nil || groupEnabled != "true" && groupEnabled != "1" {
logger.Debugf("[Group Trigger] Group management not enabled, skipping recalculation")
return
}
// Get the configured grouping mode
var groupMode string
err = l.svc.DB.Table("system").
Where("`category` = ? AND `key` = ?", "group", "mode").
Select("value").
Scan(&groupMode).Error
if err != nil {
logger.Errorw("[Group Trigger] Failed to get group mode", logger.Field("error", err.Error()))
return
}
// Validate group mode
if groupMode != "average" && groupMode != "subscribe" && groupMode != "traffic" {
logger.Debugf("[Group Trigger] Invalid group mode (current: %s), skipping", groupMode)
return
}
// Trigger group recalculation with the configured mode
logic := group.NewRecalculateGroupLogic(ctx, l.svc)
req := &types.RecalculateGroupRequest{
Mode: groupMode,
}
if err := logic.RecalculateGroup(req); err != nil {
logger.Errorw("[Group Trigger] Failed to recalculate user group",
logger.Field("user_id", userId),
logger.Field("error", err.Error()),
)
return
}
logger.Infow("[Group Trigger] Successfully recalculated user group",
logger.Field("user_id", userId),
logger.Field("mode", groupMode),
)
}()
}
// Renewal handles subscription renewal including subscription extension,
// traffic reset (if configured), commission processing, and notifications
func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order) error {
@@ -898,6 +961,7 @@ func (l *ActivateOrderLogic) RedemptionActivate(ctx context.Context, orderInfo *
Traffic: us.Traffic,
Download: us.Download,
Upload: us.Upload,
NodeGroupId: us.NodeGroupId,
}
break
}
@@ -984,6 +1048,7 @@ func (l *ActivateOrderLogic) RedemptionActivate(ctx context.Context, orderInfo *
Token: uuidx.SubscribeToken(orderInfo.OrderNo),
UUID: uuid.New().String(),
Status: 1,
NodeGroupId: sub.NodeGroupId, // Inherit node_group_id from subscription plan
}
err = l.svc.UserModel.InsertSubscribe(ctx, newSubscribe, tx)
@@ -1030,6 +1095,9 @@ func (l *ActivateOrderLogic) RedemptionActivate(ctx context.Context, orderInfo *
return err
}
// Trigger user group recalculation (runs in background)
l.triggerUserGroupRecalculation(ctx, userInfo.Id)
// 7. 清理缓存(关键步骤:让节点获取最新订阅)
l.clearServerCache(ctx, sub)
@@ -62,7 +62,6 @@ func (l *CheckSubscriptionLogic) ProcessTask(ctx context.Context, _ *asynq.Task)
}
l.clearServerCache(ctx, list...)
logger.Infow("[Check Subscription Traffic] Update subscribe status", logger.Field("user_ids", ids), logger.Field("count", int64(len(ids))))
} else {
logger.Info("[Check Subscription Traffic] No subscribe need to update")
}
@@ -108,6 +107,7 @@ func (l *CheckSubscriptionLogic) ProcessTask(ctx context.Context, _ *asynq.Task)
} else {
logger.Info("[Check Subscription Expire] No subscribe need to update")
}
return nil
})
if err != nil {
+1
View File
@@ -5,4 +5,5 @@ const (
SchedulerTotalServerData = "scheduler:total:server"
SchedulerResetTraffic = "scheduler:reset:traffic"
SchedulerTrafficStat = "scheduler:traffic:stat"
SchedulerRecalculateGroup = "scheduler:recalculate:group"
)