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:
parent
7d46b31866
commit
39310d5b9a
207
apis/admin/group.api
Normal file
207
apis/admin/group.api
Normal file
@ -0,0 +1,207 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "Group API"
|
||||
desc: "API for user group and node group management"
|
||||
author: "Tension"
|
||||
email: "tension@ppanel.com"
|
||||
version: "0.0.1"
|
||||
)
|
||||
|
||||
import (
|
||||
"../types.api"
|
||||
"./server.api"
|
||||
)
|
||||
|
||||
type (
|
||||
// ===== 节点组管理 =====
|
||||
// GetNodeGroupListRequest
|
||||
GetNodeGroupListRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
GroupId string `form:"group_id,omitempty"`
|
||||
}
|
||||
// GetNodeGroupListResponse
|
||||
GetNodeGroupListResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []NodeGroup `json:"list"`
|
||||
}
|
||||
// CreateNodeGroupRequest
|
||||
CreateNodeGroupRequest {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Description string `json:"description"`
|
||||
Sort int `json:"sort"`
|
||||
ForCalculation *bool `json:"for_calculation"`
|
||||
MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"`
|
||||
MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"`
|
||||
}
|
||||
// UpdateNodeGroupRequest
|
||||
UpdateNodeGroupRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Sort int `json:"sort"`
|
||||
ForCalculation *bool `json:"for_calculation"`
|
||||
MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"`
|
||||
MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"`
|
||||
}
|
||||
// DeleteNodeGroupRequest
|
||||
DeleteNodeGroupRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
}
|
||||
// ===== 分组配置管理 =====
|
||||
// GetGroupConfigRequest
|
||||
GetGroupConfigRequest {
|
||||
Keys []string `form:"keys,omitempty"`
|
||||
}
|
||||
// GetGroupConfigResponse
|
||||
GetGroupConfigResponse {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
State RecalculationState `json:"state"`
|
||||
}
|
||||
// UpdateGroupConfigRequest
|
||||
UpdateGroupConfigRequest {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
// RecalculationState
|
||||
RecalculationState {
|
||||
State string `json:"state"`
|
||||
Progress int `json:"progress"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
// ===== 分组操作 =====
|
||||
// RecalculateGroupRequest
|
||||
RecalculateGroupRequest {
|
||||
Mode string `json:"mode" validate:"required"`
|
||||
TriggerType string `json:"trigger_type"` // "manual" or "scheduled"
|
||||
}
|
||||
// GetGroupHistoryRequest
|
||||
GetGroupHistoryRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
GroupMode string `form:"group_mode,omitempty"`
|
||||
TriggerType string `form:"trigger_type,omitempty"`
|
||||
}
|
||||
// GetGroupHistoryResponse
|
||||
GetGroupHistoryResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []GroupHistory `json:"list"`
|
||||
}
|
||||
// GetGroupHistoryDetailRequest
|
||||
GetGroupHistoryDetailRequest {
|
||||
Id int64 `form:"id" validate:"required"`
|
||||
}
|
||||
// GetGroupHistoryDetailResponse
|
||||
GetGroupHistoryDetailResponse {
|
||||
GroupHistoryDetail
|
||||
}
|
||||
// PreviewUserNodesRequest
|
||||
PreviewUserNodesRequest {
|
||||
UserId int64 `form:"user_id" validate:"required"`
|
||||
}
|
||||
// PreviewUserNodesResponse
|
||||
PreviewUserNodesResponse {
|
||||
UserId int64 `json:"user_id"`
|
||||
NodeGroups []NodeGroupItem `json:"node_groups"`
|
||||
}
|
||||
// NodeGroupItem
|
||||
NodeGroupItem {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Nodes []Node `json:"nodes"`
|
||||
}
|
||||
// ExportGroupResultRequest
|
||||
ExportGroupResultRequest {
|
||||
HistoryId *int64 `form:"history_id,omitempty"`
|
||||
}
|
||||
// ===== Reset Groups =====
|
||||
// ResetGroupsRequest
|
||||
ResetGroupsRequest {
|
||||
Confirm bool `json:"confirm" validate:"required"`
|
||||
}
|
||||
// ===== 套餐分组映射 =====
|
||||
// SubscribeGroupMappingItem
|
||||
SubscribeGroupMappingItem {
|
||||
SubscribeName string `json:"subscribe_name"`
|
||||
NodeGroupName string `json:"node_group_name"`
|
||||
}
|
||||
// GetSubscribeGroupMappingRequest
|
||||
GetSubscribeGroupMappingRequest {}
|
||||
// GetSubscribeGroupMappingResponse
|
||||
GetSubscribeGroupMappingResponse {
|
||||
List []SubscribeGroupMappingItem `json:"list"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
prefix: v1/admin/group
|
||||
group: admin/group
|
||||
jwt: JwtAuth
|
||||
middleware: AuthMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
// ===== 节点组管理 =====
|
||||
@doc "Get node group list"
|
||||
@handler GetNodeGroupList
|
||||
get /node/list (GetNodeGroupListRequest) returns (GetNodeGroupListResponse)
|
||||
|
||||
@doc "Create node group"
|
||||
@handler CreateNodeGroup
|
||||
post /node (CreateNodeGroupRequest)
|
||||
|
||||
@doc "Update node group"
|
||||
@handler UpdateNodeGroup
|
||||
put /node (UpdateNodeGroupRequest)
|
||||
|
||||
@doc "Delete node group"
|
||||
@handler DeleteNodeGroup
|
||||
delete /node (DeleteNodeGroupRequest)
|
||||
|
||||
// ===== 分组配置管理 =====
|
||||
@doc "Get group config"
|
||||
@handler GetGroupConfig
|
||||
get /config (GetGroupConfigRequest) returns (GetGroupConfigResponse)
|
||||
|
||||
@doc "Update group config"
|
||||
@handler UpdateGroupConfig
|
||||
put /config (UpdateGroupConfigRequest)
|
||||
|
||||
// ===== 分组操作 =====
|
||||
@doc "Recalculate group"
|
||||
@handler RecalculateGroup
|
||||
post /recalculate (RecalculateGroupRequest)
|
||||
|
||||
@doc "Get recalculation status"
|
||||
@handler GetRecalculationStatus
|
||||
get /recalculation/status returns (RecalculationState)
|
||||
|
||||
@doc "Get group history"
|
||||
@handler GetGroupHistory
|
||||
get /history (GetGroupHistoryRequest) returns (GetGroupHistoryResponse)
|
||||
|
||||
@doc "Export group result"
|
||||
@handler ExportGroupResult
|
||||
get /export (ExportGroupResultRequest)
|
||||
|
||||
// Routes with query parameters
|
||||
@doc "Get group history detail"
|
||||
@handler GetGroupHistoryDetail
|
||||
get /history/detail (GetGroupHistoryDetailRequest) returns (GetGroupHistoryDetailResponse)
|
||||
|
||||
@doc "Preview user nodes"
|
||||
@handler PreviewUserNodes
|
||||
get /preview (PreviewUserNodesRequest) returns (PreviewUserNodesResponse)
|
||||
|
||||
@doc "Reset all groups"
|
||||
@handler ResetGroups
|
||||
post /reset (ResetGroupsRequest)
|
||||
|
||||
@doc "Get subscribe group mapping"
|
||||
@handler GetSubscribeGroupMapping
|
||||
get /subscribe/mapping (GetSubscribeGroupMappingRequest) returns (GetSubscribeGroupMappingResponse)
|
||||
}
|
||||
|
||||
@ -93,3 +93,4 @@ service ppanel {
|
||||
@handler GetRedemptionRecordList
|
||||
get /record/list (GetRedemptionRecordListRequest) returns (GetRedemptionRecordListResponse)
|
||||
}
|
||||
|
||||
|
||||
@ -89,6 +89,8 @@ type (
|
||||
Protocol string `json:"protocol"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Sort int `json:"sort,omitempty"`
|
||||
NodeGroupId int64 `json:"node_group_id,omitempty"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
@ -100,6 +102,7 @@ type (
|
||||
ServerId int64 `json:"server_id"`
|
||||
Protocol string `json:"protocol"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
}
|
||||
UpdateNodeRequest {
|
||||
Id int64 `json:"id"`
|
||||
@ -110,6 +113,7 @@ type (
|
||||
ServerId int64 `json:"server_id"`
|
||||
Protocol string `json:"protocol"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
}
|
||||
ToggleNodeStatusRequest {
|
||||
Id int64 `json:"id"`
|
||||
@ -122,6 +126,7 @@ type (
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
Search string `form:"search,omitempty"`
|
||||
NodeGroupId *int64 `form:"node_group_id,omitempty"`
|
||||
}
|
||||
FilterNodeListResponse {
|
||||
Total int64 `json:"total"`
|
||||
|
||||
@ -48,6 +48,8 @@ type (
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
NodeGroupId int64 `json:"node_group_id"`
|
||||
Show *bool `json:"show"`
|
||||
Sell *bool `json:"sell"`
|
||||
DeductionRatio int64 `json:"deduction_ratio"`
|
||||
@ -55,6 +57,7 @@ type (
|
||||
ResetCycle int64 `json:"reset_cycle"`
|
||||
RenewalReset *bool `json:"renewal_reset"`
|
||||
ShowOriginalPrice bool `json:"show_original_price"`
|
||||
AutoCreateGroup bool `json:"auto_create_group"`
|
||||
}
|
||||
UpdateSubscribeRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
@ -72,6 +75,8 @@ type (
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
NodeGroupId int64 `json:"node_group_id"`
|
||||
Show *bool `json:"show"`
|
||||
Sell *bool `json:"sell"`
|
||||
Sort int64 `json:"sort"`
|
||||
@ -89,6 +94,7 @@ type (
|
||||
Size int64 `form:"size" validate:"required"`
|
||||
Language string `form:"language,omitempty"`
|
||||
Search string `form:"search,omitempty"`
|
||||
NodeGroupId int64 `form:"node_group_id,omitempty"`
|
||||
}
|
||||
SubscribeItem {
|
||||
Subscribe
|
||||
|
||||
@ -78,6 +78,8 @@ type (
|
||||
OrderId int64 `json:"order_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Subscribe Subscribe `json:"subscribe"`
|
||||
NodeGroupId int64 `json:"node_group_id"`
|
||||
GroupLocked bool `json:"group_locked"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
ResetTime int64 `json:"reset_time"`
|
||||
|
||||
@ -30,3 +30,4 @@ service ppanel {
|
||||
@handler RedeemCode
|
||||
post / (RedeemCodeRequest) returns (RedeemCodeResponse)
|
||||
}
|
||||
|
||||
|
||||
@ -14,11 +14,9 @@ type (
|
||||
QuerySubscribeListRequest {
|
||||
Language string `form:"language"`
|
||||
}
|
||||
|
||||
QueryUserSubscribeNodeListResponse {
|
||||
List []UserSubscribeInfo `json:"list"`
|
||||
}
|
||||
|
||||
UserSubscribeInfo {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
@ -38,7 +36,6 @@ type (
|
||||
IsTryOut bool `json:"is_try_out"`
|
||||
Nodes []*UserSubscribeNodeInfo `json:"nodes"`
|
||||
}
|
||||
|
||||
UserSubscribeNodeInfo {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
@ -66,7 +66,6 @@ type (
|
||||
UnbindOAuthRequest {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
GetLoginLogRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
@ -95,21 +94,17 @@ type (
|
||||
Email string `json:"email" validate:"required"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
|
||||
GetDeviceListResponse {
|
||||
List []UserDevice `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
UnbindDeviceRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
}
|
||||
|
||||
UpdateUserSubscribeNoteRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id" validate:"required"`
|
||||
Note string `json:"note" validate:"max=500"`
|
||||
}
|
||||
|
||||
UpdateUserRulesRequest {
|
||||
Rules []string `json:"rules" validate:"required"`
|
||||
}
|
||||
@ -135,13 +130,10 @@ type (
|
||||
List []WithdrawalLog `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
|
||||
GetDeviceOnlineStatsResponse {
|
||||
WeeklyStats []WeeklyStat `json:"weekly_stats"`
|
||||
ConnectionRecords ConnectionRecords `json:"connection_records"`
|
||||
}
|
||||
|
||||
WeeklyStat {
|
||||
Day int `json:"day"`
|
||||
DayName string `json:"day_name"`
|
||||
@ -279,16 +271,16 @@ service ppanel {
|
||||
@doc "Delete Current User Account"
|
||||
@handler DeleteCurrentUserAccount
|
||||
delete /current_user_account
|
||||
|
||||
}
|
||||
|
||||
@server (
|
||||
prefix: v1/public/user
|
||||
group: public/user/ws
|
||||
middleware: AuthMiddleware
|
||||
)
|
||||
|
||||
service ppanel {
|
||||
@doc "Webosocket Device Connect"
|
||||
@handler DeviceWsConnect
|
||||
get /device_ws_connect
|
||||
}
|
||||
|
||||
|
||||
@ -225,6 +225,8 @@ type (
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
NodeGroupId int64 `json:"node_group_id"`
|
||||
Show bool `json:"show"`
|
||||
Sell bool `json:"sell"`
|
||||
Sort int64 `json:"sort"`
|
||||
@ -486,6 +488,8 @@ type (
|
||||
OrderId int64 `json:"order_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Subscribe Subscribe `json:"subscribe"`
|
||||
NodeGroupId int64 `json:"node_group_id"`
|
||||
GroupLocked bool `json:"group_locked"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
FinishedAt int64 `json:"finished_at"`
|
||||
@ -697,7 +701,6 @@ type (
|
||||
List []SubscribeGroup `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
GetUserSubscribeTrafficLogsRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
@ -874,5 +877,38 @@ type (
|
||||
ResetUserSubscribeTokenRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
}
|
||||
// ===== 分组功能类型定义 =====
|
||||
// NodeGroup 节点组
|
||||
NodeGroup {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Sort int `json:"sort"`
|
||||
ForCalculation bool `json:"for_calculation"`
|
||||
MinTrafficGB int64 `json:"min_traffic_gb,omitempty"`
|
||||
MaxTrafficGB int64 `json:"max_traffic_gb,omitempty"`
|
||||
NodeCount int64 `json:"node_count,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
// GroupHistory 分组历史记录
|
||||
GroupHistory {
|
||||
Id int64 `json:"id"`
|
||||
GroupMode string `json:"group_mode"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
TotalUsers int `json:"total_users"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
StartTime *int64 `json:"start_time,omitempty"`
|
||||
EndTime *int64 `json:"end_time,omitempty"`
|
||||
Operator string `json:"operator,omitempty"`
|
||||
ErrorLog string `json:"error_log,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
// GroupHistoryDetail 分组历史详情
|
||||
GroupHistoryDetail {
|
||||
GroupHistory
|
||||
ConfigSnapshot map[string]interface{} `json:"config_snapshot,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
28
initialize/migrate/database/02131_add_groups.down.sql
Normal file
28
initialize/migrate/database/02131_add_groups.down.sql
Normal file
@ -0,0 +1,28 @@
|
||||
-- Purpose: Rollback node group management tables
|
||||
-- Author: Tension
|
||||
-- Date: 2025-02-23
|
||||
-- Updated: 2025-03-06
|
||||
|
||||
-- ===== Remove system configuration entries =====
|
||||
DELETE FROM `system` WHERE `category` = 'group' AND `key` IN ('enabled', 'mode', 'auto_create_group');
|
||||
|
||||
-- ===== Remove columns and indexes from subscribe table =====
|
||||
ALTER TABLE `subscribe` DROP INDEX IF EXISTS `idx_node_group_id`;
|
||||
ALTER TABLE `subscribe` DROP COLUMN IF EXISTS `node_group_id`;
|
||||
ALTER TABLE `subscribe` DROP COLUMN IF EXISTS `node_group_ids`;
|
||||
|
||||
-- ===== Remove columns and indexes from user_subscribe table =====
|
||||
ALTER TABLE `user_subscribe` DROP INDEX IF EXISTS `idx_node_group_id`;
|
||||
ALTER TABLE `user_subscribe` DROP COLUMN IF EXISTS `node_group_id`;
|
||||
|
||||
-- ===== Remove columns and indexes from nodes table =====
|
||||
ALTER TABLE `nodes` DROP COLUMN IF EXISTS `node_group_ids`;
|
||||
|
||||
-- ===== Drop group_history_detail table =====
|
||||
DROP TABLE IF EXISTS `group_history_detail`;
|
||||
|
||||
-- ===== Drop group_history table =====
|
||||
DROP TABLE IF EXISTS `group_history`;
|
||||
|
||||
-- ===== Drop node_group table =====
|
||||
DROP TABLE IF EXISTS `node_group`;
|
||||
130
initialize/migrate/database/02131_add_groups.up.sql
Normal file
130
initialize/migrate/database/02131_add_groups.up.sql
Normal file
@ -0,0 +1,130 @@
|
||||
-- Purpose: Add node group management tables with multi-group support
|
||||
-- Author: Tension
|
||||
-- Date: 2025-02-23
|
||||
-- Updated: 2025-03-06
|
||||
|
||||
-- ===== Create node_group table =====
|
||||
DROP TABLE IF EXISTS `node_group`;
|
||||
CREATE TABLE IF NOT EXISTS `node_group` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Name',
|
||||
`description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Group Description',
|
||||
`sort` int NOT NULL DEFAULT '0' COMMENT 'Sort Order',
|
||||
`for_calculation` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'For Grouping Calculation: 0=false, 1=true',
|
||||
`min_traffic_gb` bigint DEFAULT 0 COMMENT 'Minimum Traffic (GB) for this node group',
|
||||
`max_traffic_gb` bigint DEFAULT 0 COMMENT 'Maximum Traffic (GB) for this node group',
|
||||
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
|
||||
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sort` (`sort`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Node Groups';
|
||||
|
||||
-- ===== Create group_history table =====
|
||||
DROP TABLE IF EXISTS `group_history`;
|
||||
CREATE TABLE IF NOT EXISTS `group_history` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`group_mode` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Group Mode: average/subscribe/traffic',
|
||||
`trigger_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Trigger Type: manual/auto/schedule',
|
||||
`state` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'State: pending/running/completed/failed',
|
||||
`total_users` int NOT NULL DEFAULT '0' COMMENT 'Total Users',
|
||||
`success_count` int NOT NULL DEFAULT '0' COMMENT 'Success Count',
|
||||
`failed_count` int NOT NULL DEFAULT '0' COMMENT 'Failed Count',
|
||||
`start_time` datetime(3) DEFAULT NULL COMMENT 'Start Time',
|
||||
`end_time` datetime(3) DEFAULT NULL COMMENT 'End Time',
|
||||
`operator` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Operator',
|
||||
`error_message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Error Message',
|
||||
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_group_mode` (`group_mode`),
|
||||
KEY `idx_trigger_type` (`trigger_type`),
|
||||
KEY `idx_state` (`state`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Group Calculation History';
|
||||
|
||||
-- ===== Create group_history_detail table =====
|
||||
-- Note: user_group_id column removed, using user_data JSON field instead
|
||||
DROP TABLE IF EXISTS `group_history_detail`;
|
||||
CREATE TABLE IF NOT EXISTS `group_history_detail` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`history_id` bigint NOT NULL COMMENT 'History ID',
|
||||
`node_group_id` bigint NOT NULL COMMENT 'Node Group ID',
|
||||
`user_count` int NOT NULL DEFAULT '0' COMMENT 'User Count',
|
||||
`node_count` int NOT NULL DEFAULT '0' COMMENT 'Node Count',
|
||||
`user_data` TEXT COMMENT 'User data JSON (id and email/phone)',
|
||||
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_history_id` (`history_id`),
|
||||
KEY `idx_node_group_id` (`node_group_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Group History Details';
|
||||
|
||||
-- ===== Add columns to nodes table =====
|
||||
SET @column_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'nodes' AND COLUMN_NAME = 'node_group_ids');
|
||||
SET @sql = IF(@column_exists = 0,
|
||||
'ALTER TABLE `nodes` ADD COLUMN `node_group_ids` JSON COMMENT ''Node Group IDs (JSON array, multiple groups)''',
|
||||
'SELECT ''Column node_group_ids already exists''');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ===== Add node_group_id column to user_subscribe table =====
|
||||
SET @column_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_subscribe' AND COLUMN_NAME = 'node_group_id');
|
||||
SET @sql = IF(@column_exists = 0,
|
||||
'ALTER TABLE `user_subscribe` ADD COLUMN `node_group_id` bigint NOT NULL DEFAULT 0 COMMENT ''Node Group ID (single ID)''',
|
||||
'SELECT ''Column node_group_id already exists''');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ===== Add index for user_subscribe.node_group_id =====
|
||||
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_subscribe' AND INDEX_NAME = 'idx_node_group_id');
|
||||
SET @sql = IF(@index_exists = 0,
|
||||
'ALTER TABLE `user_subscribe` ADD INDEX `idx_node_group_id` (`node_group_id`)',
|
||||
'SELECT ''Index idx_node_group_id already exists''');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ===== Add group_locked column to user_subscribe table =====
|
||||
SET @column_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_subscribe' AND COLUMN_NAME = 'group_locked');
|
||||
SET @sql = IF(@column_exists = 0,
|
||||
'ALTER TABLE `user_subscribe` ADD COLUMN `group_locked` tinyint(1) NOT NULL DEFAULT 0 COMMENT ''Group Locked''',
|
||||
'SELECT ''Column group_locked already exists in user_subscribe table''');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ===== Add columns to subscribe table =====
|
||||
SET @column_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'subscribe' AND COLUMN_NAME = 'node_group_ids');
|
||||
SET @sql = IF(@column_exists = 0,
|
||||
'ALTER TABLE `subscribe` ADD COLUMN `node_group_ids` JSON COMMENT ''Node Group IDs (JSON array, multiple groups)''',
|
||||
'SELECT ''Column node_group_ids already exists''');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ===== Add default node_group_id column to subscribe table =====
|
||||
SET @column_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'subscribe' AND COLUMN_NAME = 'node_group_id');
|
||||
SET @sql = IF(@column_exists = 0,
|
||||
'ALTER TABLE `subscribe` ADD COLUMN `node_group_id` bigint NOT NULL DEFAULT 0 COMMENT ''Default Node Group ID (single ID)''',
|
||||
'SELECT ''Column node_group_id already exists in subscribe table''');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ===== Add index for subscribe.node_group_id =====
|
||||
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'subscribe' AND INDEX_NAME = 'idx_node_group_id');
|
||||
SET @sql = IF(@index_exists = 0,
|
||||
'ALTER TABLE `subscribe` ADD INDEX `idx_node_group_id` (`node_group_id`)',
|
||||
'SELECT ''Index idx_node_group_id already exists in subscribe table''');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ===== Insert system configuration entries =====
|
||||
INSERT INTO `system` (`category`, `key`, `value`, `desc`) VALUES
|
||||
('group', 'enabled', 'false', 'Group Management Enabled'),
|
||||
('group', 'mode', 'average', 'Group Mode: average/subscribe/traffic'),
|
||||
('group', 'auto_create_group', 'false', 'Auto-create user group when creating subscribe product')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`value` = VALUES(`value`),
|
||||
`desc` = VALUES(`desc`);
|
||||
26
internal/handler/admin/group/createNodeGroupHandler.go
Normal file
26
internal/handler/admin/group/createNodeGroupHandler.go
Normal file
@ -0,0 +1,26 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/result"
|
||||
)
|
||||
|
||||
// Create node group
|
||||
func CreateNodeGroupHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.CreateNodeGroupRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := group.NewCreateNodeGroupLogic(c.Request.Context(), svcCtx)
|
||||
err := l.CreateNodeGroup(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
29
internal/handler/admin/group/deleteNodeGroupHandler.go
Normal file
29
internal/handler/admin/group/deleteNodeGroupHandler.go
Normal file
@ -0,0 +1,29 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/result"
|
||||
)
|
||||
|
||||
// Delete node group
|
||||
func DeleteNodeGroupHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.DeleteNodeGroupRequest
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := group.NewDeleteNodeGroupLogic(c.Request.Context(), svcCtx)
|
||||
err := l.DeleteNodeGroup(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
36
internal/handler/admin/group/exportGroupResultHandler.go
Normal file
36
internal/handler/admin/group/exportGroupResultHandler.go
Normal file
@ -0,0 +1,36 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/result"
|
||||
)
|
||||
|
||||
// Export group result
|
||||
func ExportGroupResultHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ExportGroupResultRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := group.NewExportGroupResultLogic(c.Request.Context(), svcCtx)
|
||||
data, filename, err := l.ExportGroupResult(&req)
|
||||
if err != nil {
|
||||
result.HttpResult(c, nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
c.Header("Content-Type", "text/csv")
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
c.Data(http.StatusOK, "text/csv", data)
|
||||
}
|
||||
}
|
||||
26
internal/handler/admin/group/getGroupConfigHandler.go
Normal file
26
internal/handler/admin/group/getGroupConfigHandler.go
Normal file
@ -0,0 +1,26 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/result"
|
||||
)
|
||||
|
||||
// Get group config
|
||||
func GetGroupConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetGroupConfigRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := group.NewGetGroupConfigLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetGroupConfig(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
26
internal/handler/admin/group/getGroupHistoryDetailHandler.go
Normal file
26
internal/handler/admin/group/getGroupHistoryDetailHandler.go
Normal file
@ -0,0 +1,26 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/result"
|
||||
)
|
||||
|
||||
// Get group history detail
|
||||
func GetGroupHistoryDetailHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetGroupHistoryDetailRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := group.NewGetGroupHistoryDetailLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetGroupHistoryDetail(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
26
internal/handler/admin/group/getGroupHistoryHandler.go
Normal file
26
internal/handler/admin/group/getGroupHistoryHandler.go
Normal file
@ -0,0 +1,26 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/result"
|
||||
)
|
||||
|
||||
// Get group history
|
||||
func GetGroupHistoryHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetGroupHistoryRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := group.NewGetGroupHistoryLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetGroupHistory(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
26
internal/handler/admin/group/getNodeGroupListHandler.go
Normal file
26
internal/handler/admin/group/getNodeGroupListHandler.go
Normal file
@ -0,0 +1,26 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/result"
|
||||
)
|
||||
|
||||
// Get node group list
|
||||
func GetNodeGroupListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetNodeGroupListRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := group.NewGetNodeGroupListLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetNodeGroupList(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/group"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Get recalculation status
|
||||
func GetRecalculationStatusHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
l := group.NewGetRecalculationStatusLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetRecalculationStatus()
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/result"
|
||||
)
|
||||
|
||||
// Get subscribe group mapping
|
||||
func GetSubscribeGroupMappingHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetSubscribeGroupMappingRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := group.NewGetSubscribeGroupMappingLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetSubscribeGroupMapping(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
26
internal/handler/admin/group/previewUserNodesHandler.go
Normal file
26
internal/handler/admin/group/previewUserNodesHandler.go
Normal file
@ -0,0 +1,26 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/result"
|
||||
)
|
||||
|
||||
// Preview user nodes
|
||||
func PreviewUserNodesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.PreviewUserNodesRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := group.NewPreviewUserNodesLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.PreviewUserNodes(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
26
internal/handler/admin/group/recalculateGroupHandler.go
Normal file
26
internal/handler/admin/group/recalculateGroupHandler.go
Normal file
@ -0,0 +1,26 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/result"
|
||||
)
|
||||
|
||||
// Recalculate group
|
||||
func RecalculateGroupHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.RecalculateGroupRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := group.NewRecalculateGroupLogic(c.Request.Context(), svcCtx)
|
||||
err := l.RecalculateGroup(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
17
internal/handler/admin/group/resetGroupsHandler.go
Normal file
17
internal/handler/admin/group/resetGroupsHandler.go
Normal file
@ -0,0 +1,17 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/group"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Reset all groups
|
||||
func ResetGroupsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
l := group.NewResetGroupsLogic(c.Request.Context(), svcCtx)
|
||||
err := l.ResetGroups()
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
26
internal/handler/admin/group/updateGroupConfigHandler.go
Normal file
26
internal/handler/admin/group/updateGroupConfigHandler.go
Normal file
@ -0,0 +1,26 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/result"
|
||||
)
|
||||
|
||||
// Update group config
|
||||
func UpdateGroupConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdateGroupConfigRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := group.NewUpdateGroupConfigLogic(c.Request.Context(), svcCtx)
|
||||
err := l.UpdateGroupConfig(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
33
internal/handler/admin/group/updateNodeGroupHandler.go
Normal file
33
internal/handler/admin/group/updateNodeGroupHandler.go
Normal file
@ -0,0 +1,33 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/result"
|
||||
)
|
||||
|
||||
// Update node group
|
||||
func UpdateNodeGroupHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdateNodeGroupRequest
|
||||
if err := c.ShouldBindUri(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := group.NewUpdateNodeGroupLogic(c.Request.Context(), svcCtx)
|
||||
err := l.UpdateNodeGroup(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import (
|
||||
adminConsole "github.com/perfect-panel/server/internal/handler/admin/console"
|
||||
adminCoupon "github.com/perfect-panel/server/internal/handler/admin/coupon"
|
||||
adminDocument "github.com/perfect-panel/server/internal/handler/admin/document"
|
||||
adminGroup "github.com/perfect-panel/server/internal/handler/admin/group"
|
||||
adminLog "github.com/perfect-panel/server/internal/handler/admin/log"
|
||||
adminMarketing "github.com/perfect-panel/server/internal/handler/admin/marketing"
|
||||
adminOrder "github.com/perfect-panel/server/internal/handler/admin/order"
|
||||
@ -188,6 +189,53 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
adminDocumentGroupRouter.GET("/list", adminDocument.GetDocumentListHandler(serverCtx))
|
||||
}
|
||||
|
||||
adminGroupGroupRouter := router.Group("/v1/admin/group")
|
||||
adminGroupGroupRouter.Use(middleware.AuthMiddleware(serverCtx))
|
||||
|
||||
{
|
||||
// Get group config
|
||||
adminGroupGroupRouter.GET("/config", adminGroup.GetGroupConfigHandler(serverCtx))
|
||||
|
||||
// Update group config
|
||||
adminGroupGroupRouter.PUT("/config", adminGroup.UpdateGroupConfigHandler(serverCtx))
|
||||
|
||||
// Export group result
|
||||
adminGroupGroupRouter.GET("/export", adminGroup.ExportGroupResultHandler(serverCtx))
|
||||
|
||||
// Get group history
|
||||
adminGroupGroupRouter.GET("/history", adminGroup.GetGroupHistoryHandler(serverCtx))
|
||||
|
||||
// Get group history detail
|
||||
adminGroupGroupRouter.GET("/history/detail", adminGroup.GetGroupHistoryDetailHandler(serverCtx))
|
||||
|
||||
// Create node group
|
||||
adminGroupGroupRouter.POST("/node", adminGroup.CreateNodeGroupHandler(serverCtx))
|
||||
|
||||
// Update node group
|
||||
adminGroupGroupRouter.PUT("/node", adminGroup.UpdateNodeGroupHandler(serverCtx))
|
||||
|
||||
// Delete node group
|
||||
adminGroupGroupRouter.DELETE("/node", adminGroup.DeleteNodeGroupHandler(serverCtx))
|
||||
|
||||
// Get node group list
|
||||
adminGroupGroupRouter.GET("/node/list", adminGroup.GetNodeGroupListHandler(serverCtx))
|
||||
|
||||
// Preview user nodes
|
||||
adminGroupGroupRouter.GET("/preview", adminGroup.PreviewUserNodesHandler(serverCtx))
|
||||
|
||||
// Recalculate group
|
||||
adminGroupGroupRouter.POST("/recalculate", adminGroup.RecalculateGroupHandler(serverCtx))
|
||||
|
||||
// Get recalculation status
|
||||
adminGroupGroupRouter.GET("/recalculation/status", adminGroup.GetRecalculationStatusHandler(serverCtx))
|
||||
|
||||
// Reset all groups
|
||||
adminGroupGroupRouter.POST("/reset", adminGroup.ResetGroupsHandler(serverCtx))
|
||||
|
||||
// Get subscribe group mapping
|
||||
adminGroupGroupRouter.GET("/subscribe/mapping", adminGroup.GetSubscribeGroupMappingHandler(serverCtx))
|
||||
}
|
||||
|
||||
adminLogGroupRouter := router.Group("/v1/admin/log")
|
||||
adminLogGroupRouter.Use(middleware.AuthMiddleware(serverCtx))
|
||||
|
||||
|
||||
46
internal/logic/admin/group/createNodeGroupLogic.go
Normal file
46
internal/logic/admin/group/createNodeGroupLogic.go
Normal file
@ -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
|
||||
}
|
||||
61
internal/logic/admin/group/deleteNodeGroupLogic.go
Normal file
61
internal/logic/admin/group/deleteNodeGroupLogic.go
Normal file
@ -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 // 自动提交
|
||||
})
|
||||
}
|
||||
128
internal/logic/admin/group/exportGroupResultLogic.go
Normal file
128
internal/logic/admin/group/exportGroupResultLogic.go
Normal file
@ -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
|
||||
}
|
||||
125
internal/logic/admin/group/getGroupConfigLogic.go
Normal file
125
internal/logic/admin/group/getGroupConfigLogic.go
Normal file
@ -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
|
||||
}
|
||||
109
internal/logic/admin/group/getGroupHistoryDetailLogic.go
Normal file
109
internal/logic/admin/group/getGroupHistoryDetailLogic.go
Normal file
@ -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
|
||||
}
|
||||
87
internal/logic/admin/group/getGroupHistoryLogic.go
Normal file
87
internal/logic/admin/group/getGroupHistoryLogic.go
Normal file
@ -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
|
||||
}
|
||||
89
internal/logic/admin/group/getNodeGroupListLogic.go
Normal file
89
internal/logic/admin/group/getNodeGroupListLogic.go
Normal file
@ -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
|
||||
}
|
||||
57
internal/logic/admin/group/getRecalculationStatusLogic.go
Normal file
57
internal/logic/admin/group/getRecalculationStatusLogic.go
Normal file
@ -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
|
||||
}
|
||||
71
internal/logic/admin/group/getSubscribeGroupMappingLogic.go
Normal file
71
internal/logic/admin/group/getSubscribeGroupMappingLogic.go
Normal file
@ -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
|
||||
}
|
||||
466
internal/logic/admin/group/previewUserNodesLogic.go
Normal file
466
internal/logic/admin/group/previewUserNodesLogic.go
Normal file
@ -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_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
|
||||
}
|
||||
814
internal/logic/admin/group/recalculateGroupLogic.go
Normal file
814
internal/logic/admin/group/recalculateGroupLogic.go
Normal file
@ -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
|
||||
}
|
||||
82
internal/logic/admin/group/resetGroupsLogic.go
Normal file
82
internal/logic/admin/group/resetGroupsLogic.go
Normal file
@ -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
|
||||
}
|
||||
188
internal/logic/admin/group/updateGroupConfigLogic.go
Normal file
188
internal/logic/admin/group/updateGroupConfigLogic.go
Normal file
@ -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
|
||||
}
|
||||
140
internal/logic/admin/group/updateNodeGroupLogic.go
Normal file
140
internal/logic/admin/group/updateNodeGroupLogic.go
Normal file
@ -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
|
||||
}
|
||||
@ -36,6 +36,7 @@ func (l *CreateNodeLogic) CreateNode(req *types.CreateNodeRequest) error {
|
||||
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,
|
||||
NodeGroupIds: nodeGroupIds,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@ -52,6 +59,7 @@ func (l *FilterNodeListLogic) FilterNodeList(req *types.FilterNodeListRequest) (
|
||||
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
|
||||
}
|
||||
|
||||
@ -16,6 +16,10 @@ func registerIpLimit(svcCtx *svc.ServiceContext, ctx context.Context, registerIp
|
||||
return true
|
||||
}
|
||||
|
||||
// Add timeout protection for Redis operations
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use a sorted set to track IP registrations with timestamp as score
|
||||
// Key format: register:ip:{ip}
|
||||
key := fmt.Sprintf("%s%s", config.RegisterIpKeyPrefix, registerIp)
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/group"
|
||||
"github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
@ -126,22 +127,76 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
|
||||
return err
|
||||
}
|
||||
|
||||
if l.svcCtx.Config.Register.EnableTrial {
|
||||
// Active trial
|
||||
var trialErr error
|
||||
trialSubscribe, trialErr = l.activeTrial(userInfo.Id)
|
||||
if trialErr != nil {
|
||||
return trialErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Activate trial subscription after transaction success (moved outside transaction to reduce lock time)
|
||||
if l.svcCtx.Config.Register.EnableTrial {
|
||||
trialSubscribe, err = l.activeTrial(userInfo.Id)
|
||||
if err != nil {
|
||||
l.Errorw("Failed to activate trial subscription", logger.Field("error", err.Error()))
|
||||
// Don't fail registration if trial activation fails
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cache after transaction success
|
||||
if l.svcCtx.Config.Register.EnableTrial && trialSubscribe != nil {
|
||||
// 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 registration",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("mode", groupMode),
|
||||
)
|
||||
}()
|
||||
|
||||
// Clear user subscription cache
|
||||
if err = l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, trialSubscribe); err != nil {
|
||||
l.Errorw("ClearSubscribeCache failed", logger.Field("error", err.Error()), logger.Field("userSubscribeId", trialSubscribe.Id))
|
||||
|
||||
@ -91,25 +91,37 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u
|
||||
return l.createExpiredServers(), nil
|
||||
}
|
||||
|
||||
subDetails, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, userSub.SubscribeId)
|
||||
// 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 {
|
||||
l.Errorw("[Generate Subscribe]find subscribe details error: %v", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe details error: %v", err.Error())
|
||||
l.Debugw("[GetServers] Failed to check group enabled", logger.Field("error", err.Error()))
|
||||
// Continue with tag-based filtering
|
||||
}
|
||||
nodeIds := tool.StringToInt64Slice(subDetails.Nodes)
|
||||
tags := strings.Split(subDetails.NodeTags, ",")
|
||||
|
||||
l.Debugf("[Generate Subscribe]nodes: %v, NodeTags: %v", nodeIds, tags)
|
||||
isGroupEnabled := (groupEnabled == "true" || groupEnabled == "1")
|
||||
|
||||
enable := true
|
||||
|
||||
_, nodes, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{
|
||||
Page: 0,
|
||||
Size: 1000,
|
||||
NodeId: nodeIds,
|
||||
Enabled: &enable, // Only get enabled nodes
|
||||
})
|
||||
var nodes []*node.Node
|
||||
if isGroupEnabled {
|
||||
// Group mode: use group_ids to filter nodes
|
||||
nodes, err = l.getNodesByGroup(userSub)
|
||||
if err != nil {
|
||||
l.Errorw("[GetServers] Failed to get nodes by group", logger.Field("error", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// Tag mode: use node_ids and tags to filter nodes
|
||||
nodes, err = l.getNodesByTag(userSub)
|
||||
if err != nil {
|
||||
l.Errorw("[GetServers] Failed to get nodes by tag", logger.Field("error", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Process nodes and create response
|
||||
if len(nodes) > 0 {
|
||||
var serverMapIds = make(map[int64]*node.Server)
|
||||
for _, n := range nodes {
|
||||
@ -157,15 +169,100 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u
|
||||
}
|
||||
|
||||
l.Debugf("[Query Subscribe]found servers: %v", len(nodes))
|
||||
|
||||
if err != nil {
|
||||
l.Errorw("[Generate Subscribe]find server details error: %v", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find server details error: %v", err.Error())
|
||||
}
|
||||
logger.Debugf("[Generate Subscribe]found servers: %v", len(nodes))
|
||||
return userSubscribeNodes, nil
|
||||
}
|
||||
|
||||
// getNodesByGroup gets nodes based on user subscription node_group_id with priority fallback
|
||||
func (l *QueryUserSubscribeNodeListLogic) getNodesByGroup(userSub *user.Subscribe) ([]*node.Node, error) {
|
||||
// 按优先级获取 node_group_id:user_subscribe.node_group_id > subscribe.node_group_id > subscribe.node_group_ids[0]
|
||||
nodeGroupId := int64(0)
|
||||
source := ""
|
||||
|
||||
// 优先级1: user_subscribe.node_group_id
|
||||
if userSub.NodeGroupId != 0 {
|
||||
nodeGroupId = userSub.NodeGroupId
|
||||
source = "user_subscribe.node_group_id"
|
||||
} else {
|
||||
// 优先级2 & 3: 从 subscribe 表获取
|
||||
subDetails, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, userSub.SubscribeId)
|
||||
if err != nil {
|
||||
l.Errorw("[GetNodesByGroup] find subscribe details error", logger.Field("error", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 优先级2: subscribe.node_group_id
|
||||
if subDetails.NodeGroupId != 0 {
|
||||
nodeGroupId = subDetails.NodeGroupId
|
||||
source = "subscribe.node_group_id"
|
||||
} else if len(subDetails.NodeGroupIds) > 0 {
|
||||
// 优先级3: subscribe.node_group_ids[0]
|
||||
nodeGroupId = subDetails.NodeGroupIds[0]
|
||||
source = "subscribe.node_group_ids[0]"
|
||||
}
|
||||
}
|
||||
|
||||
// 如果所有优先级都没有获取到,返回空节点列表
|
||||
if nodeGroupId == 0 {
|
||||
l.Debugw("[GetNodesByGroup] no node_group_id found in any priority, returning no nodes")
|
||||
return []*node.Node{}, nil
|
||||
}
|
||||
|
||||
l.Debugf("[GetNodesByGroup] Using %s: %v", source, nodeGroupId)
|
||||
|
||||
// Filter nodes by node_group_id
|
||||
enable := true
|
||||
_, nodes, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{
|
||||
Page: 0,
|
||||
Size: 1000,
|
||||
NodeGroupIds: []int64{nodeGroupId}, // Filter by node_group_ids
|
||||
Enabled: &enable,
|
||||
})
|
||||
if err != nil {
|
||||
l.Errorw("[GetNodesByGroup] FilterNodeList error", logger.Field("error", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l.Debugf("[GetNodesByGroup] Found %d nodes for node_group_id=%d", len(nodes), nodeGroupId)
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// getNodesByTag gets nodes based on subscribe node_ids and tags
|
||||
func (l *QueryUserSubscribeNodeListLogic) getNodesByTag(userSub *user.Subscribe) ([]*node.Node, error) {
|
||||
subDetails, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, userSub.SubscribeId)
|
||||
if err != nil {
|
||||
l.Errorw("[Generate Subscribe]find subscribe details error: %v", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe details error: %v", err.Error())
|
||||
}
|
||||
|
||||
nodeIds := tool.StringToInt64Slice(subDetails.Nodes)
|
||||
tags := strings.Split(subDetails.NodeTags, ",")
|
||||
|
||||
l.Debugf("[Generate Subscribe]nodes: %v, NodeTags: %v", nodeIds, tags)
|
||||
|
||||
enable := true
|
||||
_, nodes, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{
|
||||
Page: 0,
|
||||
Size: 1000,
|
||||
NodeId: nodeIds,
|
||||
Tag: tags,
|
||||
Enabled: &enable, // Only get enabled nodes
|
||||
})
|
||||
|
||||
return nodes, err
|
||||
}
|
||||
|
||||
// getAllNodes returns all enabled nodes
|
||||
func (l *QueryUserSubscribeNodeListLogic) getAllNodes() ([]*node.Node, error) {
|
||||
enable := true
|
||||
_, nodes, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{
|
||||
Page: 0,
|
||||
Size: 1000,
|
||||
Enabled: &enable,
|
||||
})
|
||||
|
||||
return nodes, err
|
||||
}
|
||||
|
||||
func (l *QueryUserSubscribeNodeListLogic) isSubscriptionExpired(userSub *user.Subscribe) bool {
|
||||
return userSub.ExpireTime.Unix() < time.Now().Unix() && userSub.ExpireTime.Unix() != 0
|
||||
}
|
||||
|
||||
@ -55,6 +55,7 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 查询该服务器上该协议的所有节点(包括属于节点组的节点)
|
||||
_, nodes, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{
|
||||
Page: 1,
|
||||
Size: 1000,
|
||||
@ -65,25 +66,74 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
l.Errorw("FilterNodeList error", logger.Field("error", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
var nodeTag []string
|
||||
|
||||
if len(nodes) == 0 {
|
||||
return &types.GetServerUserListResponse{
|
||||
Users: []types.ServerUser{
|
||||
{
|
||||
Id: 1,
|
||||
UUID: uuidx.NewUUID().String(),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 收集所有唯一的节点组 ID
|
||||
nodeGroupMap := make(map[int64]bool) // nodeGroupId -> true
|
||||
var nodeIds []int64
|
||||
var nodeTags []string
|
||||
|
||||
for _, n := range nodes {
|
||||
nodeIds = append(nodeIds, n.Id)
|
||||
if n.Tags != "" {
|
||||
nodeTag = append(nodeTag, strings.Split(n.Tags, ",")...)
|
||||
nodeTags = append(nodeTags, strings.Split(n.Tags, ",")...)
|
||||
}
|
||||
// 收集节点组 ID
|
||||
if len(n.NodeGroupIds) > 0 {
|
||||
for _, gid := range n.NodeGroupIds {
|
||||
if gid > 0 {
|
||||
nodeGroupMap[gid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, subs, err := l.svcCtx.SubscribeModel.FilterList(l.ctx, &subscribe.FilterParams{
|
||||
// 获取所有节点组 ID
|
||||
nodeGroupIds := make([]int64, 0, len(nodeGroupMap))
|
||||
for gid := range nodeGroupMap {
|
||||
nodeGroupIds = append(nodeGroupIds, gid)
|
||||
}
|
||||
|
||||
// 查询订阅:
|
||||
// 1. 如果有节点组,查询匹配这些节点组的订阅
|
||||
// 2. 如果没有节点组,查询使用节点 ID 或 tags 的订阅
|
||||
var subs []*subscribe.Subscribe
|
||||
if len(nodeGroupIds) > 0 {
|
||||
// 节点组模式:查询 node_group_id 或 node_group_ids 匹配的订阅
|
||||
_, subs, err = l.svcCtx.SubscribeModel.FilterListByNodeGroups(l.ctx, &subscribe.FilterByNodeGroupsParams{
|
||||
Page: 1,
|
||||
Size: 9999,
|
||||
NodeGroupIds: nodeGroupIds,
|
||||
})
|
||||
if err != nil {
|
||||
l.Errorw("FilterListByNodeGroups error", logger.Field("error", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// 传统模式:查询匹配节点 ID 或 tags 的订阅
|
||||
nodeTags = tool.RemoveDuplicateElements(nodeTags...)
|
||||
_, subs, err = l.svcCtx.SubscribeModel.FilterList(l.ctx, &subscribe.FilterParams{
|
||||
Page: 1,
|
||||
Size: 9999,
|
||||
Node: nodeIds,
|
||||
Tags: nodeTag,
|
||||
Tags: nodeTags,
|
||||
})
|
||||
if err != nil {
|
||||
l.Errorw("QuerySubscribeIdsByServerIdAndServerGroupId error", logger.Field("error", err.Error()))
|
||||
l.Errorw("FilterList error", logger.Field("error", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(subs) == 0 {
|
||||
return &types.GetServerUserListResponse{
|
||||
Users: []types.ServerUser{
|
||||
|
||||
@ -215,14 +215,133 @@ func (l *SubscribeLogic) getServers(userSub *user.Subscribe) ([]*node.Node, erro
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe details error: %v", err.Error())
|
||||
}
|
||||
|
||||
// 判断是否使用分组模式
|
||||
isGroupMode := l.isGroupEnabled()
|
||||
|
||||
if isGroupMode {
|
||||
// === 分组模式:使用 node_group_id 获取节点 ===
|
||||
// 按优先级获取 node_group_id:user_subscribe.node_group_id > subscribe.node_group_id > subscribe.node_group_ids[0]
|
||||
nodeGroupId := int64(0)
|
||||
source := ""
|
||||
|
||||
// 优先级1: user_subscribe.node_group_id
|
||||
if userSub.NodeGroupId != 0 {
|
||||
nodeGroupId = userSub.NodeGroupId
|
||||
source = "user_subscribe.node_group_id"
|
||||
} else {
|
||||
// 优先级2 & 3: 从 subscribe 表获取
|
||||
if subDetails.NodeGroupId != 0 {
|
||||
nodeGroupId = subDetails.NodeGroupId
|
||||
source = "subscribe.node_group_id"
|
||||
} else if len(subDetails.NodeGroupIds) > 0 {
|
||||
// 优先级3: subscribe.node_group_ids[0]
|
||||
nodeGroupId = subDetails.NodeGroupIds[0]
|
||||
source = "subscribe.node_group_ids[0]"
|
||||
}
|
||||
}
|
||||
|
||||
l.Debugf("[Generate Subscribe]group mode, using %s: %v", source, nodeGroupId)
|
||||
|
||||
// 根据 node_group_id 获取节点
|
||||
enable := true
|
||||
|
||||
// 1. 获取分组节点
|
||||
var groupNodes []*node.Node
|
||||
if nodeGroupId > 0 {
|
||||
params := &node.FilterNodeParams{
|
||||
Page: 0,
|
||||
Size: 1000,
|
||||
NodeGroupIds: []int64{nodeGroupId},
|
||||
Enabled: &enable,
|
||||
Preload: true,
|
||||
}
|
||||
_, groupNodes, err = l.svc.NodeModel.FilterNodeList(l.ctx.Request.Context(), params)
|
||||
|
||||
if err != nil {
|
||||
l.Errorw("[Generate Subscribe]filter nodes by group error", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "filter nodes by group error: %v", err.Error())
|
||||
}
|
||||
l.Debugf("[Generate Subscribe]found %d nodes for node_group_id=%d", len(groupNodes), nodeGroupId)
|
||||
}
|
||||
|
||||
// 2. 获取公共节点(NodeGroupIds 为空的节点)
|
||||
_, allNodes, err := l.svc.NodeModel.FilterNodeList(l.ctx.Request.Context(), &node.FilterNodeParams{
|
||||
Page: 0,
|
||||
Size: 1000,
|
||||
Enabled: &enable,
|
||||
Preload: true,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
l.Errorw("[Generate Subscribe]filter all nodes error", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "filter all nodes error: %v", err.Error())
|
||||
}
|
||||
|
||||
// 过滤出公共节点
|
||||
var publicNodes []*node.Node
|
||||
for _, n := range allNodes {
|
||||
if len(n.NodeGroupIds) == 0 {
|
||||
publicNodes = append(publicNodes, n)
|
||||
}
|
||||
}
|
||||
l.Debugf("[Generate Subscribe]found %d public nodes (node_group_ids is empty)", len(publicNodes))
|
||||
|
||||
// 3. 合并分组节点和公共节点
|
||||
nodesMap := make(map[int64]*node.Node)
|
||||
for _, n := range groupNodes {
|
||||
nodesMap[n.Id] = n
|
||||
}
|
||||
for _, n := range publicNodes {
|
||||
if _, exists := nodesMap[n.Id]; !exists {
|
||||
nodesMap[n.Id] = n
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为切片
|
||||
var result []*node.Node
|
||||
for _, n := range nodesMap {
|
||||
result = append(result, n)
|
||||
}
|
||||
|
||||
l.Debugf("[Generate Subscribe]total nodes (group + public): %d (group: %d, public: %d)", len(result), len(groupNodes), len(publicNodes))
|
||||
|
||||
// 查询节点组信息,获取节点组名称(仅当用户有分组时)
|
||||
if nodeGroupId > 0 {
|
||||
type NodeGroupInfo struct {
|
||||
Id int64
|
||||
Name string
|
||||
}
|
||||
var nodeGroupInfo NodeGroupInfo
|
||||
err = l.svc.DB.Table("node_group").Select("id, name").Where("id = ?", nodeGroupId).First(&nodeGroupInfo).Error
|
||||
if err != nil {
|
||||
l.Infow("[Generate Subscribe]node group not found", logger.Field("nodeGroupId", nodeGroupId), logger.Field("error", err.Error()))
|
||||
}
|
||||
|
||||
// 如果节点组信息存在,为没有 tag 的分组节点设置节点组名称为 tag
|
||||
if nodeGroupInfo.Id != 0 && nodeGroupInfo.Name != "" {
|
||||
for _, n := range result {
|
||||
// 只为分组节点设置 tag,公共节点不设置
|
||||
if n.Tags == "" && len(n.NodeGroupIds) > 0 {
|
||||
n.Tags = nodeGroupInfo.Name
|
||||
l.Debugf("[Generate Subscribe]set node_group name as tag for node %d: %s", n.Id, nodeGroupInfo.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// === 标签模式:使用 node_ids 和 tags 获取节点 ===
|
||||
nodeIds := tool.StringToInt64Slice(subDetails.Nodes)
|
||||
tags := tool.RemoveStringElement(strings.Split(subDetails.NodeTags, ","), "")
|
||||
|
||||
l.Debugf("[Generate Subscribe]nodes: %v, NodeTags: %v", len(nodeIds), len(tags))
|
||||
l.Debugf("[Generate Subscribe]tag mode, nodes: %v, NodeTags: %v", len(nodeIds), len(tags))
|
||||
if len(nodeIds) == 0 && len(tags) == 0 {
|
||||
logger.Infow("[Generate Subscribe]no subscribe nodes")
|
||||
logger.Infow("[Generate Subscribe]no subscribe nodes configured")
|
||||
return []*node.Node{}, nil
|
||||
}
|
||||
|
||||
enable := true
|
||||
var nodes []*node.Node
|
||||
_, nodes, err = l.svc.NodeModel.FilterNodeList(l.ctx.Request.Context(), &node.FilterNodeParams{
|
||||
@ -231,16 +350,15 @@ func (l *SubscribeLogic) getServers(userSub *user.Subscribe) ([]*node.Node, erro
|
||||
NodeId: nodeIds,
|
||||
Tag: tool.RemoveDuplicateElements(tags...),
|
||||
Preload: true,
|
||||
Enabled: &enable, // Only get enabled nodes
|
||||
Enabled: &enable,
|
||||
})
|
||||
|
||||
l.Debugf("[Query Subscribe]found servers: %v", len(nodes))
|
||||
|
||||
if err != nil {
|
||||
l.Errorw("[Generate Subscribe]find server details error: %v", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find server details error: %v", err.Error())
|
||||
}
|
||||
logger.Debugf("[Generate Subscribe]found servers: %v", len(nodes))
|
||||
|
||||
l.Debugf("[Generate Subscribe]found %d nodes in tag mode", len(nodes))
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
@ -290,3 +408,17 @@ func (l *SubscribeLogic) getFirstHostLine() string {
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// isGroupEnabled 判断分组功能是否启用
|
||||
func (l *SubscribeLogic) isGroupEnabled() bool {
|
||||
var value string
|
||||
err := l.svc.DB.Table("system").
|
||||
Where("`category` = ? AND `key` = ?", "group", "enabled").
|
||||
Select("value").
|
||||
Scan(&value).Error
|
||||
if err != nil {
|
||||
l.Debugf("[SubscribeLogic]check group enabled failed: %v", err)
|
||||
return false
|
||||
}
|
||||
return value == "true" || value == "1"
|
||||
}
|
||||
|
||||
54
internal/model/group/history.go
Normal file
54
internal/model/group/history.go
Normal file
@ -0,0 +1,54 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GroupHistory 分组历史记录模型
|
||||
type GroupHistory struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
GroupMode string `gorm:"type:varchar(50);not null;index:idx_group_mode;comment:Group Mode: average/subscribe/traffic"`
|
||||
TriggerType string `gorm:"type:varchar(50);not null;index:idx_trigger_type;comment:Trigger Type: manual/auto/schedule"`
|
||||
State string `gorm:"type:varchar(50);not null;index:idx_state;comment:State: pending/running/completed/failed"`
|
||||
TotalUsers int `gorm:"default:0;not null;comment:Total Users"`
|
||||
SuccessCount int `gorm:"default:0;not null;comment:Success Count"`
|
||||
FailedCount int `gorm:"default:0;not null;comment:Failed Count"`
|
||||
StartTime *time.Time `gorm:"comment:Start Time"`
|
||||
EndTime *time.Time `gorm:"comment:End Time"`
|
||||
Operator string `gorm:"type:varchar(100);comment:Operator"`
|
||||
ErrorMessage string `gorm:"type:TEXT;comment:Error Message"`
|
||||
CreatedAt time.Time `gorm:"<-:create;index:idx_created_at;comment:Create Time"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (*GroupHistory) TableName() string {
|
||||
return "group_history"
|
||||
}
|
||||
|
||||
// BeforeCreate GORM hook - 创建前回调
|
||||
func (gh *GroupHistory) BeforeCreate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GroupHistoryDetail 分组历史详情模型
|
||||
type GroupHistoryDetail struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
HistoryId int64 `gorm:"not null;index:idx_history_id;comment:History ID"`
|
||||
NodeGroupId int64 `gorm:"not null;index:idx_node_group_id;comment:Node Group ID"`
|
||||
UserCount int `gorm:"default:0;not null;comment:User Count"`
|
||||
NodeCount int `gorm:"default:0;not null;comment:Node Count"`
|
||||
UserData string `gorm:"type:text;comment:User data JSON (id and email/phone)"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (*GroupHistoryDetail) TableName() string {
|
||||
return "group_history_detail"
|
||||
}
|
||||
|
||||
// BeforeCreate GORM hook - 创建前回调
|
||||
func (ghd *GroupHistoryDetail) BeforeCreate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
14
internal/model/group/model.go
Normal file
14
internal/model/group/model.go
Normal file
@ -0,0 +1,14 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AutoMigrate 自动迁移数据库表
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&NodeGroup{},
|
||||
&GroupHistory{},
|
||||
&GroupHistoryDetail{},
|
||||
)
|
||||
}
|
||||
30
internal/model/group/node_group.go
Normal file
30
internal/model/group/node_group.go
Normal file
@ -0,0 +1,30 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NodeGroup 节点组模型
|
||||
type NodeGroup struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
Name string `gorm:"type:varchar(255);not null;comment:Name"`
|
||||
Description string `gorm:"type:varchar(500);comment:Description"`
|
||||
Sort int `gorm:"default:0;index:idx_sort;comment:Sort Order"`
|
||||
ForCalculation *bool `gorm:"default:true;not null;comment:For Calculation: whether this node group participates in grouping calculation"`
|
||||
MinTrafficGB *int64 `gorm:"default:0;comment:Minimum Traffic (GB) for this node group"`
|
||||
MaxTrafficGB *int64 `gorm:"default:0;comment:Maximum Traffic (GB) for this node group"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (*NodeGroup) TableName() string {
|
||||
return "node_group"
|
||||
}
|
||||
|
||||
// BeforeCreate GORM hook - 创建前回调
|
||||
func (ng *NodeGroup) BeforeCreate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
@ -38,6 +38,7 @@ type FilterNodeParams struct {
|
||||
NodeId []int64 // Node IDs
|
||||
ServerId []int64 // Server IDs
|
||||
Tag []string // Tags
|
||||
NodeGroupIds []int64 // Node Group IDs
|
||||
Search string // Search Address or Name
|
||||
Protocol string // Protocol
|
||||
Preload bool // Preload Server
|
||||
@ -96,6 +97,18 @@ func (m *customServerModel) FilterNodeList(ctx context.Context, params *FilterNo
|
||||
if len(params.Tag) > 0 {
|
||||
query = query.Scopes(InSet("tags", params.Tag))
|
||||
}
|
||||
if len(params.NodeGroupIds) > 0 {
|
||||
// Filter by node_group_ids using JSON_CONTAINS for each group ID
|
||||
// Multiple group IDs: node must belong to at least one of the groups
|
||||
var conditions []string
|
||||
for _, gid := range params.NodeGroupIds {
|
||||
conditions = append(conditions, fmt.Sprintf("JSON_CONTAINS(node_group_ids, %d)", gid))
|
||||
}
|
||||
if len(conditions) > 0 {
|
||||
query = query.Where("(" + strings.Join(conditions, " OR ") + ")")
|
||||
}
|
||||
}
|
||||
// If no NodeGroupIds specified, return all nodes (including public nodes)
|
||||
if params.Protocol != "" {
|
||||
query = query.Where("protocol = ?", params.Protocol)
|
||||
}
|
||||
|
||||
@ -1,12 +1,59 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// JSONInt64Slice is a custom type for handling []int64 as JSON in database
|
||||
type JSONInt64Slice []int64
|
||||
|
||||
// Scan implements sql.Scanner interface
|
||||
func (j *JSONInt64Slice) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*j = []int64{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle []byte
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
// Try to handle string
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
*j = []int64{}
|
||||
return nil
|
||||
}
|
||||
bytes = []byte(str)
|
||||
}
|
||||
|
||||
if len(bytes) == 0 {
|
||||
*j = []int64{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's a JSON array
|
||||
if bytes[0] != '[' {
|
||||
// Not a JSON array, return empty slice
|
||||
*j = []int64{}
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, j)
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer interface
|
||||
func (j JSONInt64Slice) Value() (driver.Value, error) {
|
||||
if len(j) == 0 {
|
||||
return "[]", nil
|
||||
}
|
||||
return json.Marshal(j)
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
Id int64 `gorm:"primary_key"`
|
||||
Name string `gorm:"type:varchar(100);not null;default:'';comment:Node Name"`
|
||||
@ -18,6 +65,7 @@ type Node struct {
|
||||
Protocol string `gorm:"type:varchar(100);not null;default:'';comment:Protocol"`
|
||||
Enabled *bool `gorm:"type:boolean;not null;default:true;comment:Enabled"`
|
||||
Sort int `gorm:"uniqueIndex;not null;default:0;comment:Sort"`
|
||||
NodeGroupIds JSONInt64Slice `gorm:"type:json;comment:Node Group IDs (JSON array, multiple groups)"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
|
||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package subscribe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@ -19,6 +20,13 @@ type FilterParams struct {
|
||||
Language string // Language
|
||||
DefaultLanguage bool // Default Subscribe Language Data
|
||||
Search string // Search Keywords
|
||||
NodeGroupId *int64 // Node Group ID
|
||||
}
|
||||
|
||||
type FilterByNodeGroupsParams struct {
|
||||
Page int // Page Number
|
||||
Size int // Page Size
|
||||
NodeGroupIds []int64 // Node Group IDs (multiple)
|
||||
}
|
||||
|
||||
func (p *FilterParams) Normalize() {
|
||||
@ -32,6 +40,7 @@ func (p *FilterParams) Normalize() {
|
||||
|
||||
type customSubscribeLogicModel interface {
|
||||
FilterList(ctx context.Context, params *FilterParams) (int64, []*Subscribe, error)
|
||||
FilterListByNodeGroups(ctx context.Context, params *FilterByNodeGroupsParams) (int64, []*Subscribe, error)
|
||||
ClearCache(ctx context.Context, id ...int64) error
|
||||
QuerySubscribeMinSortByIds(ctx context.Context, ids []int64) (int64, error)
|
||||
}
|
||||
@ -102,6 +111,10 @@ func (m *customSubscribeModel) FilterList(ctx context.Context, params *FilterPar
|
||||
if len(params.Tags) > 0 {
|
||||
query = query.Scopes(InSet("node_tags", params.Tags))
|
||||
}
|
||||
if params.NodeGroupId != nil {
|
||||
// Filter by node_group_ids using JSON_CONTAINS
|
||||
query = query.Where("JSON_CONTAINS(node_group_ids, ?)", *params.NodeGroupId)
|
||||
}
|
||||
if lang != "" {
|
||||
query = query.Where("language = ?", lang)
|
||||
} else if params.DefaultLanguage {
|
||||
@ -154,3 +167,67 @@ func InSet(field string, values []string) func(db *gorm.DB) *gorm.DB {
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
// FilterListByNodeGroups Filter subscribes by node groups
|
||||
// Match if subscribe's node_group_id OR node_group_ids contains any of the provided node group IDs
|
||||
func (m *customSubscribeModel) FilterListByNodeGroups(ctx context.Context, params *FilterByNodeGroupsParams) (int64, []*Subscribe, error) {
|
||||
if params == nil {
|
||||
params = &FilterByNodeGroupsParams{
|
||||
Page: 1,
|
||||
Size: 10,
|
||||
}
|
||||
}
|
||||
if params.Page <= 0 {
|
||||
params.Page = 1
|
||||
}
|
||||
if params.Size <= 0 {
|
||||
params.Size = 10
|
||||
}
|
||||
|
||||
var list []*Subscribe
|
||||
var total int64
|
||||
|
||||
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
|
||||
query := conn.Model(&Subscribe{})
|
||||
|
||||
// Filter by node groups: match if node_group_id or node_group_ids contains any of the provided IDs
|
||||
if len(params.NodeGroupIds) > 0 {
|
||||
var conditions []string
|
||||
var args []interface{}
|
||||
|
||||
// Condition 1: node_group_id IN (...)
|
||||
placeholders := make([]string, len(params.NodeGroupIds))
|
||||
for i, id := range params.NodeGroupIds {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
conditions = append(conditions, "node_group_id IN ("+strings.Join(placeholders, ",")+")")
|
||||
|
||||
// Condition 2: JSON_CONTAINS(node_group_ids, id) for each id
|
||||
for _, id := range params.NodeGroupIds {
|
||||
conditions = append(conditions, "JSON_CONTAINS(node_group_ids, ?)")
|
||||
args = append(args, id)
|
||||
}
|
||||
|
||||
// Combine with OR: (node_group_id IN (...) OR JSON_CONTAINS(node_group_ids, id1) OR ...)
|
||||
query = query.Where("("+strings.Join(conditions, " OR ")+")", args...)
|
||||
}
|
||||
|
||||
// Count total
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Find with pagination
|
||||
return query.Order("sort ASC").
|
||||
Limit(params.Size).
|
||||
Offset((params.Page - 1) * params.Size).
|
||||
Find(v).Error
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
return total, list, nil
|
||||
}
|
||||
|
||||
@ -1,11 +1,58 @@
|
||||
package subscribe
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// JSONInt64Slice is a custom type for handling []int64 as JSON in database
|
||||
type JSONInt64Slice []int64
|
||||
|
||||
// Scan implements sql.Scanner interface
|
||||
func (j *JSONInt64Slice) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*j = []int64{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle []byte
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
// Try to handle string
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
*j = []int64{}
|
||||
return nil
|
||||
}
|
||||
bytes = []byte(str)
|
||||
}
|
||||
|
||||
if len(bytes) == 0 {
|
||||
*j = []int64{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's a JSON array
|
||||
if bytes[0] != '[' {
|
||||
// Not a JSON array, return empty slice
|
||||
*j = []int64{}
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, j)
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer interface
|
||||
func (j JSONInt64Slice) Value() (driver.Value, error) {
|
||||
if len(j) == 0 {
|
||||
return "[]", nil
|
||||
}
|
||||
return json.Marshal(j)
|
||||
}
|
||||
|
||||
type Subscribe struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
Name string `gorm:"type:varchar(255);not null;default:'';comment:Subscribe Name"`
|
||||
@ -22,6 +69,8 @@ type Subscribe struct {
|
||||
Quota int64 `gorm:"type:int;not null;default:0;comment:Quota"`
|
||||
Nodes string `gorm:"type:varchar(255);comment:Node Ids"`
|
||||
NodeTags string `gorm:"type:varchar(255);comment:Node Tags"`
|
||||
NodeGroupIds JSONInt64Slice `gorm:"type:json;comment:Node Group IDs (JSON array, multiple groups)"`
|
||||
NodeGroupId int64 `gorm:"default:0;index:idx_node_group_id;comment:Default Node Group ID (single ID)"`
|
||||
Show *bool `gorm:"type:tinyint(1);not null;default:0;comment:Show portal page"`
|
||||
Sell *bool `gorm:"type:tinyint(1);not null;default:0;comment:Sell"`
|
||||
Sort int64 `gorm:"type:int;not null;default:0;comment:Sort"`
|
||||
|
||||
@ -27,6 +27,7 @@ type SubscribeDetails struct {
|
||||
OrderId int64 `gorm:"index:idx_order_id;not null;comment:Order ID"`
|
||||
SubscribeId int64 `gorm:"index:idx_subscribe_id;not null;comment:Subscription ID"`
|
||||
Subscribe *subscribe.Subscribe `gorm:"foreignKey:SubscribeId;references:Id"`
|
||||
NodeGroupId int64 `gorm:"index:idx_node_group_id;not null;default:0;comment:Node Group ID (single ID)"`
|
||||
StartTime time.Time `gorm:"default:CURRENT_TIMESTAMP(3);not null;comment:Subscription Start Time"`
|
||||
ExpireTime time.Time `gorm:"default:NULL;comment:Subscription Expire Time"`
|
||||
FinishedAt *time.Time `gorm:"default:NULL;comment:Finished Time"`
|
||||
|
||||
@ -1,11 +1,58 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// JSONInt64Slice is a custom type for handling []int64 as JSON in database
|
||||
type JSONInt64Slice []int64
|
||||
|
||||
// Scan implements sql.Scanner interface
|
||||
func (j *JSONInt64Slice) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*j = []int64{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle []byte
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
// Try to handle string
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
*j = []int64{}
|
||||
return nil
|
||||
}
|
||||
bytes = []byte(str)
|
||||
}
|
||||
|
||||
if len(bytes) == 0 {
|
||||
*j = []int64{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's a JSON array
|
||||
if bytes[0] != '[' {
|
||||
// Not a JSON array, return empty slice
|
||||
*j = []int64{}
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, j)
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer interface
|
||||
func (j JSONInt64Slice) Value() (driver.Value, error) {
|
||||
if len(j) == 0 {
|
||||
return "[]", nil
|
||||
}
|
||||
return json.Marshal(j)
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
Password string `gorm:"type:varchar(100);not null;comment:User Password"`
|
||||
@ -43,6 +90,8 @@ type Subscribe struct {
|
||||
User User `gorm:"foreignKey:UserId;references:Id"`
|
||||
OrderId int64 `gorm:"index:idx_order_id;not null;comment:Order ID"`
|
||||
SubscribeId int64 `gorm:"index:idx_subscribe_id;not null;comment:Subscription ID"`
|
||||
NodeGroupId int64 `gorm:"index:idx_node_group_id;not null;default:0;comment:Node Group ID (single ID)"`
|
||||
GroupLocked *bool `gorm:"type:tinyint(1);not null;default:0;comment:Group Locked"`
|
||||
StartTime time.Time `gorm:"default:CURRENT_TIMESTAMP(3);not null;comment:Subscription Start Time"`
|
||||
ExpireTime time.Time `gorm:"default:NULL;comment:Subscription Expire Time"`
|
||||
FinishedAt *time.Time `gorm:"default:NULL;comment:Finished Time"`
|
||||
|
||||
@ -320,6 +320,15 @@ type CreateDocumentRequest struct {
|
||||
Show *bool `json:"show"`
|
||||
}
|
||||
|
||||
type CreateNodeGroupRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Description string `json:"description"`
|
||||
Sort int `json:"sort"`
|
||||
ForCalculation *bool `json:"for_calculation"`
|
||||
MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"`
|
||||
MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"`
|
||||
}
|
||||
|
||||
type CreateNodeRequest struct {
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
@ -328,6 +337,7 @@ type CreateNodeRequest struct {
|
||||
ServerId int64 `json:"server_id"`
|
||||
Protocol string `json:"protocol"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
}
|
||||
|
||||
type CreateOrderRequest struct {
|
||||
@ -420,6 +430,8 @@ type CreateSubscribeRequest struct {
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
NodeGroupId int64 `json:"node_group_id"`
|
||||
Show *bool `json:"show"`
|
||||
Sell *bool `json:"sell"`
|
||||
DeductionRatio int64 `json:"deduction_ratio"`
|
||||
@ -427,6 +439,7 @@ type CreateSubscribeRequest struct {
|
||||
ResetCycle int64 `json:"reset_cycle"`
|
||||
RenewalReset *bool `json:"renewal_reset"`
|
||||
ShowOriginalPrice bool `json:"show_original_price"`
|
||||
AutoCreateGroup bool `json:"auto_create_group"`
|
||||
}
|
||||
|
||||
type CreateTicketFollowRequest struct {
|
||||
@ -505,6 +518,10 @@ type DeleteDocumentRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
}
|
||||
|
||||
type DeleteNodeGroupRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
}
|
||||
|
||||
type DeleteNodeRequest struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
@ -600,6 +617,10 @@ type EmailAuthticateConfig struct {
|
||||
DomainSuffixList string `json:"domain_suffix_list"`
|
||||
}
|
||||
|
||||
type ExportGroupResultRequest struct {
|
||||
HistoryId *int64 `form:"history_id,omitempty"`
|
||||
}
|
||||
|
||||
type FilterBalanceLogRequest struct {
|
||||
FilterLogParams
|
||||
UserId int64 `form:"user_id,optional"`
|
||||
@ -661,6 +682,7 @@ type FilterNodeListRequest struct {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
Search string `form:"search,omitempty"`
|
||||
NodeGroupId *int64 `form:"node_group_id,omitempty"`
|
||||
}
|
||||
|
||||
type FilterNodeListResponse struct {
|
||||
@ -884,6 +906,37 @@ type GetGlobalConfigResponse struct {
|
||||
WebAd bool `json:"web_ad"`
|
||||
}
|
||||
|
||||
type GetGroupConfigRequest struct {
|
||||
Keys []string `form:"keys,omitempty"`
|
||||
}
|
||||
|
||||
type GetGroupConfigResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
State RecalculationState `json:"state"`
|
||||
}
|
||||
|
||||
type GetGroupHistoryDetailRequest struct {
|
||||
Id int64 `form:"id" validate:"required"`
|
||||
}
|
||||
|
||||
type GetGroupHistoryDetailResponse struct {
|
||||
GroupHistoryDetail
|
||||
}
|
||||
|
||||
type GetGroupHistoryRequest struct {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
GroupMode string `form:"group_mode,omitempty"`
|
||||
TriggerType string `form:"trigger_type,omitempty"`
|
||||
}
|
||||
|
||||
type GetGroupHistoryResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
List []GroupHistory `json:"list"`
|
||||
}
|
||||
|
||||
type GetLoginLogRequest struct {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
@ -906,6 +959,17 @@ type GetMessageLogListResponse struct {
|
||||
List []MessageLog `json:"list"`
|
||||
}
|
||||
|
||||
type GetNodeGroupListRequest struct {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
GroupId string `form:"group_id,omitempty"`
|
||||
}
|
||||
|
||||
type GetNodeGroupListResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
List []NodeGroup `json:"list"`
|
||||
}
|
||||
|
||||
type GetNodeMultiplierResponse struct {
|
||||
Periods []TimePeriod `json:"periods"`
|
||||
}
|
||||
@ -1033,11 +1097,19 @@ type GetSubscribeGroupListResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type GetSubscribeGroupMappingRequest struct {
|
||||
}
|
||||
|
||||
type GetSubscribeGroupMappingResponse struct {
|
||||
List []SubscribeGroupMappingItem `json:"list"`
|
||||
}
|
||||
|
||||
type GetSubscribeListRequest struct {
|
||||
Page int64 `form:"page" validate:"required"`
|
||||
Size int64 `form:"size" validate:"required"`
|
||||
Language string `form:"language,omitempty"`
|
||||
Search string `form:"search,omitempty"`
|
||||
NodeGroupId int64 `form:"node_group_id,omitempty"`
|
||||
}
|
||||
|
||||
type GetSubscribeListResponse struct {
|
||||
@ -1215,6 +1287,25 @@ type GoogleLoginCallbackRequest struct {
|
||||
State string `form:"state"`
|
||||
}
|
||||
|
||||
type GroupHistory struct {
|
||||
Id int64 `json:"id"`
|
||||
GroupMode string `json:"group_mode"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
TotalUsers int `json:"total_users"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
StartTime *int64 `json:"start_time,omitempty"`
|
||||
EndTime *int64 `json:"end_time,omitempty"`
|
||||
Operator string `json:"operator,omitempty"`
|
||||
ErrorLog string `json:"error_log,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type GroupHistoryDetail struct {
|
||||
GroupHistory
|
||||
ConfigSnapshot map[string]interface{} `json:"config_snapshot,omitempty"`
|
||||
}
|
||||
|
||||
type HasMigrateSeverNodeResponse struct {
|
||||
HasMigrate bool `json:"has_migrate"`
|
||||
}
|
||||
@ -1304,6 +1395,8 @@ type Node struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Sort int `json:"sort,omitempty"`
|
||||
NodeGroupId int64 `json:"node_group_id,omitempty"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
@ -1325,6 +1418,25 @@ type NodeDNS struct {
|
||||
Domains []string `json:"domains"`
|
||||
}
|
||||
|
||||
type NodeGroup struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Sort int `json:"sort"`
|
||||
ForCalculation bool `json:"for_calculation"`
|
||||
MinTrafficGB int64 `json:"min_traffic_gb,omitempty"`
|
||||
MaxTrafficGB int64 `json:"max_traffic_gb,omitempty"`
|
||||
NodeCount int64 `json:"node_count,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type NodeGroupItem struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Nodes []Node `json:"nodes"`
|
||||
}
|
||||
|
||||
type NodeOutbound struct {
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"`
|
||||
@ -1535,6 +1647,15 @@ type PreviewSubscribeTemplateResponse struct {
|
||||
Template string `json:"template"` // 预览的模板内容
|
||||
}
|
||||
|
||||
type PreviewUserNodesRequest struct {
|
||||
UserId int64 `form:"user_id" validate:"required"`
|
||||
}
|
||||
|
||||
type PreviewUserNodesResponse struct {
|
||||
UserId int64 `json:"user_id"`
|
||||
NodeGroups []NodeGroupItem `json:"node_groups"`
|
||||
}
|
||||
|
||||
type PrivacyPolicyConfig struct {
|
||||
PrivacyPolicy string `json:"privacy_policy"`
|
||||
}
|
||||
@ -1813,6 +1934,17 @@ type QuotaTask struct {
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type RecalculateGroupRequest struct {
|
||||
Mode string `json:"mode" validate:"required"`
|
||||
TriggerType string `json:"trigger_type"` // "manual" or "scheduled"
|
||||
}
|
||||
|
||||
type RecalculationState struct {
|
||||
State string `json:"state"`
|
||||
Progress int `json:"progress"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type RechargeOrderRequest struct {
|
||||
Amount int64 `json:"amount" validate:"required,gt=0,lte=2000000000"`
|
||||
Payment int64 `json:"payment"`
|
||||
@ -1890,6 +2022,10 @@ type ResetAllSubscribeTokenResponse struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
type ResetGroupsRequest struct {
|
||||
Confirm bool `json:"confirm" validate:"required"`
|
||||
}
|
||||
|
||||
type ResetPasswordRequest struct {
|
||||
Identifier string `json:"identifier"`
|
||||
Email string `json:"email" validate:"required"`
|
||||
@ -2153,6 +2289,8 @@ type Subscribe struct {
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
NodeGroupId int64 `json:"node_group_id"`
|
||||
Show bool `json:"show"`
|
||||
Sell bool `json:"sell"`
|
||||
Sort int64 `json:"sort"`
|
||||
@ -2212,6 +2350,11 @@ type SubscribeGroup struct {
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SubscribeGroupMappingItem struct {
|
||||
SubscribeName string `json:"subscribe_name"`
|
||||
NodeGroupName string `json:"node_group_name"`
|
||||
}
|
||||
|
||||
type SubscribeItem struct {
|
||||
Subscribe
|
||||
Sold int64 `json:"sold"`
|
||||
@ -2465,6 +2608,22 @@ type UpdateDocumentRequest struct {
|
||||
Show *bool `json:"show"`
|
||||
}
|
||||
|
||||
type UpdateGroupConfigRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
type UpdateNodeGroupRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Sort int `json:"sort"`
|
||||
ForCalculation *bool `json:"for_calculation"`
|
||||
MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"`
|
||||
MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateNodeRequest struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@ -2474,6 +2633,7 @@ type UpdateNodeRequest struct {
|
||||
ServerId int64 `json:"server_id"`
|
||||
Protocol string `json:"protocol"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateOrderStatusRequest struct {
|
||||
@ -2551,6 +2711,8 @@ type UpdateSubscribeRequest struct {
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
NodeGroupId int64 `json:"node_group_id"`
|
||||
Show *bool `json:"show"`
|
||||
Sell *bool `json:"sell"`
|
||||
Sort int64 `json:"sort"`
|
||||
|
||||
@ -56,7 +56,9 @@ func (s *service) verify(ctx context.Context, secret string, token string, ip st
|
||||
_ = writer.WriteField("idempotency_key", key)
|
||||
}
|
||||
_ = writer.Close()
|
||||
client := &http.Client{}
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
req, _ := http.NewRequest("POST", s.url, body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
firstResult, err := client.Do(req)
|
||||
|
||||
@ -30,6 +30,7 @@ import (
|
||||
"apis/admin/ads.api"
|
||||
"apis/admin/marketing.api"
|
||||
"apis/admin/application.api"
|
||||
"apis/admin/group.api"
|
||||
"apis/public/user.api"
|
||||
"apis/public/subscribe.api"
|
||||
"apis/public/redemption.api"
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
87
queue/logic/group/recalculateGroupLogic.go
Normal file
87
queue/logic/group/recalculateGroupLogic.go
Normal file
@ -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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -5,4 +5,5 @@ const (
|
||||
SchedulerTotalServerData = "scheduler:total:server"
|
||||
SchedulerResetTraffic = "scheduler:reset:traffic"
|
||||
SchedulerTrafficStat = "scheduler:traffic:stat"
|
||||
SchedulerRecalculateGroup = "scheduler:recalculate:group"
|
||||
)
|
||||
|
||||
@ -52,6 +52,12 @@ func (m *Service) Start() {
|
||||
logger.Errorf("register update exchange rate task failed: %s", err.Error())
|
||||
}
|
||||
|
||||
// schedule recalculate group task: every hour
|
||||
recalculateGroupTask := asynq.NewTask(types.SchedulerRecalculateGroup, nil)
|
||||
if _, err := m.server.Register("@every 6h", recalculateGroupTask, asynq.MaxRetry(2)); err != nil {
|
||||
logger.Errorf("register recalculate group task failed: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := m.server.Run(); err != nil {
|
||||
logger.Errorf("run scheduler failed: %s", err.Error())
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user