Merge remote-tracking branch 'origin/master' into internal
This commit is contained in:
commit
e5e9f93f68
215
apis/admin/group.api
Normal file
215
apis/admin/group.api
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
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"`
|
||||||
|
IsExpiredGroup *bool `json:"is_expired_group"`
|
||||||
|
ExpiredDaysLimit *int `json:"expired_days_limit"`
|
||||||
|
MaxTrafficGBExpired *int64 `json:"max_traffic_gb_expired,omitempty"`
|
||||||
|
SpeedLimit *int `json:"speed_limit"`
|
||||||
|
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"`
|
||||||
|
IsExpiredGroup *bool `json:"is_expired_group"`
|
||||||
|
ExpiredDaysLimit *int `json:"expired_days_limit"`
|
||||||
|
MaxTrafficGBExpired *int64 `json:"max_traffic_gb_expired,omitempty"`
|
||||||
|
SpeedLimit *int `json:"speed_limit"`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
@ -89,6 +89,8 @@ type (
|
|||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
Sort int `json:"sort,omitempty"`
|
Sort int `json:"sort,omitempty"`
|
||||||
|
NodeGroupId int64 `json:"node_group_id,omitempty"`
|
||||||
|
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@ -100,6 +102,7 @@ type (
|
|||||||
ServerId int64 `json:"server_id"`
|
ServerId int64 `json:"server_id"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
|
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||||
}
|
}
|
||||||
UpdateNodeRequest {
|
UpdateNodeRequest {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
@ -110,6 +113,7 @@ type (
|
|||||||
ServerId int64 `json:"server_id"`
|
ServerId int64 `json:"server_id"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
|
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||||
}
|
}
|
||||||
ToggleNodeStatusRequest {
|
ToggleNodeStatusRequest {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
@ -122,6 +126,7 @@ type (
|
|||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
Size int `form:"size"`
|
Size int `form:"size"`
|
||||||
Search string `form:"search,omitempty"`
|
Search string `form:"search,omitempty"`
|
||||||
|
NodeGroupId *int64 `form:"node_group_id,omitempty"`
|
||||||
}
|
}
|
||||||
FilterNodeListResponse {
|
FilterNodeListResponse {
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
|
|||||||
@ -48,6 +48,9 @@ type (
|
|||||||
Quota int64 `json:"quota"`
|
Quota int64 `json:"quota"`
|
||||||
Nodes []int64 `json:"nodes"`
|
Nodes []int64 `json:"nodes"`
|
||||||
NodeTags []string `json:"node_tags"`
|
NodeTags []string `json:"node_tags"`
|
||||||
|
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||||
|
NodeGroupId int64 `json:"node_group_id"`
|
||||||
|
TrafficLimit []TrafficLimit `json:"traffic_limit"`
|
||||||
Show *bool `json:"show"`
|
Show *bool `json:"show"`
|
||||||
Sell *bool `json:"sell"`
|
Sell *bool `json:"sell"`
|
||||||
DeductionRatio int64 `json:"deduction_ratio"`
|
DeductionRatio int64 `json:"deduction_ratio"`
|
||||||
@ -55,6 +58,7 @@ type (
|
|||||||
ResetCycle int64 `json:"reset_cycle"`
|
ResetCycle int64 `json:"reset_cycle"`
|
||||||
RenewalReset *bool `json:"renewal_reset"`
|
RenewalReset *bool `json:"renewal_reset"`
|
||||||
ShowOriginalPrice bool `json:"show_original_price"`
|
ShowOriginalPrice bool `json:"show_original_price"`
|
||||||
|
AutoCreateGroup bool `json:"auto_create_group"`
|
||||||
}
|
}
|
||||||
UpdateSubscribeRequest {
|
UpdateSubscribeRequest {
|
||||||
Id int64 `json:"id" validate:"required"`
|
Id int64 `json:"id" validate:"required"`
|
||||||
@ -72,6 +76,9 @@ type (
|
|||||||
Quota int64 `json:"quota"`
|
Quota int64 `json:"quota"`
|
||||||
Nodes []int64 `json:"nodes"`
|
Nodes []int64 `json:"nodes"`
|
||||||
NodeTags []string `json:"node_tags"`
|
NodeTags []string `json:"node_tags"`
|
||||||
|
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||||
|
NodeGroupId int64 `json:"node_group_id"`
|
||||||
|
TrafficLimit []TrafficLimit `json:"traffic_limit"`
|
||||||
Show *bool `json:"show"`
|
Show *bool `json:"show"`
|
||||||
Sell *bool `json:"sell"`
|
Sell *bool `json:"sell"`
|
||||||
Sort int64 `json:"sort"`
|
Sort int64 `json:"sort"`
|
||||||
@ -89,6 +96,7 @@ type (
|
|||||||
Size int64 `form:"size" validate:"required"`
|
Size int64 `form:"size" validate:"required"`
|
||||||
Language string `form:"language,omitempty"`
|
Language string `form:"language,omitempty"`
|
||||||
Search string `form:"search,omitempty"`
|
Search string `form:"search,omitempty"`
|
||||||
|
NodeGroupId int64 `form:"node_group_id,omitempty"`
|
||||||
}
|
}
|
||||||
SubscribeItem {
|
SubscribeItem {
|
||||||
Subscribe
|
Subscribe
|
||||||
|
|||||||
@ -83,6 +83,8 @@ type (
|
|||||||
OrderId int64 `json:"order_id"`
|
OrderId int64 `json:"order_id"`
|
||||||
SubscribeId int64 `json:"subscribe_id"`
|
SubscribeId int64 `json:"subscribe_id"`
|
||||||
Subscribe Subscribe `json:"subscribe"`
|
Subscribe Subscribe `json:"subscribe"`
|
||||||
|
NodeGroupId int64 `json:"node_group_id"`
|
||||||
|
GroupLocked bool `json:"group_locked"`
|
||||||
StartTime int64 `json:"start_time"`
|
StartTime int64 `json:"start_time"`
|
||||||
ExpireTime int64 `json:"expire_time"`
|
ExpireTime int64 `json:"expire_time"`
|
||||||
ResetTime int64 `json:"reset_time"`
|
ResetTime int64 `json:"reset_time"`
|
||||||
|
|||||||
@ -18,6 +18,8 @@ type (
|
|||||||
UserAgent string `header:"User-Agent"`
|
UserAgent string `header:"User-Agent"`
|
||||||
LoginType string `header:"Login-Type"`
|
LoginType string `header:"Login-Type"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
|
CaptchaId string `json:"captcha_id,optional"`
|
||||||
|
CaptchaCode string `json:"captcha_code,optional"`
|
||||||
}
|
}
|
||||||
// Check user is exist request
|
// Check user is exist request
|
||||||
CheckUserRequest {
|
CheckUserRequest {
|
||||||
@ -38,8 +40,10 @@ type (
|
|||||||
UserAgent string `header:"User-Agent"`
|
UserAgent string `header:"User-Agent"`
|
||||||
LoginType string `header:"Login-Type"`
|
LoginType string `header:"Login-Type"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
|
CaptchaId string `json:"captcha_id,optional"`
|
||||||
|
CaptchaCode string `json:"captcha_code,optional"`
|
||||||
}
|
}
|
||||||
// User login response
|
// User reset password request
|
||||||
ResetPasswordRequest {
|
ResetPasswordRequest {
|
||||||
Identifier string `json:"identifier"`
|
Identifier string `json:"identifier"`
|
||||||
Email string `json:"email" validate:"required"`
|
Email string `json:"email" validate:"required"`
|
||||||
@ -49,6 +53,8 @@ type (
|
|||||||
UserAgent string `header:"User-Agent"`
|
UserAgent string `header:"User-Agent"`
|
||||||
LoginType string `header:"Login-Type"`
|
LoginType string `header:"Login-Type"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
|
CaptchaId string `json:"captcha_id,optional"`
|
||||||
|
CaptchaCode string `json:"captcha_code,optional"`
|
||||||
}
|
}
|
||||||
// Email login request
|
// Email login request
|
||||||
EmailLoginRequest {
|
EmailLoginRequest {
|
||||||
@ -86,6 +92,8 @@ type (
|
|||||||
UserAgent string `header:"User-Agent"`
|
UserAgent string `header:"User-Agent"`
|
||||||
LoginType string `header:"Login-Type"`
|
LoginType string `header:"Login-Type"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
|
CaptchaId string `json:"captcha_id,optional"`
|
||||||
|
CaptchaCode string `json:"captcha_code,optional"`
|
||||||
}
|
}
|
||||||
// Check user is exist request
|
// Check user is exist request
|
||||||
TelephoneCheckUserRequest {
|
TelephoneCheckUserRequest {
|
||||||
@ -108,6 +116,8 @@ type (
|
|||||||
UserAgent string `header:"User-Agent"`
|
UserAgent string `header:"User-Agent"`
|
||||||
LoginType string `header:"Login-Type,optional"`
|
LoginType string `header:"Login-Type,optional"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
|
CaptchaId string `json:"captcha_id,optional"`
|
||||||
|
CaptchaCode string `json:"captcha_code,optional"`
|
||||||
}
|
}
|
||||||
// User login response
|
// User login response
|
||||||
TelephoneResetPasswordRequest {
|
TelephoneResetPasswordRequest {
|
||||||
@ -120,6 +130,8 @@ type (
|
|||||||
UserAgent string `header:"User-Agent"`
|
UserAgent string `header:"User-Agent"`
|
||||||
LoginType string `header:"Login-Type,optional"`
|
LoginType string `header:"Login-Type,optional"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
|
CaptchaId string `json:"captcha_id,optional"`
|
||||||
|
CaptchaCode string `json:"captcha_code,optional"`
|
||||||
}
|
}
|
||||||
AppleLoginCallbackRequest {
|
AppleLoginCallbackRequest {
|
||||||
Code string `form:"code"`
|
Code string `form:"code"`
|
||||||
@ -137,6 +149,11 @@ type (
|
|||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
ShortCode string `json:"short_code,optional"`
|
ShortCode string `json:"short_code,optional"`
|
||||||
}
|
}
|
||||||
|
GenerateCaptchaResponse {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@server (
|
@server (
|
||||||
@ -181,11 +198,34 @@ service ppanel {
|
|||||||
@handler TelephoneResetPassword
|
@handler TelephoneResetPassword
|
||||||
post /reset/telephone (TelephoneResetPasswordRequest) returns (LoginResponse)
|
post /reset/telephone (TelephoneResetPasswordRequest) returns (LoginResponse)
|
||||||
|
|
||||||
|
@doc "Generate captcha"
|
||||||
|
@handler GenerateCaptcha
|
||||||
|
post /captcha/generate returns (GenerateCaptchaResponse)
|
||||||
|
|
||||||
@doc "Device Login"
|
@doc "Device Login"
|
||||||
@handler DeviceLogin
|
@handler DeviceLogin
|
||||||
post /login/device (DeviceLoginRequest) returns (LoginResponse)
|
post /login/device (DeviceLoginRequest) returns (LoginResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@server (
|
||||||
|
prefix: v1/auth/admin
|
||||||
|
group: auth/admin
|
||||||
|
middleware: DeviceMiddleware
|
||||||
|
)
|
||||||
|
service ppanel {
|
||||||
|
@doc "Admin login"
|
||||||
|
@handler AdminLogin
|
||||||
|
post /login (UserLoginRequest) returns (LoginResponse)
|
||||||
|
|
||||||
|
@doc "Admin reset password"
|
||||||
|
@handler AdminResetPassword
|
||||||
|
post /reset (ResetPasswordRequest) returns (LoginResponse)
|
||||||
|
|
||||||
|
@doc "Generate captcha"
|
||||||
|
@handler AdminGenerateCaptcha
|
||||||
|
post /captcha/generate returns (GenerateCaptchaResponse)
|
||||||
|
}
|
||||||
|
|
||||||
@server (
|
@server (
|
||||||
prefix: v1/auth/oauth
|
prefix: v1/auth/oauth
|
||||||
group: auth/oauth
|
group: auth/oauth
|
||||||
|
|||||||
@ -12,10 +12,12 @@ import "./types.api"
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
VeifyConfig {
|
VeifyConfig {
|
||||||
|
CaptchaType string `json:"captcha_type"`
|
||||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||||
EnableLoginVerify bool `json:"enable_login_verify"`
|
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"`
|
||||||
EnableRegisterVerify bool `json:"enable_register_verify"`
|
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"`
|
||||||
EnableResetPasswordVerify bool `json:"enable_reset_password_verify"`
|
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"`
|
||||||
|
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"`
|
||||||
}
|
}
|
||||||
GetGlobalConfigResponse {
|
GetGlobalConfigResponse {
|
||||||
Site SiteConfig `json:"site"`
|
Site SiteConfig `json:"site"`
|
||||||
|
|||||||
@ -170,11 +170,13 @@ type (
|
|||||||
DeviceLimit int64 `json:"device_limit"`
|
DeviceLimit int64 `json:"device_limit"`
|
||||||
}
|
}
|
||||||
VerifyConfig {
|
VerifyConfig {
|
||||||
|
CaptchaType string `json:"captcha_type"` // local or turnstile
|
||||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||||
TurnstileSecret string `json:"turnstile_secret"`
|
TurnstileSecret string `json:"turnstile_secret"`
|
||||||
EnableLoginVerify bool `json:"enable_login_verify"`
|
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` // User login captcha
|
||||||
EnableRegisterVerify bool `json:"enable_register_verify"`
|
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` // User register captcha
|
||||||
EnableResetPasswordVerify bool `json:"enable_reset_password_verify"`
|
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` // Admin login captcha
|
||||||
|
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"` // User reset password captcha
|
||||||
}
|
}
|
||||||
NodeConfig {
|
NodeConfig {
|
||||||
NodeSecret string `json:"node_secret"`
|
NodeSecret string `json:"node_secret"`
|
||||||
@ -226,6 +228,12 @@ type (
|
|||||||
Quantity int64 `json:"quantity"`
|
Quantity int64 `json:"quantity"`
|
||||||
Discount float64 `json:"discount"`
|
Discount float64 `json:"discount"`
|
||||||
}
|
}
|
||||||
|
TrafficLimit {
|
||||||
|
StatType string `json:"stat_type"`
|
||||||
|
StatValue int64 `json:"stat_value"`
|
||||||
|
TrafficUsage int64 `json:"traffic_usage"`
|
||||||
|
SpeedLimit int64 `json:"speed_limit"`
|
||||||
|
}
|
||||||
Subscribe {
|
Subscribe {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@ -243,6 +251,9 @@ type (
|
|||||||
Quota int64 `json:"quota"`
|
Quota int64 `json:"quota"`
|
||||||
Nodes []int64 `json:"nodes"`
|
Nodes []int64 `json:"nodes"`
|
||||||
NodeTags []string `json:"node_tags"`
|
NodeTags []string `json:"node_tags"`
|
||||||
|
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||||
|
NodeGroupId int64 `json:"node_group_id"`
|
||||||
|
TrafficLimit []TrafficLimit `json:"traffic_limit"`
|
||||||
Show bool `json:"show"`
|
Show bool `json:"show"`
|
||||||
Sell bool `json:"sell"`
|
Sell bool `json:"sell"`
|
||||||
Sort int64 `json:"sort"`
|
Sort int64 `json:"sort"`
|
||||||
@ -951,5 +962,42 @@ type (
|
|||||||
ResetUserSubscribeTokenRequest {
|
ResetUserSubscribeTokenRequest {
|
||||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
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"`
|
||||||
|
IsExpiredGroup bool `json:"is_expired_group"`
|
||||||
|
ExpiredDaysLimit int `json:"expired_days_limit"`
|
||||||
|
MaxTrafficGBExpired int64 `json:"max_traffic_gb_expired,omitempty"`
|
||||||
|
SpeedLimit int `json:"speed_limit"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
3
go.mod
3
go.mod
@ -94,6 +94,7 @@ require (
|
|||||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/golang/glog v1.2.0 // indirect
|
github.com/golang/glog v1.2.0 // indirect
|
||||||
github.com/golang/mock v1.6.0 // indirect
|
github.com/golang/mock v1.6.0 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
@ -118,6 +119,7 @@ require (
|
|||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/mojocn/base64Captcha v1.3.8 // indirect
|
||||||
github.com/openzipkin/zipkin-go v0.4.2 // indirect
|
github.com/openzipkin/zipkin-go v0.4.2 // indirect
|
||||||
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
@ -140,6 +142,7 @@ require (
|
|||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.13.0 // indirect
|
golang.org/x/arch v0.13.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d // indirect
|
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d // indirect
|
||||||
|
golang.org/x/image v0.23.0 // indirect
|
||||||
golang.org/x/net v0.34.0 // indirect
|
golang.org/x/net v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
|||||||
31
go.sum
31
go.sum
@ -159,6 +159,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD
|
|||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68=
|
github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68=
|
||||||
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||||
@ -274,6 +276,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
|
|||||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/mojocn/base64Captcha v1.3.8 h1:rrN9BhCwXKS8ht1e21kvR3iTaMgf4qPC9sRoV52bqEg=
|
||||||
|
github.com/mojocn/base64Captcha v1.3.8/go.mod h1:QFZy927L8HVP3+VV5z2b1EAEiv1KxVJKZbAucVgLUy4=
|
||||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
@ -405,12 +409,17 @@ golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPh
|
|||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d h1:N0hmiNbwsSNwHBAvR3QB5w25pUwH4tK0Y/RltD1j1h4=
|
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d h1:N0hmiNbwsSNwHBAvR3QB5w25pUwH4tK0Y/RltD1j1h4=
|
||||||
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
|
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
|
||||||
|
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||||
|
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
@ -419,6 +428,9 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|||||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@ -434,7 +446,10 @@ golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qx
|
|||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
@ -448,6 +463,10 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@ -466,14 +485,21 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@ -481,7 +507,10 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
@ -499,6 +528,8 @@ golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
|||||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|||||||
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`);
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
-- Rollback: restore old verify configuration fields
|
||||||
|
INSERT INTO `system` (`category`, `key`, `value`, `type`, `desc`) VALUES
|
||||||
|
('verify', 'EnableLoginVerify', 'false', 'bool', 'is enable login verify'),
|
||||||
|
('verify', 'EnableRegisterVerify', 'false', 'bool', 'is enable register verify'),
|
||||||
|
('verify', 'EnableResetPasswordVerify', 'false', 'bool', 'is enable reset password verify')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`value` = VALUES(`value`),
|
||||||
|
`desc` = VALUES(`desc`);
|
||||||
|
|
||||||
|
-- Remove new captcha configuration fields
|
||||||
|
DELETE FROM `system` WHERE `category` = 'verify' AND `key` IN (
|
||||||
|
'CaptchaType',
|
||||||
|
'EnableUserLoginCaptcha',
|
||||||
|
'EnableUserRegisterCaptcha',
|
||||||
|
'EnableAdminLoginCaptcha',
|
||||||
|
'EnableUserResetPasswordCaptcha'
|
||||||
|
);
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
-- Add new captcha configuration fields
|
||||||
|
INSERT INTO `system` (`category`, `key`, `value`, `type`, `desc`) VALUES
|
||||||
|
('verify', 'CaptchaType', 'local', 'string', 'Captcha type: local or turnstile'),
|
||||||
|
('verify', 'EnableUserLoginCaptcha', 'false', 'bool', 'Enable captcha for user login'),
|
||||||
|
('verify', 'EnableUserRegisterCaptcha', 'false', 'bool', 'Enable captcha for user registration'),
|
||||||
|
('verify', 'EnableAdminLoginCaptcha', 'false', 'bool', 'Enable captcha for admin login'),
|
||||||
|
('verify', 'EnableUserResetPasswordCaptcha', 'false', 'bool', 'Enable captcha for user reset password')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`value` = VALUES(`value`),
|
||||||
|
`desc` = VALUES(`desc`);
|
||||||
|
|
||||||
|
-- Remove old verify configuration fields
|
||||||
|
DELETE FROM `system` WHERE `category` = 'verify' AND `key` IN (
|
||||||
|
'EnableLoginVerify',
|
||||||
|
'EnableRegisterVerify',
|
||||||
|
'EnableResetPasswordVerify'
|
||||||
|
);
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
-- 回滚 user_subscribe 表的过期流量字段
|
||||||
|
ALTER TABLE `user_subscribe`
|
||||||
|
DROP COLUMN `expired_upload`,
|
||||||
|
DROP COLUMN `expired_download`;
|
||||||
|
|
||||||
|
-- 回滚 node_group 表的过期节点组字段
|
||||||
|
ALTER TABLE `node_group`
|
||||||
|
DROP INDEX `idx_is_expired_group`,
|
||||||
|
DROP COLUMN `speed_limit`,
|
||||||
|
DROP COLUMN `max_traffic_gb_expired`,
|
||||||
|
DROP COLUMN `expired_days_limit`,
|
||||||
|
DROP COLUMN `is_expired_group`;
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
-- 为 node_group 表添加过期节点组相关字段
|
||||||
|
ALTER TABLE `node_group`
|
||||||
|
ADD COLUMN `is_expired_group` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Is Expired Group: 0=normal, 1=expired group' AFTER `for_calculation`,
|
||||||
|
ADD COLUMN `expired_days_limit` int NOT NULL DEFAULT 7 COMMENT 'Expired days limit (days)' AFTER `is_expired_group`,
|
||||||
|
ADD COLUMN `max_traffic_gb_expired` bigint DEFAULT 0 COMMENT 'Max traffic for expired users (GB)' AFTER `expired_days_limit`,
|
||||||
|
ADD COLUMN `speed_limit` int NOT NULL DEFAULT 0 COMMENT 'Speed limit (KB/s)' AFTER `max_traffic_gb_expired`;
|
||||||
|
|
||||||
|
-- 添加索引
|
||||||
|
ALTER TABLE `node_group` ADD INDEX `idx_is_expired_group` (`is_expired_group`);
|
||||||
|
|
||||||
|
-- 为 user_subscribe 表添加过期流量统计字段
|
||||||
|
ALTER TABLE `user_subscribe`
|
||||||
|
ADD COLUMN `expired_download` bigint NOT NULL DEFAULT 0 COMMENT 'Expired period download traffic (bytes)' AFTER `upload`,
|
||||||
|
ADD COLUMN `expired_upload` bigint NOT NULL DEFAULT 0 COMMENT 'Expired period upload traffic (bytes)' AFTER `expired_download`;
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
-- Purpose: Rollback traffic_limit rules from subscribe
|
||||||
|
-- Author: Claude Code
|
||||||
|
-- Date: 2026-03-12
|
||||||
|
|
||||||
|
-- ===== Remove traffic_limit column from subscribe table =====
|
||||||
|
ALTER TABLE `subscribe` DROP COLUMN IF EXISTS `traffic_limit`;
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
-- Purpose: Add traffic_limit rules to subscribe
|
||||||
|
-- Author: Claude Code
|
||||||
|
-- Date: 2026-03-12
|
||||||
|
|
||||||
|
-- ===== Add traffic_limit column to subscribe table =====
|
||||||
|
SET @column_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'subscribe'
|
||||||
|
AND COLUMN_NAME = 'traffic_limit'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
@column_exists = 0,
|
||||||
|
'ALTER TABLE `subscribe` ADD COLUMN `traffic_limit` TEXT NULL COMMENT ''Traffic Limit Rules (JSON)'' AFTER `node_group_id`',
|
||||||
|
'SELECT ''Column traffic_limit already exists in subscribe table'''
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/handler/auth/admin/adminGenerateCaptchaHandler.go
Normal file
18
internal/handler/auth/admin/adminGenerateCaptchaHandler.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/auth/admin"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate captcha
|
||||||
|
func AdminGenerateCaptchaHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
|
||||||
|
l := admin.NewAdminGenerateCaptchaLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.AdminGenerateCaptcha()
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
internal/handler/auth/admin/adminLoginHandler.go
Normal file
30
internal/handler/auth/admin/adminLoginHandler.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
adminLogic "github.com/perfect-panel/server/internal/logic/auth/admin"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Admin login
|
||||||
|
func AdminLoginHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.UserLoginRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
// get client ip
|
||||||
|
req.IP = c.ClientIP()
|
||||||
|
req.UserAgent = c.Request.UserAgent()
|
||||||
|
|
||||||
|
validateErr := svcCtx.Validate(&req)
|
||||||
|
if validateErr != nil {
|
||||||
|
result.ParamErrorResult(c, validateErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := adminLogic.NewAdminLoginLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.AdminLogin(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/handler/auth/admin/adminResetPasswordHandler.go
Normal file
29
internal/handler/auth/admin/adminResetPasswordHandler.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
adminLogic "github.com/perfect-panel/server/internal/logic/auth/admin"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Admin reset password
|
||||||
|
func AdminResetPasswordHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.ResetPasswordRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
validateErr := svcCtx.Validate(&req)
|
||||||
|
if validateErr != nil {
|
||||||
|
result.ParamErrorResult(c, validateErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// get client ip
|
||||||
|
req.IP = c.ClientIP()
|
||||||
|
req.UserAgent = c.Request.UserAgent()
|
||||||
|
|
||||||
|
l := adminLogic.NewAdminResetPasswordLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.AdminResetPassword(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/handler/auth/generateCaptchaHandler.go
Normal file
18
internal/handler/auth/generateCaptchaHandler.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/auth"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate captcha
|
||||||
|
func GenerateCaptchaHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
|
||||||
|
l := auth.NewGenerateCaptchaLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.GenerateCaptcha()
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,16 +1,11 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/perfect-panel/server/internal/logic/auth"
|
"github.com/perfect-panel/server/internal/logic/auth"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/result"
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
"github.com/perfect-panel/server/pkg/turnstile"
|
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reset password
|
// Reset password
|
||||||
@ -25,17 +20,8 @@ func ResetPasswordHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
// get client ip
|
// get client ip
|
||||||
req.IP = c.ClientIP()
|
req.IP = c.ClientIP()
|
||||||
if svcCtx.Config.Verify.ResetPasswordVerify {
|
req.UserAgent = c.Request.UserAgent()
|
||||||
verifyTurns := turnstile.New(turnstile.Config{
|
|
||||||
Secret: svcCtx.Config.Verify.TurnstileSecret,
|
|
||||||
Timeout: 3 * time.Second,
|
|
||||||
})
|
|
||||||
if verify, err := verifyTurns.Verify(c, req.CfToken, req.IP); err != nil || !verify {
|
|
||||||
err = errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "error: %v, verify: %v", err, verify)
|
|
||||||
result.HttpResult(c, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
l := auth.NewResetPasswordLogic(c.Request.Context(), svcCtx)
|
l := auth.NewResetPasswordLogic(c.Request.Context(), svcCtx)
|
||||||
resp, err := l.ResetPassword(&req)
|
resp, err := l.ResetPassword(&req)
|
||||||
result.HttpResult(c, resp, err)
|
result.HttpResult(c, resp, err)
|
||||||
|
|||||||
@ -1,16 +1,11 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/perfect-panel/server/internal/logic/auth"
|
"github.com/perfect-panel/server/internal/logic/auth"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/result"
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
"github.com/perfect-panel/server/pkg/turnstile"
|
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// User Telephone login
|
// User Telephone login
|
||||||
|
|||||||
@ -1,14 +1,13 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/perfect-panel/server/internal/logic/auth"
|
"github.com/perfect-panel/server/internal/logic/auth"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/captcha"
|
||||||
"github.com/perfect-panel/server/pkg/result"
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
"github.com/perfect-panel/server/pkg/turnstile"
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
@ -25,17 +24,44 @@ func TelephoneResetPasswordHandler(svcCtx *svc.ServiceContext) func(c *gin.Conte
|
|||||||
}
|
}
|
||||||
// get client ip
|
// get client ip
|
||||||
req.IP = c.ClientIP()
|
req.IP = c.ClientIP()
|
||||||
if svcCtx.Config.Verify.ResetPasswordVerify {
|
|
||||||
verifyTurns := turnstile.New(turnstile.Config{
|
// Get verify config from database
|
||||||
Secret: svcCtx.Config.Verify.TurnstileSecret,
|
verifyCfg, err := svcCtx.SystemModel.GetVerifyConfig(c.Request.Context())
|
||||||
Timeout: 3 * time.Second,
|
if err != nil {
|
||||||
|
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get verify config failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var config struct {
|
||||||
|
CaptchaType string `json:"captcha_type"`
|
||||||
|
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"`
|
||||||
|
TurnstileSecret string `json:"turnstile_secret"`
|
||||||
|
}
|
||||||
|
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config)
|
||||||
|
|
||||||
|
// Verify captcha if enabled
|
||||||
|
if config.EnableUserResetPasswordCaptcha {
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaType(config.CaptchaType),
|
||||||
|
TurnstileSecret: config.TurnstileSecret,
|
||||||
|
RedisClient: svcCtx.Redis,
|
||||||
})
|
})
|
||||||
if verify, err := verifyTurns.Verify(c.Request.Context(), req.CfToken, req.IP); err != nil || !verify {
|
|
||||||
err = errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "error: %v, verify: %v", err, verify)
|
var token, code string
|
||||||
result.HttpResult(c, nil, err)
|
if config.CaptchaType == "turnstile" {
|
||||||
|
token = req.CfToken
|
||||||
|
} else {
|
||||||
|
token = req.CaptchaId
|
||||||
|
code = req.CaptchaCode
|
||||||
|
}
|
||||||
|
|
||||||
|
verified, err := captchaService.Verify(c.Request.Context(), token, code, req.IP)
|
||||||
|
if err != nil || !verified {
|
||||||
|
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "captcha verification failed: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
l := auth.NewTelephoneResetPasswordLogic(c, svcCtx)
|
l := auth.NewTelephoneResetPasswordLogic(c, svcCtx)
|
||||||
resp, err := l.TelephoneResetPassword(&req)
|
resp, err := l.TelephoneResetPassword(&req)
|
||||||
result.HttpResult(c, resp, err)
|
result.HttpResult(c, resp, err)
|
||||||
|
|||||||
@ -1,16 +1,11 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/perfect-panel/server/internal/logic/auth"
|
"github.com/perfect-panel/server/internal/logic/auth"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/result"
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
"github.com/perfect-panel/server/pkg/turnstile"
|
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// User Telephone register
|
// User Telephone register
|
||||||
@ -26,17 +21,7 @@ func TelephoneUserRegisterHandler(svcCtx *svc.ServiceContext) func(c *gin.Contex
|
|||||||
// get client ip
|
// get client ip
|
||||||
req.IP = c.ClientIP()
|
req.IP = c.ClientIP()
|
||||||
req.UserAgent = c.Request.UserAgent()
|
req.UserAgent = c.Request.UserAgent()
|
||||||
if svcCtx.Config.Verify.RegisterVerify {
|
|
||||||
verifyTurns := turnstile.New(turnstile.Config{
|
|
||||||
Secret: svcCtx.Config.Verify.TurnstileSecret,
|
|
||||||
Timeout: 3 * time.Second,
|
|
||||||
})
|
|
||||||
if verify, err := verifyTurns.Verify(c, req.CfToken, req.IP); err != nil || !verify {
|
|
||||||
err = errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "error: %v, verify: %v", err, verify)
|
|
||||||
result.HttpResult(c, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
l := auth.NewTelephoneUserRegisterLogic(c.Request.Context(), svcCtx)
|
l := auth.NewTelephoneUserRegisterLogic(c.Request.Context(), svcCtx)
|
||||||
resp, err := l.TelephoneUserRegister(&req)
|
resp, err := l.TelephoneUserRegister(&req)
|
||||||
result.HttpResult(c, resp, err)
|
result.HttpResult(c, resp, err)
|
||||||
|
|||||||
@ -1,16 +1,11 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/perfect-panel/server/internal/logic/auth"
|
"github.com/perfect-panel/server/internal/logic/auth"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/result"
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
"github.com/perfect-panel/server/pkg/turnstile"
|
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// User login
|
// User login
|
||||||
@ -21,17 +16,7 @@ func UserLoginHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
|||||||
// get client ip
|
// get client ip
|
||||||
req.IP = c.ClientIP()
|
req.IP = c.ClientIP()
|
||||||
req.UserAgent = c.Request.UserAgent()
|
req.UserAgent = c.Request.UserAgent()
|
||||||
if svcCtx.Config.Verify.LoginVerify && !svcCtx.Config.Debug {
|
|
||||||
verifyTurns := turnstile.New(turnstile.Config{
|
|
||||||
Secret: svcCtx.Config.Verify.TurnstileSecret,
|
|
||||||
Timeout: 3 * time.Second,
|
|
||||||
})
|
|
||||||
if verify, err := verifyTurns.Verify(c, req.CfToken, req.IP); err != nil || !verify {
|
|
||||||
err = errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "error: %v, verify: %v", err, verify)
|
|
||||||
result.HttpResult(c, nil, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
validateErr := svcCtx.Validate(&req)
|
validateErr := svcCtx.Validate(&req)
|
||||||
if validateErr != nil {
|
if validateErr != nil {
|
||||||
result.ParamErrorResult(c, validateErr)
|
result.ParamErrorResult(c, validateErr)
|
||||||
|
|||||||
@ -1,16 +1,11 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/perfect-panel/server/internal/logic/auth"
|
"github.com/perfect-panel/server/internal/logic/auth"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/result"
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
"github.com/perfect-panel/server/pkg/turnstile"
|
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// User register
|
// User register
|
||||||
@ -21,16 +16,7 @@ func UserRegisterHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
|||||||
// get client ip
|
// get client ip
|
||||||
req.IP = c.ClientIP()
|
req.IP = c.ClientIP()
|
||||||
req.UserAgent = c.Request.UserAgent()
|
req.UserAgent = c.Request.UserAgent()
|
||||||
if svcCtx.Config.Verify.RegisterVerify {
|
|
||||||
verifyTurns := turnstile.New(turnstile.Config{
|
|
||||||
Secret: svcCtx.Config.Verify.TurnstileSecret,
|
|
||||||
Timeout: 3 * time.Second,
|
|
||||||
})
|
|
||||||
if verify, err := verifyTurns.Verify(c, req.CfToken, req.IP); err != nil || !verify {
|
|
||||||
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "verify error: %v", err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
validateErr := svcCtx.Validate(&req)
|
validateErr := svcCtx.Validate(&req)
|
||||||
if validateErr != nil {
|
if validateErr != nil {
|
||||||
result.ParamErrorResult(c, validateErr)
|
result.ParamErrorResult(c, validateErr)
|
||||||
|
|||||||
26
internal/handler/public/user/getUserTrafficStatsHandler.go
Normal file
26
internal/handler/public/user/getUserTrafficStatsHandler.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/public/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get User Traffic Statistics
|
||||||
|
func GetUserTrafficStatsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.GetUserTrafficStatsRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
validateErr := svcCtx.Validate(&req)
|
||||||
|
if validateErr != nil {
|
||||||
|
result.ParamErrorResult(c, validateErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := user.NewGetUserTrafficStatsLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.GetUserTrafficStats(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import (
|
|||||||
adminConsole "github.com/perfect-panel/server/internal/handler/admin/console"
|
adminConsole "github.com/perfect-panel/server/internal/handler/admin/console"
|
||||||
adminCoupon "github.com/perfect-panel/server/internal/handler/admin/coupon"
|
adminCoupon "github.com/perfect-panel/server/internal/handler/admin/coupon"
|
||||||
adminDocument "github.com/perfect-panel/server/internal/handler/admin/document"
|
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"
|
adminLog "github.com/perfect-panel/server/internal/handler/admin/log"
|
||||||
adminMarketing "github.com/perfect-panel/server/internal/handler/admin/marketing"
|
adminMarketing "github.com/perfect-panel/server/internal/handler/admin/marketing"
|
||||||
adminOrder "github.com/perfect-panel/server/internal/handler/admin/order"
|
adminOrder "github.com/perfect-panel/server/internal/handler/admin/order"
|
||||||
@ -24,6 +25,7 @@ import (
|
|||||||
adminTool "github.com/perfect-panel/server/internal/handler/admin/tool"
|
adminTool "github.com/perfect-panel/server/internal/handler/admin/tool"
|
||||||
adminUser "github.com/perfect-panel/server/internal/handler/admin/user"
|
adminUser "github.com/perfect-panel/server/internal/handler/admin/user"
|
||||||
auth "github.com/perfect-panel/server/internal/handler/auth"
|
auth "github.com/perfect-panel/server/internal/handler/auth"
|
||||||
|
authAdmin "github.com/perfect-panel/server/internal/handler/auth/admin"
|
||||||
authOauth "github.com/perfect-panel/server/internal/handler/auth/oauth"
|
authOauth "github.com/perfect-panel/server/internal/handler/auth/oauth"
|
||||||
common "github.com/perfect-panel/server/internal/handler/common"
|
common "github.com/perfect-panel/server/internal/handler/common"
|
||||||
publicAnnouncement "github.com/perfect-panel/server/internal/handler/public/announcement"
|
publicAnnouncement "github.com/perfect-panel/server/internal/handler/public/announcement"
|
||||||
@ -189,6 +191,53 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
adminDocumentGroupRouter.GET("/list", adminDocument.GetDocumentListHandler(serverCtx))
|
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 := router.Group("/v1/admin/log")
|
||||||
adminLogGroupRouter.Use(middleware.AuthMiddleware(serverCtx))
|
adminLogGroupRouter.Use(middleware.AuthMiddleware(serverCtx))
|
||||||
|
|
||||||
@ -659,6 +708,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
authGroupRouter.Use(middleware.DeviceMiddleware(serverCtx))
|
authGroupRouter.Use(middleware.DeviceMiddleware(serverCtx))
|
||||||
|
|
||||||
{
|
{
|
||||||
|
// Generate captcha
|
||||||
|
authGroupRouter.POST("/captcha/generate", auth.GenerateCaptchaHandler(serverCtx))
|
||||||
|
|
||||||
// Check user is exist
|
// Check user is exist
|
||||||
authGroupRouter.GET("/check", auth.CheckUserHandler(serverCtx))
|
authGroupRouter.GET("/check", auth.CheckUserHandler(serverCtx))
|
||||||
|
|
||||||
@ -690,6 +742,20 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
authGroupRouter.POST("/reset/telephone", auth.TelephoneResetPasswordHandler(serverCtx))
|
authGroupRouter.POST("/reset/telephone", auth.TelephoneResetPasswordHandler(serverCtx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authAdminGroupRouter := router.Group("/v1/auth/admin")
|
||||||
|
authAdminGroupRouter.Use(middleware.DeviceMiddleware(serverCtx))
|
||||||
|
|
||||||
|
{
|
||||||
|
// Generate captcha
|
||||||
|
authAdminGroupRouter.POST("/captcha/generate", authAdmin.AdminGenerateCaptchaHandler(serverCtx))
|
||||||
|
|
||||||
|
// Admin login
|
||||||
|
authAdminGroupRouter.POST("/login", authAdmin.AdminLoginHandler(serverCtx))
|
||||||
|
|
||||||
|
// Admin reset password
|
||||||
|
authAdminGroupRouter.POST("/reset", authAdmin.AdminResetPasswordHandler(serverCtx))
|
||||||
|
}
|
||||||
|
|
||||||
authOauthGroupRouter := router.Group("/v1/auth/oauth")
|
authOauthGroupRouter := router.Group("/v1/auth/oauth")
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -980,6 +1046,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
// Reset User Subscribe Token
|
// Reset User Subscribe Token
|
||||||
publicUserGroupRouter.PUT("/subscribe_token", publicUser.ResetUserSubscribeTokenHandler(serverCtx))
|
publicUserGroupRouter.PUT("/subscribe_token", publicUser.ResetUserSubscribeTokenHandler(serverCtx))
|
||||||
|
|
||||||
|
// Get User Traffic Statistics
|
||||||
|
publicUserGroupRouter.GET("/traffic_stats", publicUser.GetUserTrafficStatsHandler(serverCtx))
|
||||||
|
|
||||||
// Unbind Device
|
// Unbind Device
|
||||||
publicUserGroupRouter.PUT("/unbind_device", publicUser.UnbindDeviceHandler(serverCtx))
|
publicUserGroupRouter.PUT("/unbind_device", publicUser.UnbindDeviceHandler(serverCtx))
|
||||||
|
|
||||||
|
|||||||
81
internal/logic/admin/group/createNodeGroupLogic.go
Normal file
81
internal/logic/admin/group/createNodeGroupLogic.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// 验证:系统中只能有一个过期节点组
|
||||||
|
if req.IsExpiredGroup != nil && *req.IsExpiredGroup {
|
||||||
|
var count int64
|
||||||
|
err := l.svcCtx.DB.Model(&group.NodeGroup{}).
|
||||||
|
Where("is_expired_group = ?", true).
|
||||||
|
Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to check expired group count: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return errors.New("system already has an expired node group, cannot create multiple")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建节点组
|
||||||
|
nodeGroup := &group.NodeGroup{
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
Sort: req.Sort,
|
||||||
|
ForCalculation: req.ForCalculation,
|
||||||
|
IsExpiredGroup: req.IsExpiredGroup,
|
||||||
|
MaxTrafficGBExpired: req.MaxTrafficGBExpired,
|
||||||
|
MinTrafficGB: req.MinTrafficGB,
|
||||||
|
MaxTrafficGB: req.MaxTrafficGB,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置过期节点组的默认值
|
||||||
|
if req.IsExpiredGroup != nil && *req.IsExpiredGroup {
|
||||||
|
// 过期节点组不参与分组计算
|
||||||
|
falseValue := false
|
||||||
|
nodeGroup.ForCalculation = &falseValue
|
||||||
|
|
||||||
|
if req.ExpiredDaysLimit != nil {
|
||||||
|
nodeGroup.ExpiredDaysLimit = *req.ExpiredDaysLimit
|
||||||
|
} else {
|
||||||
|
nodeGroup.ExpiredDaysLimit = 7 // 默认7天
|
||||||
|
}
|
||||||
|
if req.SpeedLimit != nil {
|
||||||
|
nodeGroup.SpeedLimit = *req.SpeedLimit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
62
internal/logic/admin/group/deleteNodeGroupLogic.go
Normal file
62
internal/logic/admin/group/deleteNodeGroupLogic.go
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
package group
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/group"
|
||||||
|
"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"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有关联节点(使用JSON_CONTAINS查询node_group_ids数组)
|
||||||
|
var nodeCount int64
|
||||||
|
if err := l.svcCtx.DB.Model(&node.Node{}).Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", 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 // 自动提交
|
||||||
|
})
|
||||||
|
}
|
||||||
129
internal/logic/admin/group/exportGroupResultLogic.go
Normal file
129
internal/logic/admin/group/exportGroupResultLogic.go
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
package group
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/group"
|
||||||
|
"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/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.Model(&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
|
||||||
|
}
|
||||||
103
internal/logic/admin/group/getNodeGroupListLogic.go
Normal file
103
internal/logic/admin/group/getNodeGroupListLogic.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package group
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/group"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// 统计该组的节点数(JSON数组查询)
|
||||||
|
var nodeCount int64
|
||||||
|
l.svcCtx.DB.Model(&node.Node{}).Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", ng.Id)).Count(&nodeCount)
|
||||||
|
|
||||||
|
// 处理指针类型的字段
|
||||||
|
var forCalculation bool
|
||||||
|
if ng.ForCalculation != nil {
|
||||||
|
forCalculation = *ng.ForCalculation
|
||||||
|
} else {
|
||||||
|
forCalculation = true // 默认值
|
||||||
|
}
|
||||||
|
|
||||||
|
var isExpiredGroup bool
|
||||||
|
if ng.IsExpiredGroup != nil {
|
||||||
|
isExpiredGroup = *ng.IsExpiredGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
var minTrafficGB, maxTrafficGB, maxTrafficGBExpired int64
|
||||||
|
if ng.MinTrafficGB != nil {
|
||||||
|
minTrafficGB = *ng.MinTrafficGB
|
||||||
|
}
|
||||||
|
if ng.MaxTrafficGB != nil {
|
||||||
|
maxTrafficGB = *ng.MaxTrafficGB
|
||||||
|
}
|
||||||
|
if ng.MaxTrafficGBExpired != nil {
|
||||||
|
maxTrafficGBExpired = *ng.MaxTrafficGBExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
list = append(list, types.NodeGroup{
|
||||||
|
Id: ng.Id,
|
||||||
|
Name: ng.Name,
|
||||||
|
Description: ng.Description,
|
||||||
|
Sort: ng.Sort,
|
||||||
|
ForCalculation: forCalculation,
|
||||||
|
IsExpiredGroup: isExpiredGroup,
|
||||||
|
ExpiredDaysLimit: ng.ExpiredDaysLimit,
|
||||||
|
MaxTrafficGBExpired: maxTrafficGBExpired,
|
||||||
|
SpeedLimit: ng.SpeedLimit,
|
||||||
|
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.Model(&subscribe.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.Model(&group.NodeGroup{}).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
|
||||||
|
}
|
||||||
585
internal/logic/admin/group/previewUserNodesLogic.go
Normal file
585
internal/logic/admin/group/previewUserNodesLogic.go
Normal file
@ -0,0 +1,585 @@
|
|||||||
|
package group
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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/user"
|
||||||
|
"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.Model(&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
|
||||||
|
Nodes string // JSON string - 直接分配的节点ID
|
||||||
|
NodeTags string // 节点标签
|
||||||
|
}
|
||||||
|
var subscribeInfos []SubscribeInfo
|
||||||
|
err = l.svcCtx.DB.Model(&subscribe.Subscribe{}).
|
||||||
|
Select("id, node_group_id, node_group_ids, nodes, node_tags").
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 3. 收集所有订阅中直接分配的节点ID
|
||||||
|
var allDirectNodeIds []int64
|
||||||
|
for _, subInfo := range subscribeInfos {
|
||||||
|
if subInfo.Nodes != "" && subInfo.Nodes != "null" {
|
||||||
|
// nodes 是逗号分隔的字符串,如 "1,2,3"
|
||||||
|
nodeIdStrs := strings.Split(subInfo.Nodes, ",")
|
||||||
|
for _, idStr := range nodeIdStrs {
|
||||||
|
idStr = strings.TrimSpace(idStr)
|
||||||
|
if idStr != "" {
|
||||||
|
var nodeId int64
|
||||||
|
if _, err := fmt.Sscanf(idStr, "%d", &nodeId); err == nil {
|
||||||
|
allDirectNodeIds = append(allDirectNodeIds, nodeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Debugf("[PreviewUserNodes] subscribe_id=%d has direct nodes: %s", subInfo.Id, subInfo.Nodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 去重
|
||||||
|
allDirectNodeIds = removeDuplicateInt64(allDirectNodeIds)
|
||||||
|
logger.Infof("[PreviewUserNodes] collected direct node_ids: %v", allDirectNodeIds)
|
||||||
|
|
||||||
|
// 4. 判断分组功能是否启用
|
||||||
|
type SystemConfig struct {
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
var config SystemConfig
|
||||||
|
l.svcCtx.DB.Model(&struct {
|
||||||
|
Category string `gorm:"column:category"`
|
||||||
|
Key string `gorm:"column:key"`
|
||||||
|
Value string `gorm:"column:value"`
|
||||||
|
}{}).
|
||||||
|
Table("system").
|
||||||
|
Where("`category` = ? AND `key` = ?", "group", "enabled").
|
||||||
|
Select("value").
|
||||||
|
Scan(&config)
|
||||||
|
|
||||||
|
logger.Infof("[PreviewUserNodes] groupEnabled: %v", config.Value)
|
||||||
|
|
||||||
|
isGroupEnabled := config.Value == "true" || config.Value == "1"
|
||||||
|
|
||||||
|
var filteredNodes []node.Node
|
||||||
|
|
||||||
|
if isGroupEnabled {
|
||||||
|
// === 启用分组功能:通过用户订阅的 node_group_id 查询节点 ===
|
||||||
|
logger.Infof("[PreviewUserNodes] using group-based node filtering")
|
||||||
|
|
||||||
|
if len(allNodeGroupIds) == 0 && len(allDirectNodeIds) == 0 {
|
||||||
|
logger.Infof("[PreviewUserNodes] no node groups and no direct nodes found in user subscribes")
|
||||||
|
resp = &types.PreviewUserNodesResponse{
|
||||||
|
UserId: req.UserId,
|
||||||
|
NodeGroups: []types.NodeGroupItem{},
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 查询所有启用的节点(只有当有节点组时才查询)
|
||||||
|
if len(allNodeGroupIds) > 0 {
|
||||||
|
var dbNodes []node.Node
|
||||||
|
err = l.svcCtx.DB.Model(&node.Node{}).
|
||||||
|
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")
|
||||||
|
|
||||||
|
// 从已查询的 subscribeInfos 中获取 node_tags
|
||||||
|
var allTags []string
|
||||||
|
for _, subInfo := range subscribeInfos {
|
||||||
|
if subInfo.NodeTags != "" {
|
||||||
|
tags := strings.Split(subInfo.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 && len(allDirectNodeIds) == 0 {
|
||||||
|
logger.Infof("[PreviewUserNodes] no tags and no direct nodes found in subscribes")
|
||||||
|
resp = &types.PreviewUserNodesResponse{
|
||||||
|
UserId: req.UserId,
|
||||||
|
NodeGroups: []types.NodeGroupItem{},
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 查询所有启用的节点(只有当有 tags 时才查询)
|
||||||
|
if len(allTags) > 0 {
|
||||||
|
var dbNodes []node.Node
|
||||||
|
err = l.svcCtx.DB.Model(&node.Node{}).
|
||||||
|
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. 根据是否启用分组功能,选择不同的分组方式
|
||||||
|
nodeGroupItems := make([]types.NodeGroupItem, 0)
|
||||||
|
|
||||||
|
if isGroupEnabled {
|
||||||
|
// === 启用分组:按节点组分组 ===
|
||||||
|
// 转换为 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: n.NodeGroupIds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按节点组分组节点
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询节点组信息并构建响应
|
||||||
|
nodeGroupInfoMap := make(map[int64]string)
|
||||||
|
validGroupIds := make([]int64, 0)
|
||||||
|
|
||||||
|
if len(allGroupIds) > 0 {
|
||||||
|
type NodeGroupInfo struct {
|
||||||
|
Id int64
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
var nodeGroupInfos []NodeGroupInfo
|
||||||
|
err = l.svcCtx.DB.Model(&group.NodeGroup{}).
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建响应:根据有效节点组ID重新分组节点
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// === 未启用分组:按 tag 分组 ===
|
||||||
|
// 按 tag 分组节点
|
||||||
|
tagGroupMap := make(map[string][]types.Node)
|
||||||
|
|
||||||
|
for _, n := range filteredNodes {
|
||||||
|
tags := []string{}
|
||||||
|
if n.Tags != "" {
|
||||||
|
tags = strings.Split(n.Tags, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换节点
|
||||||
|
node := types.Node{
|
||||||
|
Id: n.Id,
|
||||||
|
Name: n.Name,
|
||||||
|
Tags: tags,
|
||||||
|
Port: n.Port,
|
||||||
|
Address: n.Address,
|
||||||
|
ServerId: n.ServerId,
|
||||||
|
Protocol: n.Protocol,
|
||||||
|
Enabled: n.Enabled,
|
||||||
|
Sort: n.Sort,
|
||||||
|
NodeGroupIds: []int64(n.NodeGroupIds),
|
||||||
|
CreatedAt: n.CreatedAt.Unix(),
|
||||||
|
UpdatedAt: n.UpdatedAt.Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将节点添加到每个匹配的 tag 分组中
|
||||||
|
if len(tags) > 0 {
|
||||||
|
for _, tag := range tags {
|
||||||
|
tag = strings.TrimSpace(tag)
|
||||||
|
if tag != "" {
|
||||||
|
tagGroupMap[tag] = append(tagGroupMap[tag], node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没有 tag 的节点放入特殊分组
|
||||||
|
tagGroupMap[""] = append(tagGroupMap[""], node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建响应:按 tag 分组
|
||||||
|
for tag, nodes := range tagGroupMap {
|
||||||
|
nodeGroupItems = append(nodeGroupItems, types.NodeGroupItem{
|
||||||
|
Id: 0, // tag 分组使用 ID 0
|
||||||
|
Name: tag,
|
||||||
|
Nodes: nodes,
|
||||||
|
})
|
||||||
|
logger.Infof("[PreviewUserNodes] adding tag group: tag=%s, nodes=%d", tag, len(nodes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加套餐节点组(直接分配的节点)
|
||||||
|
if len(allDirectNodeIds) > 0 {
|
||||||
|
// 查询直接分配的节点详情
|
||||||
|
var directNodes []node.Node
|
||||||
|
err = l.svcCtx.DB.Model(&node.Node{}).
|
||||||
|
Where("id IN ? AND enabled = ?", allDirectNodeIds, true).
|
||||||
|
Find(&directNodes).Error
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("[PreviewUserNodes] failed to get direct nodes: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(directNodes) > 0 {
|
||||||
|
// 转换为 types.Node
|
||||||
|
directNodeItems := make([]types.Node, 0, len(directNodes))
|
||||||
|
for _, n := range directNodes {
|
||||||
|
tags := []string{}
|
||||||
|
if n.Tags != "" {
|
||||||
|
tags = strings.Split(n.Tags, ",")
|
||||||
|
}
|
||||||
|
directNodeItems = append(directNodeItems, types.Node{
|
||||||
|
Id: n.Id,
|
||||||
|
Name: n.Name,
|
||||||
|
Tags: tags,
|
||||||
|
Port: n.Port,
|
||||||
|
Address: n.Address,
|
||||||
|
ServerId: n.ServerId,
|
||||||
|
Protocol: n.Protocol,
|
||||||
|
Enabled: n.Enabled,
|
||||||
|
Sort: n.Sort,
|
||||||
|
NodeGroupIds: []int64(n.NodeGroupIds),
|
||||||
|
CreatedAt: n.CreatedAt.Unix(),
|
||||||
|
UpdatedAt: n.UpdatedAt.Unix(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加套餐节点组(使用特殊ID -1,Name 为空字符串,前端根据 ID -1 进行国际化)
|
||||||
|
nodeGroupItems = append(nodeGroupItems, types.NodeGroupItem{
|
||||||
|
Id: -1,
|
||||||
|
Name: "", // 空字符串,前端根据 ID -1 识别并国际化
|
||||||
|
Nodes: directNodeItems,
|
||||||
|
})
|
||||||
|
logger.Infof("[PreviewUserNodes] adding subscription nodes group: nodes=%d", len(directNodeItems))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
818
internal/logic/admin/group/recalculateGroupLogic.go
Normal file
818
internal/logic/admin/group/recalculateGroupLogic.go
Normal file
@ -0,0 +1,818 @@
|
|||||||
|
package group
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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/user"
|
||||||
|
"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.Model(&user.AuthMethods{}).
|
||||||
|
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.Model(&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.Model(&group.NodeGroup{}).
|
||||||
|
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.Model(&subscribe.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.Model(&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.Model(&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.Model(&node.Node{}).
|
||||||
|
Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", 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.Model(&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.Model(&group.NodeGroup{}).
|
||||||
|
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.Model(&subscribe.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.Model(&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.Model(&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.Model(&node.Node{}).
|
||||||
|
Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", 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.Model(&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.Model(&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
|
||||||
|
}
|
||||||
185
internal/logic/admin/group/updateNodeGroupLogic.go
Normal file
185
internal/logic/admin/group/updateNodeGroupLogic.go
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
package group
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证:系统中只能有一个过期节点组
|
||||||
|
if req.IsExpiredGroup != nil && *req.IsExpiredGroup {
|
||||||
|
var count int64
|
||||||
|
err := l.svcCtx.DB.Model(&group.NodeGroup{}).
|
||||||
|
Where("is_expired_group = ? AND id != ?", true, req.Id).
|
||||||
|
Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to check expired group count: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return errors.New("system already has an expired node group, cannot create multiple")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证:被订阅商品设置为默认节点组的不能设置为过期节点组
|
||||||
|
var subscribeCount int64
|
||||||
|
err = l.svcCtx.DB.Model(&subscribe.Subscribe{}).
|
||||||
|
Where("node_group_id = ?", req.Id).
|
||||||
|
Count(&subscribeCount).Error
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("failed to check subscribe usage: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if subscribeCount > 0 {
|
||||||
|
return errors.New("this node group is used as default node group in subscription products, cannot set as expired group")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建更新数据
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if req.IsExpiredGroup != nil {
|
||||||
|
updates["is_expired_group"] = *req.IsExpiredGroup
|
||||||
|
// 过期节点组不参与分组计算
|
||||||
|
if *req.IsExpiredGroup {
|
||||||
|
updates["for_calculation"] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.ExpiredDaysLimit != nil {
|
||||||
|
updates["expired_days_limit"] = *req.ExpiredDaysLimit
|
||||||
|
}
|
||||||
|
if req.MaxTrafficGBExpired != nil {
|
||||||
|
updates["max_traffic_gb_expired"] = *req.MaxTrafficGBExpired
|
||||||
|
}
|
||||||
|
if req.SpeedLimit != nil {
|
||||||
|
updates["speed_limit"] = *req.SpeedLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取新的流量区间值
|
||||||
|
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,
|
Address: req.Address,
|
||||||
ServerId: req.ServerId,
|
ServerId: req.ServerId,
|
||||||
Protocol: req.Protocol,
|
Protocol: req.Protocol,
|
||||||
|
NodeGroupIds: node.JSONInt64Slice(req.NodeGroupIds),
|
||||||
}
|
}
|
||||||
err := l.svcCtx.NodeModel.InsertNode(l.ctx, &data)
|
err := l.svcCtx.NodeModel.InsertNode(l.ctx, &data)
|
||||||
if err != nil {
|
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) {
|
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{
|
total, data, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{
|
||||||
Page: req.Page,
|
Page: req.Page,
|
||||||
Size: req.Size,
|
Size: req.Size,
|
||||||
Search: req.Search,
|
Search: req.Search,
|
||||||
|
NodeGroupIds: nodeGroupIds,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -52,6 +59,7 @@ func (l *FilterNodeListLogic) FilterNodeList(req *types.FilterNodeListRequest) (
|
|||||||
Protocol: datum.Protocol,
|
Protocol: datum.Protocol,
|
||||||
Enabled: datum.Enabled,
|
Enabled: datum.Enabled,
|
||||||
Sort: datum.Sort,
|
Sort: datum.Sort,
|
||||||
|
NodeGroupIds: []int64(datum.NodeGroupIds),
|
||||||
CreatedAt: datum.CreatedAt.UnixMilli(),
|
CreatedAt: datum.CreatedAt.UnixMilli(),
|
||||||
UpdatedAt: datum.UpdatedAt.UnixMilli(),
|
UpdatedAt: datum.UpdatedAt.UnixMilli(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -40,6 +40,7 @@ func (l *UpdateNodeLogic) UpdateNode(req *types.UpdateNodeRequest) error {
|
|||||||
data.Address = req.Address
|
data.Address = req.Address
|
||||||
data.Protocol = req.Protocol
|
data.Protocol = req.Protocol
|
||||||
data.Enabled = req.Enabled
|
data.Enabled = req.Enabled
|
||||||
|
data.NodeGroupIds = node.JSONInt64Slice(req.NodeGroupIds)
|
||||||
err = l.svcCtx.NodeModel.UpdateNode(l.ctx, data)
|
err = l.svcCtx.NodeModel.UpdateNode(l.ctx, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[UpdateNode] Update Database Error: ", logger.Field("error", err.Error()))
|
l.Errorw("[UpdateNode] Update Database Error: ", logger.Field("error", err.Error()))
|
||||||
|
|||||||
@ -34,6 +34,12 @@ func (l *CreateSubscribeLogic) CreateSubscribe(req *types.CreateSubscribeRequest
|
|||||||
val, _ := json.Marshal(req.Discount)
|
val, _ := json.Marshal(req.Discount)
|
||||||
discount = string(val)
|
discount = string(val)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trafficLimit := ""
|
||||||
|
if len(req.TrafficLimit) > 0 {
|
||||||
|
val, _ := json.Marshal(req.TrafficLimit)
|
||||||
|
trafficLimit = string(val)
|
||||||
|
}
|
||||||
sub := &subscribe.Subscribe{
|
sub := &subscribe.Subscribe{
|
||||||
Id: 0,
|
Id: 0,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@ -51,6 +57,9 @@ func (l *CreateSubscribeLogic) CreateSubscribe(req *types.CreateSubscribeRequest
|
|||||||
NewUserOnly: req.NewUserOnly,
|
NewUserOnly: req.NewUserOnly,
|
||||||
Nodes: tool.Int64SliceToString(req.Nodes),
|
Nodes: tool.Int64SliceToString(req.Nodes),
|
||||||
NodeTags: tool.StringSliceToString(req.NodeTags),
|
NodeTags: tool.StringSliceToString(req.NodeTags),
|
||||||
|
NodeGroupIds: subscribe.JSONInt64Slice(req.NodeGroupIds),
|
||||||
|
NodeGroupId: req.NodeGroupId,
|
||||||
|
TrafficLimit: trafficLimit,
|
||||||
Show: req.Show,
|
Show: req.Show,
|
||||||
Sell: req.Sell,
|
Sell: req.Sell,
|
||||||
Sort: 0,
|
Sort: 0,
|
||||||
|
|||||||
@ -42,6 +42,12 @@ func (l *GetSubscribeDetailsLogic) GetSubscribeDetails(req *types.GetSubscribeDe
|
|||||||
l.Logger.Error("[GetSubscribeDetailsLogic] JSON unmarshal failed: ", logger.Field("error", err.Error()), logger.Field("discount", sub.Discount))
|
l.Logger.Error("[GetSubscribeDetailsLogic] JSON unmarshal failed: ", logger.Field("error", err.Error()), logger.Field("discount", sub.Discount))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if sub.TrafficLimit != "" {
|
||||||
|
err = json.Unmarshal([]byte(sub.TrafficLimit), &resp.TrafficLimit)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[GetSubscribeDetailsLogic] JSON unmarshal failed: ", logger.Field("error", err.Error()), logger.Field("traffic_limit", sub.TrafficLimit))
|
||||||
|
}
|
||||||
|
}
|
||||||
resp.Nodes = tool.StringToInt64Slice(sub.Nodes)
|
resp.Nodes = tool.StringToInt64Slice(sub.Nodes)
|
||||||
resp.NodeTags = strings.Split(sub.NodeTags, ",")
|
resp.NodeTags = strings.Split(sub.NodeTags, ",")
|
||||||
return resp, nil
|
return resp, nil
|
||||||
|
|||||||
@ -30,12 +30,20 @@ func NewGetSubscribeListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *GetSubscribeListLogic) GetSubscribeList(req *types.GetSubscribeListRequest) (resp *types.GetSubscribeListResponse, err error) {
|
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),
|
Page: int(req.Page),
|
||||||
Size: int(req.Size),
|
Size: int(req.Size),
|
||||||
Language: req.Language,
|
Language: req.Language,
|
||||||
Search: req.Search,
|
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 {
|
if err != nil {
|
||||||
l.Logger.Error("[GetSubscribeListLogic] get subscribe list failed: ", logger.Field("error", err.Error()))
|
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())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get subscribe list failed: %v", err.Error())
|
||||||
@ -54,8 +62,22 @@ func (l *GetSubscribeListLogic) GetSubscribeList(req *types.GetSubscribeListRequ
|
|||||||
l.Logger.Error("[GetSubscribeListLogic] JSON unmarshal failed: ", logger.Field("error", err.Error()), logger.Field("discount", item.Discount))
|
l.Logger.Error("[GetSubscribeListLogic] JSON unmarshal failed: ", logger.Field("error", err.Error()), logger.Field("discount", item.Discount))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if item.TrafficLimit != "" {
|
||||||
|
err = json.Unmarshal([]byte(item.TrafficLimit), &sub.TrafficLimit)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[GetSubscribeListLogic] JSON unmarshal failed: ", logger.Field("error", err.Error()), logger.Field("traffic_limit", item.TrafficLimit))
|
||||||
|
}
|
||||||
|
}
|
||||||
sub.Nodes = tool.StringToInt64Slice(item.Nodes)
|
sub.Nodes = tool.StringToInt64Slice(item.Nodes)
|
||||||
sub.NodeTags = strings.Split(item.NodeTags, ",")
|
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)
|
resultList = append(resultList, sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,12 @@ func (l *UpdateSubscribeLogic) UpdateSubscribe(req *types.UpdateSubscribeRequest
|
|||||||
val, _ := json.Marshal(req.Discount)
|
val, _ := json.Marshal(req.Discount)
|
||||||
discount = string(val)
|
discount = string(val)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trafficLimit := ""
|
||||||
|
if len(req.TrafficLimit) > 0 {
|
||||||
|
val, _ := json.Marshal(req.TrafficLimit)
|
||||||
|
trafficLimit = string(val)
|
||||||
|
}
|
||||||
sub := &subscribe.Subscribe{
|
sub := &subscribe.Subscribe{
|
||||||
Id: req.Id,
|
Id: req.Id,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@ -59,6 +65,9 @@ func (l *UpdateSubscribeLogic) UpdateSubscribe(req *types.UpdateSubscribeRequest
|
|||||||
NewUserOnly: req.NewUserOnly,
|
NewUserOnly: req.NewUserOnly,
|
||||||
Nodes: tool.Int64SliceToString(req.Nodes),
|
Nodes: tool.Int64SliceToString(req.Nodes),
|
||||||
NodeTags: tool.StringSliceToString(req.NodeTags),
|
NodeTags: tool.StringSliceToString(req.NodeTags),
|
||||||
|
NodeGroupIds: subscribe.JSONInt64Slice(req.NodeGroupIds),
|
||||||
|
NodeGroupId: req.NodeGroupId,
|
||||||
|
TrafficLimit: trafficLimit,
|
||||||
Show: req.Show,
|
Show: req.Show,
|
||||||
Sell: req.Sell,
|
Sell: req.Sell,
|
||||||
Sort: req.Sort,
|
Sort: req.Sort,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
@ -64,6 +65,7 @@ func (l *CreateUserSubscribeLogic) CreateUserSubscribe(req *types.CreateUserSubs
|
|||||||
Upload: 0,
|
Upload: 0,
|
||||||
Token: uuidx.SubscribeToken(fmt.Sprintf("adminCreate:%d", time.Now().UnixMilli())),
|
Token: uuidx.SubscribeToken(fmt.Sprintf("adminCreate:%d", time.Now().UnixMilli())),
|
||||||
UUID: uuid.New().String(),
|
UUID: uuid.New().String(),
|
||||||
|
NodeGroupId: sub.NodeGroupId,
|
||||||
Status: 1,
|
Status: 1,
|
||||||
}
|
}
|
||||||
if err = l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub); err != nil {
|
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())
|
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)
|
err = l.svcCtx.UserModel.UpdateUserCache(l.ctx, userInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("UpdateUserCache error", logger.Field("error", err.Error()))
|
l.Errorw("UpdateUserCache error", logger.Field("error", err.Error()))
|
||||||
@ -81,5 +137,6 @@ func (l *CreateUserSubscribeLogic) CreateUserSubscribe(req *types.CreateUserSubs
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorw("ClearSubscribe error", logger.Field("error", err.Error()))
|
logger.Errorw("ClearSubscribe error", logger.Field("error", err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,6 +53,7 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs
|
|||||||
Token: userSub.Token,
|
Token: userSub.Token,
|
||||||
UUID: userSub.UUID,
|
UUID: userSub.UUID,
|
||||||
Status: userSub.Status,
|
Status: userSub.Status,
|
||||||
|
NodeGroupId: userSub.NodeGroupId,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -81,5 +82,6 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs
|
|||||||
l.Errorf("ClearServerAllCache error: %v", err.Error())
|
l.Errorf("ClearServerAllCache error: %v", err.Error())
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "failed to clear server cache: %v", err.Error())
|
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "failed to clear server cache: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
70
internal/logic/auth/admin/adminGenerateCaptchaLogic.go
Normal file
70
internal/logic/auth/admin/adminGenerateCaptchaLogic.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/captcha"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminGenerateCaptchaLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate captcha
|
||||||
|
func NewAdminGenerateCaptchaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AdminGenerateCaptchaLogic {
|
||||||
|
return &AdminGenerateCaptchaLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *AdminGenerateCaptchaLogic) AdminGenerateCaptcha() (resp *types.GenerateCaptchaResponse, err error) {
|
||||||
|
resp = &types.GenerateCaptchaResponse{}
|
||||||
|
|
||||||
|
// Get verify config from database
|
||||||
|
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[AdminGenerateCaptchaLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var config struct {
|
||||||
|
CaptchaType string `json:"captcha_type"`
|
||||||
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||||
|
TurnstileSecret string `json:"turnstile_secret"`
|
||||||
|
}
|
||||||
|
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config)
|
||||||
|
|
||||||
|
resp.Type = config.CaptchaType
|
||||||
|
|
||||||
|
// If captcha type is local, generate captcha image
|
||||||
|
if config.CaptchaType == "local" {
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeLocal,
|
||||||
|
RedisClient: l.svcCtx.Redis,
|
||||||
|
})
|
||||||
|
|
||||||
|
id, image, err := captchaService.Generate(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[AdminGenerateCaptchaLogic] Generate captcha error: ", logger.Field("error", err.Error()))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Generate captcha error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Id = id
|
||||||
|
resp.Image = image
|
||||||
|
} else if config.CaptchaType == "turnstile" {
|
||||||
|
// For Turnstile, just return the site key
|
||||||
|
resp.Id = config.TurnstileSiteKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
201
internal/logic/auth/admin/adminLoginLogic.go
Normal file
201
internal/logic/auth/admin/adminLoginLogic.go
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/auth"
|
||||||
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
|
"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/captcha"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/perfect-panel/server/pkg/jwt"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
|
"github.com/perfect-panel/server/pkg/uuidx"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminLoginLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin login
|
||||||
|
func NewAdminLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AdminLoginLogic {
|
||||||
|
return &AdminLoginLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *AdminLoginLogic) AdminLogin(req *types.UserLoginRequest) (resp *types.LoginResponse, err error) {
|
||||||
|
loginStatus := false
|
||||||
|
var userInfo *user.User
|
||||||
|
// Record login status
|
||||||
|
defer func(svcCtx *svc.ServiceContext) {
|
||||||
|
if userInfo != nil && userInfo.Id != 0 {
|
||||||
|
loginLog := log.Login{
|
||||||
|
Method: "email",
|
||||||
|
LoginIP: req.IP,
|
||||||
|
UserAgent: req.UserAgent,
|
||||||
|
Success: loginStatus,
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
content, _ := loginLog.Marshal()
|
||||||
|
if err := l.svcCtx.LogModel.Insert(l.ctx, &log.SystemLog{
|
||||||
|
Type: log.TypeLogin.Uint8(),
|
||||||
|
Date: time.Now().Format("2006-01-02"),
|
||||||
|
ObjectID: userInfo.Id,
|
||||||
|
Content: string(content),
|
||||||
|
}); err != nil {
|
||||||
|
l.Errorw("failed to insert login log",
|
||||||
|
logger.Field("user_id", userInfo.Id),
|
||||||
|
logger.Field("ip", req.IP),
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(l.svcCtx)
|
||||||
|
|
||||||
|
// Verify captcha
|
||||||
|
if err := l.verifyCaptcha(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
||||||
|
|
||||||
|
if userInfo.DeletedAt.Valid {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email deleted: %v", req.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.As(err, &gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email not exist: %v", req.Email)
|
||||||
|
}
|
||||||
|
logger.WithContext(l.ctx).Error(err)
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user info failed: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
if userInfo.IsAdmin == nil || !*userInfo.IsAdmin {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.PermissionDenied), "user is not admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind device to user if identifier is provided
|
||||||
|
if req.Identifier != "" {
|
||||||
|
bindLogic := auth.NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||||
|
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
|
||||||
|
l.Errorw("failed to bind device to user",
|
||||||
|
logger.Field("user_id", userInfo.Id),
|
||||||
|
logger.Field("identifier", req.Identifier),
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
)
|
||||||
|
// Don't fail login if device binding fails, just log the error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.ctx.Value(constant.CtxLoginType) != nil {
|
||||||
|
req.LoginType = l.ctx.Value(constant.CtxLoginType).(string)
|
||||||
|
}
|
||||||
|
// Generate session id
|
||||||
|
sessionId := uuidx.NewUUID().String()
|
||||||
|
// Generate token
|
||||||
|
token, err := jwt.NewJwtToken(
|
||||||
|
l.svcCtx.Config.JwtAuth.AccessSecret,
|
||||||
|
time.Now().Unix(),
|
||||||
|
l.svcCtx.Config.JwtAuth.AccessExpire,
|
||||||
|
jwt.WithOption("UserId", userInfo.Id),
|
||||||
|
jwt.WithOption("SessionId", sessionId),
|
||||||
|
jwt.WithOption("identifier", req.Identifier),
|
||||||
|
jwt.WithOption("CtxLoginType", req.LoginType),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[AdminLogin] token generate error", logger.Field("error", err.Error()))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
|
||||||
|
}
|
||||||
|
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||||
|
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||||
|
}
|
||||||
|
loginStatus = true
|
||||||
|
return &types.LoginResponse{
|
||||||
|
Token: token,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *AdminLoginLogic) verifyCaptcha(req *types.UserLoginRequest) error {
|
||||||
|
// Get verify config from database
|
||||||
|
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[AdminLoginLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var config struct {
|
||||||
|
CaptchaType string `json:"captcha_type"`
|
||||||
|
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"`
|
||||||
|
TurnstileSecret string `json:"turnstile_secret"`
|
||||||
|
}
|
||||||
|
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config)
|
||||||
|
|
||||||
|
// Check if captcha is enabled for admin login
|
||||||
|
if !config.EnableAdminLoginCaptcha {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify based on captcha type
|
||||||
|
if config.CaptchaType == "local" {
|
||||||
|
if req.CaptchaId == "" || req.CaptchaCode == "" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required")
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeLocal,
|
||||||
|
RedisClient: l.svcCtx.Redis,
|
||||||
|
})
|
||||||
|
|
||||||
|
valid, err := captchaService.Verify(l.ctx, req.CaptchaId, req.CaptchaCode, req.IP)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[AdminLoginLogic] Verify captcha error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha")
|
||||||
|
}
|
||||||
|
} else if config.CaptchaType == "turnstile" {
|
||||||
|
if req.CfToken == "" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required")
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeTurnstile,
|
||||||
|
TurnstileSecret: config.TurnstileSecret,
|
||||||
|
})
|
||||||
|
|
||||||
|
valid, err := captchaService.Verify(l.ctx, req.CfToken, "", req.IP)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[AdminLoginLogic] Verify turnstile error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
229
internal/logic/auth/admin/adminResetPasswordLogic.go
Normal file
229
internal/logic/auth/admin/adminResetPasswordLogic.go
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/auth"
|
||||||
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
|
"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/captcha"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/perfect-panel/server/pkg/jwt"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
|
"github.com/perfect-panel/server/pkg/uuidx"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminResetPasswordLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
type CacheKeyPayload struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin reset password
|
||||||
|
func NewAdminResetPasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AdminResetPasswordLogic {
|
||||||
|
return &AdminResetPasswordLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *AdminResetPasswordLogic) AdminResetPassword(req *types.ResetPasswordRequest) (resp *types.LoginResponse, err error) {
|
||||||
|
var userInfo *user.User
|
||||||
|
loginStatus := false
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if userInfo != nil && userInfo.Id != 0 && loginStatus {
|
||||||
|
loginLog := log.Login{
|
||||||
|
Method: "email",
|
||||||
|
LoginIP: req.IP,
|
||||||
|
UserAgent: req.UserAgent,
|
||||||
|
Success: loginStatus,
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
content, _ := loginLog.Marshal()
|
||||||
|
if err := l.svcCtx.LogModel.Insert(l.ctx, &log.SystemLog{
|
||||||
|
Id: 0,
|
||||||
|
Type: log.TypeLogin.Uint8(),
|
||||||
|
Date: time.Now().Format("2006-01-02"),
|
||||||
|
ObjectID: userInfo.Id,
|
||||||
|
Content: string(content),
|
||||||
|
}); err != nil {
|
||||||
|
l.Errorw("failed to insert login log",
|
||||||
|
logger.Field("user_id", userInfo.Id),
|
||||||
|
logger.Field("ip", req.IP),
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.Security, req.Email)
|
||||||
|
// Check the verification code
|
||||||
|
if value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result(); err != nil {
|
||||||
|
l.Errorw("Verification code error", logger.Field("cacheKey", cacheKey), logger.Field("error", err.Error()))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "Verification code error")
|
||||||
|
} else {
|
||||||
|
var payload CacheKeyPayload
|
||||||
|
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
||||||
|
l.Errorw("Unmarshal errors", logger.Field("cacheKey", cacheKey), logger.Field("error", err.Error()), logger.Field("value", value))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "Verification code error")
|
||||||
|
}
|
||||||
|
if payload.Code != req.Code {
|
||||||
|
l.Errorw("Verification code error", logger.Field("cacheKey", cacheKey), logger.Field("error", "Verification code error"), logger.Field("reqCode", req.Code), logger.Field("payloadCode", payload.Code))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "Verification code error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify captcha
|
||||||
|
if err := l.verifyCaptcha(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user
|
||||||
|
authMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email not exist: %v", req.Email)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find user by email error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo, err = l.svcCtx.UserModel.FindOne(l.ctx, authMethod.UserId)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email not exist: %v", req.Email)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user info failed: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
if userInfo.IsAdmin == nil || !*userInfo.IsAdmin {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.PermissionDenied), "user is not admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
userInfo.Password = tool.EncodePassWord(req.Password)
|
||||||
|
userInfo.Algo = "default"
|
||||||
|
if err = l.svcCtx.UserModel.Update(l.ctx, userInfo); err != nil {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update user info failed: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind device to user if identifier is provided
|
||||||
|
if req.Identifier != "" {
|
||||||
|
bindLogic := auth.NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||||
|
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
|
||||||
|
l.Errorw("failed to bind device to user",
|
||||||
|
logger.Field("user_id", userInfo.Id),
|
||||||
|
logger.Field("identifier", req.Identifier),
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
)
|
||||||
|
// Don't fail register if device binding fails, just log the error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.ctx.Value(constant.CtxLoginType) != nil {
|
||||||
|
req.LoginType = l.ctx.Value(constant.CtxLoginType).(string)
|
||||||
|
}
|
||||||
|
// Generate session id
|
||||||
|
sessionId := uuidx.NewUUID().String()
|
||||||
|
// Generate token
|
||||||
|
token, err := jwt.NewJwtToken(
|
||||||
|
l.svcCtx.Config.JwtAuth.AccessSecret,
|
||||||
|
time.Now().Unix(),
|
||||||
|
l.svcCtx.Config.JwtAuth.AccessExpire,
|
||||||
|
jwt.WithOption("UserId", userInfo.Id),
|
||||||
|
jwt.WithOption("SessionId", sessionId),
|
||||||
|
jwt.WithOption("identifier", req.Identifier),
|
||||||
|
jwt.WithOption("CtxLoginType", req.LoginType),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[AdminResetPassword] token generate error", logger.Field("error", err.Error()))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
|
||||||
|
}
|
||||||
|
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||||
|
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||||
|
}
|
||||||
|
loginStatus = true
|
||||||
|
return &types.LoginResponse{
|
||||||
|
Token: token,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *AdminResetPasswordLogic) verifyCaptcha(req *types.ResetPasswordRequest) error {
|
||||||
|
// Get verify config from database
|
||||||
|
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[AdminResetPasswordLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var config struct {
|
||||||
|
CaptchaType string `json:"captcha_type"`
|
||||||
|
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"`
|
||||||
|
TurnstileSecret string `json:"turnstile_secret"`
|
||||||
|
}
|
||||||
|
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config)
|
||||||
|
|
||||||
|
// Check if admin login captcha is enabled (use admin login captcha for reset password)
|
||||||
|
if !config.EnableAdminLoginCaptcha {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify based on captcha type
|
||||||
|
if config.CaptchaType == "local" {
|
||||||
|
if req.CaptchaId == "" || req.CaptchaCode == "" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required")
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeLocal,
|
||||||
|
RedisClient: l.svcCtx.Redis,
|
||||||
|
})
|
||||||
|
|
||||||
|
valid, err := captchaService.Verify(l.ctx, req.CaptchaId, req.CaptchaCode, req.IP)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[AdminResetPasswordLogic] Verify captcha error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha")
|
||||||
|
}
|
||||||
|
} else if config.CaptchaType == "turnstile" {
|
||||||
|
if req.CfToken == "" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required")
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeTurnstile,
|
||||||
|
TurnstileSecret: config.TurnstileSecret,
|
||||||
|
})
|
||||||
|
|
||||||
|
valid, err := captchaService.Verify(l.ctx, req.CfToken, "", req.IP)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[AdminResetPasswordLogic] Verify turnstile error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
70
internal/logic/auth/generateCaptchaLogic.go
Normal file
70
internal/logic/auth/generateCaptchaLogic.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/captcha"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GenerateCaptchaLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate captcha
|
||||||
|
func NewGenerateCaptchaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateCaptchaLogic {
|
||||||
|
return &GenerateCaptchaLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *GenerateCaptchaLogic) GenerateCaptcha() (resp *types.GenerateCaptchaResponse, err error) {
|
||||||
|
resp = &types.GenerateCaptchaResponse{}
|
||||||
|
|
||||||
|
// Get verify config from database
|
||||||
|
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[GenerateCaptchaLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var config struct {
|
||||||
|
CaptchaType string `json:"captcha_type"`
|
||||||
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||||
|
TurnstileSecret string `json:"turnstile_secret"`
|
||||||
|
}
|
||||||
|
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config)
|
||||||
|
|
||||||
|
resp.Type = config.CaptchaType
|
||||||
|
|
||||||
|
// If captcha type is local, generate captcha image
|
||||||
|
if config.CaptchaType == "local" {
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeLocal,
|
||||||
|
RedisClient: l.svcCtx.Redis,
|
||||||
|
})
|
||||||
|
|
||||||
|
id, image, err := captchaService.Generate(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[GenerateCaptchaLogic] Generate captcha error: ", logger.Field("error", err.Error()))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Generate captcha error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Id = id
|
||||||
|
resp.Image = image
|
||||||
|
} else if config.CaptchaType == "turnstile" {
|
||||||
|
// For Turnstile, just return the site key
|
||||||
|
resp.Id = config.TurnstileSiteKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@ -16,6 +16,10 @@ func registerIpLimit(svcCtx *svc.ServiceContext, ctx context.Context, registerIp
|
|||||||
return true
|
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
|
// Use a sorted set to track IP registrations with timestamp as score
|
||||||
// Key format: register:ip:{ip}
|
// Key format: register:ip:{ip}
|
||||||
key := fmt.Sprintf("%s%s", config.RegisterIpKeyPrefix, registerIp)
|
key := fmt.Sprintf("%s%s", config.RegisterIpKeyPrefix, registerIp)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/pkg/captcha"
|
||||||
"github.com/perfect-panel/server/pkg/jwt"
|
"github.com/perfect-panel/server/pkg/jwt"
|
||||||
"github.com/perfect-panel/server/pkg/uuidx"
|
"github.com/perfect-panel/server/pkg/uuidx"
|
||||||
|
|
||||||
@ -91,6 +92,11 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
|
|||||||
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify captcha
|
||||||
|
if err := l.verifyCaptcha(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Check user
|
// Check user
|
||||||
authMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
|
authMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -155,3 +161,68 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
|
|||||||
Token: token,
|
Token: token,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *ResetPasswordLogic) verifyCaptcha(req *types.ResetPasswordRequest) error {
|
||||||
|
// Get verify config from database
|
||||||
|
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[ResetPasswordLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var config struct {
|
||||||
|
CaptchaType string `json:"captcha_type"`
|
||||||
|
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"`
|
||||||
|
TurnstileSecret string `json:"turnstile_secret"`
|
||||||
|
}
|
||||||
|
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config)
|
||||||
|
|
||||||
|
// Check if user reset password captcha is enabled
|
||||||
|
if !config.EnableUserResetPasswordCaptcha {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify based on captcha type
|
||||||
|
if config.CaptchaType == "local" {
|
||||||
|
if req.CaptchaId == "" || req.CaptchaCode == "" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required")
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeLocal,
|
||||||
|
RedisClient: l.svcCtx.Redis,
|
||||||
|
})
|
||||||
|
|
||||||
|
valid, err := captchaService.Verify(l.ctx, req.CaptchaId, req.CaptchaCode, req.IP)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[ResetPasswordLogic] Verify captcha error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha")
|
||||||
|
}
|
||||||
|
} else if config.CaptchaType == "turnstile" {
|
||||||
|
if req.CfToken == "" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required")
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeTurnstile,
|
||||||
|
TurnstileSecret: config.TurnstileSecret,
|
||||||
|
})
|
||||||
|
|
||||||
|
valid, err := captchaService.Verify(l.ctx, req.CfToken, "", req.IP)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[ResetPasswordLogic] Verify turnstile error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/captcha"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"github.com/perfect-panel/server/pkg/jwt"
|
"github.com/perfect-panel/server/pkg/jwt"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@ -94,6 +95,11 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r
|
|||||||
return nil, xerr.NewErrCodeMsg(xerr.InvalidParams, "password and telephone code is empty")
|
return nil, xerr.NewErrCodeMsg(xerr.InvalidParams, "password and telephone code is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify captcha
|
||||||
|
if err := l.verifyCaptcha(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if req.TelephoneCode == "" {
|
if req.TelephoneCode == "" {
|
||||||
// Verify password
|
// Verify password
|
||||||
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) {
|
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) {
|
||||||
@ -164,3 +170,67 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r
|
|||||||
Token: token,
|
Token: token,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *TelephoneLoginLogic) verifyCaptcha(req *types.TelephoneLoginRequest) error {
|
||||||
|
// Get verify config from database
|
||||||
|
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[TelephoneLoginLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var config struct {
|
||||||
|
CaptchaType string `json:"captcha_type"`
|
||||||
|
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"`
|
||||||
|
TurnstileSecret string `json:"turnstile_secret"`
|
||||||
|
}
|
||||||
|
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config)
|
||||||
|
|
||||||
|
// Check if captcha is enabled for user login
|
||||||
|
if !config.EnableUserLoginCaptcha {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify based on captcha type
|
||||||
|
if config.CaptchaType == "local" {
|
||||||
|
if req.CaptchaId == "" || req.CaptchaCode == "" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required")
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeLocal,
|
||||||
|
RedisClient: l.svcCtx.Redis,
|
||||||
|
})
|
||||||
|
|
||||||
|
valid, err := captchaService.Verify(l.ctx, req.CaptchaId, req.CaptchaCode, req.IP)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[TelephoneLoginLogic] Verify captcha error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha")
|
||||||
|
}
|
||||||
|
} else if config.CaptchaType == "turnstile" {
|
||||||
|
if req.CfToken == "" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required")
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeTurnstile,
|
||||||
|
TurnstileSecret: config.TurnstileSecret,
|
||||||
|
})
|
||||||
|
|
||||||
|
valid, err := captchaService.Verify(l.ctx, req.CfToken, "", req.IP)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[TelephoneLoginLogic] Verify turnstile error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/captcha"
|
||||||
"github.com/perfect-panel/server/pkg/jwt"
|
"github.com/perfect-panel/server/pkg/jwt"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
"github.com/perfect-panel/server/pkg/phone"
|
"github.com/perfect-panel/server/pkg/phone"
|
||||||
@ -81,6 +82,12 @@ func (l *TelephoneUserRegisterLogic) TelephoneUserRegister(req *types.TelephoneR
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
|
||||||
}
|
}
|
||||||
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
||||||
|
|
||||||
|
// Verify captcha
|
||||||
|
if err := l.verifyCaptcha(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the user exists
|
// Check if the user exists
|
||||||
_, err = l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "mobile", phoneNumber)
|
_, err = l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "mobile", phoneNumber)
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@ -280,3 +287,67 @@ func (l *TelephoneUserRegisterLogic) activeTrial(uid int64) (*user.Subscribe, er
|
|||||||
return userSub, nil
|
return userSub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *TelephoneUserRegisterLogic) verifyCaptcha(req *types.TelephoneRegisterRequest) error {
|
||||||
|
// Get verify config from database
|
||||||
|
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[TelephoneUserRegisterLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var config struct {
|
||||||
|
CaptchaType string `json:"captcha_type"`
|
||||||
|
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"`
|
||||||
|
TurnstileSecret string `json:"turnstile_secret"`
|
||||||
|
}
|
||||||
|
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config)
|
||||||
|
|
||||||
|
// Check if captcha is enabled for user register
|
||||||
|
if !config.EnableUserRegisterCaptcha {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify based on captcha type
|
||||||
|
if config.CaptchaType == "local" {
|
||||||
|
if req.CaptchaId == "" || req.CaptchaCode == "" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required")
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeLocal,
|
||||||
|
RedisClient: l.svcCtx.Redis,
|
||||||
|
})
|
||||||
|
|
||||||
|
valid, err := captchaService.Verify(l.ctx, req.CaptchaId, req.CaptchaCode, req.IP)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[TelephoneUserRegisterLogic] Verify captcha error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha")
|
||||||
|
}
|
||||||
|
} else if config.CaptchaType == "turnstile" {
|
||||||
|
if req.CfToken == "" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required")
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeTurnstile,
|
||||||
|
TurnstileSecret: config.TurnstileSecret,
|
||||||
|
})
|
||||||
|
|
||||||
|
valid, err := captchaService.Verify(l.ctx, req.CfToken, "", req.IP)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[TelephoneUserRegisterLogic] Verify turnstile error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
|
"github.com/perfect-panel/server/pkg/captcha"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
|
||||||
@ -66,6 +67,11 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
|||||||
}
|
}
|
||||||
}(l.svcCtx)
|
}(l.svcCtx)
|
||||||
|
|
||||||
|
// Verify captcha
|
||||||
|
if err := l.verifyCaptcha(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@ -134,3 +140,67 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
|||||||
Token: token,
|
Token: token,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *UserLoginLogic) verifyCaptcha(req *types.UserLoginRequest) error {
|
||||||
|
// Get verify config from database
|
||||||
|
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[UserLoginLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var config struct {
|
||||||
|
CaptchaType string `json:"captcha_type"`
|
||||||
|
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"`
|
||||||
|
TurnstileSecret string `json:"turnstile_secret"`
|
||||||
|
}
|
||||||
|
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config)
|
||||||
|
|
||||||
|
// Check if captcha is enabled for user login
|
||||||
|
if !config.EnableUserLoginCaptcha {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify based on captcha type
|
||||||
|
if config.CaptchaType == "local" {
|
||||||
|
if req.CaptchaId == "" || req.CaptchaCode == "" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required")
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeLocal,
|
||||||
|
RedisClient: l.svcCtx.Redis,
|
||||||
|
})
|
||||||
|
|
||||||
|
valid, err := captchaService.Verify(l.ctx, req.CaptchaId, req.CaptchaCode, req.IP)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[UserLoginLogic] Verify captcha error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha")
|
||||||
|
}
|
||||||
|
} else if config.CaptchaType == "turnstile" {
|
||||||
|
if req.CfToken == "" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required")
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeTurnstile,
|
||||||
|
TurnstileSecret: config.TurnstileSecret,
|
||||||
|
})
|
||||||
|
|
||||||
|
valid, err := captchaService.Verify(l.ctx, req.CfToken, "", req.IP)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[UserLoginLogic] Verify turnstile error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -8,11 +8,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/config"
|
"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/logic/common"
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/captcha"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"github.com/perfect-panel/server/pkg/jwt"
|
"github.com/perfect-panel/server/pkg/jwt"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@ -85,6 +87,12 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
|
|||||||
}
|
}
|
||||||
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify captcha
|
||||||
|
if err := l.verifyCaptcha(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the user exists
|
// Check if the user exists
|
||||||
u, err := l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
u, err := l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@ -132,22 +140,76 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
|
|||||||
return err
|
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
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// Clear cache after transaction success
|
||||||
if l.svcCtx.Config.Register.EnableTrial && trialSubscribe != nil {
|
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
|
// Clear user subscription cache
|
||||||
if err = l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, trialSubscribe); err != nil {
|
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))
|
l.Errorw("ClearSubscribeCache failed", logger.Field("error", err.Error()), logger.Field("userSubscribeId", trialSubscribe.Id))
|
||||||
@ -202,7 +264,7 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
|
|||||||
}
|
}
|
||||||
loginStatus := true
|
loginStatus := true
|
||||||
defer func() {
|
defer func() {
|
||||||
if token != "" && userInfo.Id != 0 {
|
if token != "" && userInfo != nil && userInfo.Id != 0 {
|
||||||
loginLog := log.Login{
|
loginLog := log.Login{
|
||||||
Method: "email",
|
Method: "email",
|
||||||
LoginIP: req.IP,
|
LoginIP: req.IP,
|
||||||
@ -275,3 +337,67 @@ func (l *UserRegisterLogic) activeTrial(uid int64) (*user.Subscribe, error) {
|
|||||||
}
|
}
|
||||||
return userSub, nil
|
return userSub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *UserRegisterLogic) verifyCaptcha(req *types.UserRegisterRequest) error {
|
||||||
|
// Get verify config from database
|
||||||
|
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[UserRegisterLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var config struct {
|
||||||
|
CaptchaType string `json:"captcha_type"`
|
||||||
|
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"`
|
||||||
|
TurnstileSecret string `json:"turnstile_secret"`
|
||||||
|
}
|
||||||
|
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config)
|
||||||
|
|
||||||
|
// Check if user register captcha is enabled
|
||||||
|
if !config.EnableUserRegisterCaptcha {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify based on captcha type
|
||||||
|
if config.CaptchaType == "local" {
|
||||||
|
if req.CaptchaId == "" || req.CaptchaCode == "" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required")
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeLocal,
|
||||||
|
RedisClient: l.svcCtx.Redis,
|
||||||
|
})
|
||||||
|
|
||||||
|
valid, err := captchaService.Verify(l.ctx, req.CaptchaId, req.CaptchaCode, req.IP)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[UserRegisterLogic] Verify captcha error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha")
|
||||||
|
}
|
||||||
|
} else if config.CaptchaType == "turnstile" {
|
||||||
|
if req.CfToken == "" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required")
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaService := captcha.NewService(captcha.Config{
|
||||||
|
Type: captcha.CaptchaTypeTurnstile,
|
||||||
|
TurnstileSecret: config.TurnstileSecret,
|
||||||
|
})
|
||||||
|
|
||||||
|
valid, err := captchaService.Verify(l.ctx, req.CfToken, "", req.IP)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[UserRegisterLogic] Verify turnstile error: ", logger.Field("error", err.Error()))
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -41,6 +41,11 @@ func (l *GetGlobalConfigLogic) GetGlobalConfig() (resp *types.GetGlobalConfigRes
|
|||||||
l.Logger.Error("[GetGlobalConfigLogic] GetVerifyCodeConfig error: ", logger.Field("error", err.Error()))
|
l.Logger.Error("[GetGlobalConfigLogic] GetVerifyCodeConfig error: ", logger.Field("error", err.Error()))
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyCodeConfig error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyCodeConfig error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
l.Logger.Error("[GetGlobalConfigLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
tool.DeepCopy(&resp.Site, l.svcCtx.Config.Site)
|
tool.DeepCopy(&resp.Site, l.svcCtx.Config.Site)
|
||||||
tool.DeepCopy(&resp.Subscribe, l.svcCtx.Config.Subscribe)
|
tool.DeepCopy(&resp.Subscribe, l.svcCtx.Config.Subscribe)
|
||||||
@ -52,17 +57,12 @@ func (l *GetGlobalConfigLogic) GetGlobalConfig() (resp *types.GetGlobalConfigRes
|
|||||||
tool.DeepCopy(&resp.Invite, l.svcCtx.Config.Invite)
|
tool.DeepCopy(&resp.Invite, l.svcCtx.Config.Invite)
|
||||||
tool.SystemConfigSliceReflectToStruct(currencyCfg, &resp.Currency)
|
tool.SystemConfigSliceReflectToStruct(currencyCfg, &resp.Currency)
|
||||||
tool.SystemConfigSliceReflectToStruct(verifyCodeCfg, &resp.VerifyCode)
|
tool.SystemConfigSliceReflectToStruct(verifyCodeCfg, &resp.VerifyCode)
|
||||||
|
tool.SystemConfigSliceReflectToStruct(verifyCfg, &resp.Verify)
|
||||||
|
|
||||||
if report.IsGatewayMode() {
|
if report.IsGatewayMode() {
|
||||||
resp.Subscribe.SubscribePath = "/sub" + l.svcCtx.Config.Subscribe.SubscribePath
|
resp.Subscribe.SubscribePath = "/sub" + l.svcCtx.Config.Subscribe.SubscribePath
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.Verify = types.VeifyConfig{
|
|
||||||
TurnstileSiteKey: l.svcCtx.Config.Verify.TurnstileSiteKey,
|
|
||||||
EnableLoginVerify: l.svcCtx.Config.Verify.LoginVerify,
|
|
||||||
EnableRegisterVerify: l.svcCtx.Config.Verify.RegisterVerify,
|
|
||||||
EnableResetPasswordVerify: l.svcCtx.Config.Verify.ResetPasswordVerify,
|
|
||||||
}
|
|
||||||
var methods []string
|
var methods []string
|
||||||
|
|
||||||
// auth methods
|
// auth methods
|
||||||
|
|||||||
@ -104,18 +104,23 @@ func fillUserSubscribeInfoEntitlementFields(sub *types.UserSubscribeInfo, entitl
|
|||||||
func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (userSubscribeNodes []*types.UserSubscribeNodeInfo, err error) {
|
func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (userSubscribeNodes []*types.UserSubscribeNodeInfo, err error) {
|
||||||
userSubscribeNodes = make([]*types.UserSubscribeNodeInfo, 0)
|
userSubscribeNodes = make([]*types.UserSubscribeNodeInfo, 0)
|
||||||
if l.isSubscriptionExpired(userSub) {
|
if l.isSubscriptionExpired(userSub) {
|
||||||
return l.createExpiredServers(), nil
|
return l.createExpiredServers(userSub), 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 {
|
if err != nil {
|
||||||
l.Errorw("[Generate Subscribe]find subscribe details error: %v", logger.Field("error", err.Error()))
|
l.Debugw("[GetServers] Failed to check group enabled", logger.Field("error", err.Error()))
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe details error: %v", err.Error())
|
// Continue with tag-based filtering
|
||||||
}
|
}
|
||||||
nodeIds := tool.StringToInt64Slice(subDetails.Nodes)
|
nodeIds := tool.StringToInt64Slice(subDetails.Nodes)
|
||||||
tags := normalizeSubscribeNodeTags(subDetails.NodeTags)
|
tags := normalizeSubscribeNodeTags(subDetails.NodeTags)
|
||||||
|
|
||||||
l.Debugf("[Generate Subscribe]nodes: %v, NodeTags: %v", nodeIds, tags)
|
isGroupEnabled := (groupEnabled == "true" || groupEnabled == "1")
|
||||||
|
|
||||||
enable := true
|
enable := true
|
||||||
|
|
||||||
@ -127,6 +132,7 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u
|
|||||||
Enabled: &enable, // Only get enabled nodes
|
Enabled: &enable, // Only get enabled nodes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Process nodes and create response
|
||||||
if len(nodes) > 0 {
|
if len(nodes) > 0 {
|
||||||
var serverMapIds = make(map[int64]*node.Server)
|
var serverMapIds = make(map[int64]*node.Server)
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
@ -174,21 +180,241 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u
|
|||||||
}
|
}
|
||||||
|
|
||||||
l.Debugf("[Query Subscribe]found servers: %v", len(nodes))
|
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
|
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 := ""
|
||||||
|
var directNodeIds []int64
|
||||||
|
|
||||||
|
// 优先级1: user_subscribe.node_group_id
|
||||||
|
if userSub.NodeGroupId != 0 {
|
||||||
|
nodeGroupId = userSub.NodeGroupId
|
||||||
|
source = "user_subscribe.node_group_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 subscribe 详情(用于获取 node_group_id 和直接分配的节点)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取直接分配的节点ID
|
||||||
|
directNodeIds = tool.StringToInt64Slice(subDetails.Nodes)
|
||||||
|
l.Debugf("[GetNodesByGroup] direct nodes: %v", directNodeIds)
|
||||||
|
|
||||||
|
// 如果 user_subscribe 没有 node_group_id,从 subscribe 获取
|
||||||
|
if nodeGroupId == 0 {
|
||||||
|
// 优先级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]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Debugf("[GetNodesByGroup] Using %s: %v", source, nodeGroupId)
|
||||||
|
|
||||||
|
// 查询所有启用的节点
|
||||||
|
enable := true
|
||||||
|
_, allNodes, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{
|
||||||
|
Page: 0,
|
||||||
|
Size: 10000,
|
||||||
|
Enabled: &enable,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[GetNodesByGroup] FilterNodeList error", logger.Field("error", err.Error()))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤节点
|
||||||
|
var resultNodes []*node.Node
|
||||||
|
nodeIdMap := make(map[int64]bool)
|
||||||
|
|
||||||
|
for _, n := range allNodes {
|
||||||
|
// 1. 公共节点(node_group_ids 为空),所有人可见
|
||||||
|
if len(n.NodeGroupIds) == 0 {
|
||||||
|
if !nodeIdMap[n.Id] {
|
||||||
|
resultNodes = append(resultNodes, n)
|
||||||
|
nodeIdMap[n.Id] = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果有节点组,检查节点是否属于该节点组
|
||||||
|
if nodeGroupId != 0 {
|
||||||
|
for _, gid := range n.NodeGroupIds {
|
||||||
|
if gid == nodeGroupId {
|
||||||
|
if !nodeIdMap[n.Id] {
|
||||||
|
resultNodes = append(resultNodes, n)
|
||||||
|
nodeIdMap[n.Id] = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 添加直接分配的节点
|
||||||
|
if len(directNodeIds) > 0 {
|
||||||
|
for _, n := range allNodes {
|
||||||
|
if tool.Contains(directNodeIds, n.Id) && !nodeIdMap[n.Id] {
|
||||||
|
resultNodes = append(resultNodes, n)
|
||||||
|
nodeIdMap[n.Id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Debugf("[GetNodesByGroup] Found %d nodes (group=%d, direct=%d)", len(resultNodes), nodeGroupId, len(directNodeIds))
|
||||||
|
return resultNodes, 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, ",")
|
||||||
|
newTags := make([]string, 0)
|
||||||
|
for _, t := range tags {
|
||||||
|
if t != "" {
|
||||||
|
newTags = append(newTags, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags = newTags
|
||||||
|
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 {
|
func (l *QueryUserSubscribeNodeListLogic) isSubscriptionExpired(userSub *user.Subscribe) bool {
|
||||||
return userSub.ExpireTime.Unix() < time.Now().Unix() && userSub.ExpireTime.Unix() != 0
|
return userSub.ExpireTime.Unix() < time.Now().Unix() && userSub.ExpireTime.Unix() != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *QueryUserSubscribeNodeListLogic) createExpiredServers() []*types.UserSubscribeNodeInfo {
|
func (l *QueryUserSubscribeNodeListLogic) createExpiredServers(userSub *user.Subscribe) []*types.UserSubscribeNodeInfo {
|
||||||
|
// 1. 查询过期节点组
|
||||||
|
var expiredGroup group.NodeGroup
|
||||||
|
err := l.svcCtx.DB.Where("is_expired_group = ?", true).First(&expiredGroup).Error
|
||||||
|
if err != nil {
|
||||||
|
l.Debugw("no expired node group configured", logger.Field("error", err))
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查用户是否在过期天数限制内
|
||||||
|
expiredDays := int(time.Since(userSub.ExpireTime).Hours() / 24)
|
||||||
|
if expiredDays > expiredGroup.ExpiredDaysLimit {
|
||||||
|
l.Debugf("user subscription expired %d days, exceeds limit %d days", expiredDays, expiredGroup.ExpiredDaysLimit)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查用户已使用流量是否超过限制(仅使用过期期间的流量)
|
||||||
|
if expiredGroup.MaxTrafficGBExpired != nil && *expiredGroup.MaxTrafficGBExpired > 0 {
|
||||||
|
usedTrafficGB := (userSub.ExpiredDownload + userSub.ExpiredUpload) / (1024 * 1024 * 1024)
|
||||||
|
if usedTrafficGB >= *expiredGroup.MaxTrafficGBExpired {
|
||||||
|
l.Debugf("user expired traffic %d GB, exceeds expired group limit %d GB", usedTrafficGB, *expiredGroup.MaxTrafficGBExpired)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 查询过期节点组的节点
|
||||||
|
enable := true
|
||||||
|
_, nodes, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{
|
||||||
|
Page: 0,
|
||||||
|
Size: 1000,
|
||||||
|
NodeGroupIds: []int64{expiredGroup.Id},
|
||||||
|
Enabled: &enable,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("failed to query expired group nodes", logger.Field("error", err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
l.Debug("no nodes found in expired group")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 查询服务器信息
|
||||||
|
var serverMapIds = make(map[int64]*node.Server)
|
||||||
|
for _, n := range nodes {
|
||||||
|
serverMapIds[n.ServerId] = nil
|
||||||
|
}
|
||||||
|
var serverIds []int64
|
||||||
|
for k := range serverMapIds {
|
||||||
|
serverIds = append(serverIds, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
servers, err := l.svcCtx.NodeModel.QueryServerList(l.ctx, serverIds)
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("failed to query servers", logger.Field("error", err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range servers {
|
||||||
|
serverMapIds[s.Id] = s
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 构建节点列表
|
||||||
|
userSubscribeNodes := make([]*types.UserSubscribeNodeInfo, 0, len(nodes))
|
||||||
|
for _, n := range nodes {
|
||||||
|
server := serverMapIds[n.ServerId]
|
||||||
|
if server == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userSubscribeNode := &types.UserSubscribeNodeInfo{
|
||||||
|
Id: n.Id,
|
||||||
|
Name: n.Name,
|
||||||
|
Uuid: userSub.UUID,
|
||||||
|
Protocol: n.Protocol,
|
||||||
|
Protocols: server.Protocols,
|
||||||
|
Port: n.Port,
|
||||||
|
Address: n.Address,
|
||||||
|
Tags: strings.Split(n.Tags, ","),
|
||||||
|
Country: server.Country,
|
||||||
|
City: server.City,
|
||||||
|
Latitude: server.Latitude,
|
||||||
|
Longitude: server.Longitude,
|
||||||
|
LongitudeCenter: server.LongitudeCenter,
|
||||||
|
LatitudeCenter: server.LatitudeCenter,
|
||||||
|
CreatedAt: n.CreatedAt.Unix(),
|
||||||
|
}
|
||||||
|
userSubscribeNodes = append(userSubscribeNodes, userSubscribeNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Infof("returned %d nodes from expired group for user %d (expired %d days)", len(userSubscribeNodes), userSub.UserId, expiredDays)
|
||||||
|
return userSubscribeNodes
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *QueryUserSubscribeNodeListLogic) getFirstHostLine() string {
|
func (l *QueryUserSubscribeNodeListLogic) getFirstHostLine() string {
|
||||||
|
|||||||
138
internal/logic/public/user/getUserTrafficStatsLogic.go
Normal file
138
internal/logic/public/user/getUserTrafficStatsLogic.go
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"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/constant"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetUserTrafficStatsLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get User Traffic Statistics
|
||||||
|
func NewGetUserTrafficStatsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUserTrafficStatsLogic {
|
||||||
|
return &GetUserTrafficStatsLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *GetUserTrafficStatsLogic) GetUserTrafficStats(req *types.GetUserTrafficStatsRequest) (resp *types.GetUserTrafficStatsResponse, err error) {
|
||||||
|
// 获取当前用户
|
||||||
|
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||||
|
if !ok {
|
||||||
|
logger.Error("current user is not found in context")
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将字符串 ID 转换为 int64
|
||||||
|
userSubscribeId, err := strconv.ParseInt(req.UserSubscribeId, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[GetUserTrafficStats] Invalid User Subscribe ID:",
|
||||||
|
logger.Field("user_subscribe_id", req.UserSubscribeId),
|
||||||
|
logger.Field("err", err.Error()))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid subscription ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证订阅归属权 - 直接查询 user_subscribe 表
|
||||||
|
var userSubscribe struct {
|
||||||
|
Id int64
|
||||||
|
UserId int64
|
||||||
|
}
|
||||||
|
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Table("user_subscribe").
|
||||||
|
Select("id, user_id").
|
||||||
|
Where("id = ?", userSubscribeId).
|
||||||
|
First(&userSubscribe).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
l.Errorw("[GetUserTrafficStats] User Subscribe Not Found:",
|
||||||
|
logger.Field("user_subscribe_id", userSubscribeId),
|
||||||
|
logger.Field("user_id", u.Id))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Subscription not found")
|
||||||
|
}
|
||||||
|
l.Errorw("[GetUserTrafficStats] Query User Subscribe Error:", logger.Field("err", err.Error()))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Query User Subscribe Error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if userSubscribe.UserId != u.Id {
|
||||||
|
l.Errorw("[GetUserTrafficStats] User Subscribe Access Denied:",
|
||||||
|
logger.Field("user_subscribe_id", userSubscribeId),
|
||||||
|
logger.Field("subscribe_user_id", userSubscribe.UserId),
|
||||||
|
logger.Field("current_user_id", u.Id))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算时间范围
|
||||||
|
now := time.Now()
|
||||||
|
startDate := now.AddDate(0, 0, -req.Days+1)
|
||||||
|
startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, time.Local)
|
||||||
|
|
||||||
|
// 初始化响应
|
||||||
|
resp = &types.GetUserTrafficStatsResponse{
|
||||||
|
List: make([]types.DailyTrafficStats, 0, req.Days),
|
||||||
|
TotalUpload: 0,
|
||||||
|
TotalDownload: 0,
|
||||||
|
TotalTraffic: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按天查询流量数据
|
||||||
|
for i := 0; i < req.Days; i++ {
|
||||||
|
currentDate := startDate.AddDate(0, 0, i)
|
||||||
|
dayStart := time.Date(currentDate.Year(), currentDate.Month(), currentDate.Day(), 0, 0, 0, 0, time.Local)
|
||||||
|
dayEnd := dayStart.Add(24 * time.Hour).Add(-time.Nanosecond)
|
||||||
|
|
||||||
|
// 查询当天流量
|
||||||
|
var dailyTraffic struct {
|
||||||
|
Upload int64
|
||||||
|
Download int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接使用 model 的查询方法
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Table("traffic_log").
|
||||||
|
Select("COALESCE(SUM(upload), 0) as upload, COALESCE(SUM(download), 0) as download").
|
||||||
|
Where("user_id = ? AND subscribe_id = ? AND timestamp BETWEEN ? AND ?",
|
||||||
|
u.Id, userSubscribeId, dayStart, dayEnd).
|
||||||
|
Scan(&dailyTraffic).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[GetUserTrafficStats] Query Daily Traffic Error:",
|
||||||
|
logger.Field("date", currentDate.Format("2006-01-02")),
|
||||||
|
logger.Field("err", err.Error()))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Query Traffic Error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到结果列表
|
||||||
|
total := dailyTraffic.Upload + dailyTraffic.Download
|
||||||
|
resp.List = append(resp.List, types.DailyTrafficStats{
|
||||||
|
Date: currentDate.Format("2006-01-02"),
|
||||||
|
Upload: dailyTraffic.Upload,
|
||||||
|
Download: dailyTraffic.Download,
|
||||||
|
Total: total,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 累加总计
|
||||||
|
resp.TotalUpload += dailyTraffic.Upload
|
||||||
|
resp.TotalDownload += dailyTraffic.Download
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.TotalTraffic = resp.TotalUpload + resp.TotalDownload
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package user
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||||
@ -58,6 +59,9 @@ func (l *QueryUserSubscribeLogic) QueryUserSubscribe() (resp *types.QueryUserSub
|
|||||||
var sub types.UserSubscribe
|
var sub types.UserSubscribe
|
||||||
tool.DeepCopy(&sub, item)
|
tool.DeepCopy(&sub, item)
|
||||||
|
|
||||||
|
// 填充 IdStr 字段,避免前端精度丢失
|
||||||
|
sub.IdStr = strconv.FormatInt(item.Id, 10)
|
||||||
|
|
||||||
// 解析Discount字段 避免在续订时只能续订一个月
|
// 解析Discount字段 避免在续订时只能续订一个月
|
||||||
if item.Subscribe != nil && item.Subscribe.Discount != "" {
|
if item.Subscribe != nil && item.Subscribe.Discount != "" {
|
||||||
var discounts []types.SubscribeDiscount
|
var discounts []types.SubscribeDiscount
|
||||||
|
|||||||
@ -4,10 +4,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/model/group"
|
||||||
"github.com/perfect-panel/server/internal/model/node"
|
"github.com/perfect-panel/server/internal/model/node"
|
||||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
"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/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
@ -55,6 +58,7 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询该服务器上该协议的所有节点(包括属于节点组的节点)
|
||||||
_, nodes, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{
|
_, nodes, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{
|
||||||
Page: 1,
|
Page: 1,
|
||||||
Size: 1000,
|
Size: 1000,
|
||||||
@ -65,25 +69,74 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
|||||||
l.Errorw("FilterNodeList error", logger.Field("error", err.Error()))
|
l.Errorw("FilterNodeList error", logger.Field("error", err.Error()))
|
||||||
return nil, err
|
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 nodeIds []int64
|
||||||
|
var nodeTags []string
|
||||||
|
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
nodeIds = append(nodeIds, n.Id)
|
nodeIds = append(nodeIds, n.Id)
|
||||||
if n.Tags != "" {
|
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,
|
Page: 1,
|
||||||
Size: 9999,
|
Size: 9999,
|
||||||
Node: nodeIds,
|
Node: nodeIds,
|
||||||
Tags: nodeTag,
|
Tags: nodeTags,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("QuerySubscribeIdsByServerIdAndServerGroupId error", logger.Field("error", err.Error()))
|
l.Errorw("FilterList error", logger.Field("error", err.Error()))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(subs) == 0 {
|
if len(subs) == 0 {
|
||||||
return &types.GetServerUserListResponse{
|
return &types.GetServerUserListResponse{
|
||||||
Users: []types.ServerUser{
|
Users: []types.ServerUser{
|
||||||
@ -101,14 +154,33 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, datum := range data {
|
for _, datum := range data {
|
||||||
|
if !l.shouldIncludeServerUser(datum, nodeGroupIds) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算该用户的实际限速值(考虑按量限速规则)
|
||||||
|
effectiveSpeedLimit := l.calculateEffectiveSpeedLimit(sub, datum)
|
||||||
|
|
||||||
users = append(users, types.ServerUser{
|
users = append(users, types.ServerUser{
|
||||||
Id: datum.Id,
|
Id: datum.Id,
|
||||||
UUID: datum.UUID,
|
UUID: datum.UUID,
|
||||||
SpeedLimit: sub.SpeedLimit,
|
SpeedLimit: effectiveSpeedLimit,
|
||||||
DeviceLimit: sub.DeviceLimit,
|
DeviceLimit: sub.DeviceLimit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理过期订阅用户:如果当前节点属于过期节点组,添加符合条件的过期用户
|
||||||
|
if len(nodeGroupIds) > 0 {
|
||||||
|
expiredUsers, expiredSpeedLimit := l.getExpiredUsers(nodeGroupIds)
|
||||||
|
for i := range expiredUsers {
|
||||||
|
if expiredSpeedLimit > 0 {
|
||||||
|
expiredUsers[i].SpeedLimit = expiredSpeedLimit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
users = append(users, expiredUsers...)
|
||||||
|
}
|
||||||
|
|
||||||
if len(users) == 0 {
|
if len(users) == 0 {
|
||||||
users = append(users, types.ServerUser{
|
users = append(users, types.ServerUser{
|
||||||
Id: 1,
|
Id: 1,
|
||||||
@ -131,3 +203,175 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
|||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *GetServerUserListLogic) shouldIncludeServerUser(userSub *user.Subscribe, serverNodeGroupIds []int64) bool {
|
||||||
|
if userSub == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if userSub.ExpireTime.Unix() == 0 || userSub.ExpireTime.After(time.Now()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.canUseExpiredNodeGroup(userSub, serverNodeGroupIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *GetServerUserListLogic) getExpiredUsers(serverNodeGroupIds []int64) ([]types.ServerUser, int64) {
|
||||||
|
var expiredGroup group.NodeGroup
|
||||||
|
if err := l.svcCtx.DB.Where("is_expired_group = ?", true).First(&expiredGroup).Error; err != nil {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tool.Contains(serverNodeGroupIds, expiredGroup.Id) {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiredSubs []*user.Subscribe
|
||||||
|
if err := l.svcCtx.DB.Where("status = ?", 3).Find(&expiredSubs).Error; err != nil {
|
||||||
|
l.Errorw("query expired subscriptions failed", logger.Field("error", err.Error()))
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
users := make([]types.ServerUser, 0)
|
||||||
|
seen := make(map[int64]bool)
|
||||||
|
for _, userSub := range expiredSubs {
|
||||||
|
if !l.checkExpiredUserEligibility(userSub, &expiredGroup) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[userSub.Id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[userSub.Id] = true
|
||||||
|
users = append(users, types.ServerUser{
|
||||||
|
Id: userSub.Id,
|
||||||
|
UUID: userSub.UUID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, int64(expiredGroup.SpeedLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *GetServerUserListLogic) checkExpiredUserEligibility(userSub *user.Subscribe, expiredGroup *group.NodeGroup) bool {
|
||||||
|
expiredDays := int(time.Since(userSub.ExpireTime).Hours() / 24)
|
||||||
|
if expiredDays > expiredGroup.ExpiredDaysLimit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if expiredGroup.MaxTrafficGBExpired != nil && *expiredGroup.MaxTrafficGBExpired > 0 {
|
||||||
|
usedTrafficGB := (userSub.ExpiredDownload + userSub.ExpiredUpload) / (1024 * 1024 * 1024)
|
||||||
|
if usedTrafficGB >= *expiredGroup.MaxTrafficGBExpired {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *GetServerUserListLogic) canUseExpiredNodeGroup(userSub *user.Subscribe, serverNodeGroupIds []int64) bool {
|
||||||
|
var expiredGroup group.NodeGroup
|
||||||
|
if err := l.svcCtx.DB.Where("is_expired_group = ?", true).First(&expiredGroup).Error; err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tool.Contains(serverNodeGroupIds, expiredGroup.Id) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
expiredDays := int(time.Since(userSub.ExpireTime).Hours() / 24)
|
||||||
|
if expiredDays > expiredGroup.ExpiredDaysLimit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if expiredGroup.MaxTrafficGBExpired != nil && *expiredGroup.MaxTrafficGBExpired > 0 {
|
||||||
|
usedTrafficGB := (userSub.ExpiredDownload + userSub.ExpiredUpload) / (1024 * 1024 * 1024)
|
||||||
|
if usedTrafficGB >= *expiredGroup.MaxTrafficGBExpired {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateEffectiveSpeedLimit 计算用户的实际限速值(考虑按量限速规则)
|
||||||
|
func (l *GetServerUserListLogic) calculateEffectiveSpeedLimit(sub *subscribe.Subscribe, userSub *user.Subscribe) int64 {
|
||||||
|
baseSpeedLimit := sub.SpeedLimit
|
||||||
|
|
||||||
|
// 解析 traffic_limit 规则
|
||||||
|
if sub.TrafficLimit == "" {
|
||||||
|
return baseSpeedLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
var trafficLimitRules []types.TrafficLimit
|
||||||
|
if err := json.Unmarshal([]byte(sub.TrafficLimit), &trafficLimitRules); err != nil {
|
||||||
|
l.Errorw("[calculateEffectiveSpeedLimit] Failed to unmarshal traffic_limit",
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
logger.Field("traffic_limit", sub.TrafficLimit))
|
||||||
|
return baseSpeedLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(trafficLimitRules) == 0 {
|
||||||
|
return baseSpeedLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询用户指定时段的流量使用情况
|
||||||
|
now := time.Now()
|
||||||
|
for _, rule := range trafficLimitRules {
|
||||||
|
var startTime, endTime time.Time
|
||||||
|
|
||||||
|
if rule.StatType == "hour" {
|
||||||
|
// 按小时统计:根据 StatValue 计算时间范围(往前推 N 小时)
|
||||||
|
if rule.StatValue <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 从当前时间往前推 StatValue 小时
|
||||||
|
startTime = now.Add(-time.Duration(rule.StatValue) * time.Hour)
|
||||||
|
endTime = now
|
||||||
|
} else if rule.StatType == "day" {
|
||||||
|
// 按天统计:根据 StatValue 计算时间范围(往前推 N 天)
|
||||||
|
if rule.StatValue <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 从当前时间往前推 StatValue 天
|
||||||
|
startTime = now.AddDate(0, 0, -int(rule.StatValue))
|
||||||
|
endTime = now
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询该时段的流量使用
|
||||||
|
var usedTraffic struct {
|
||||||
|
Upload int64
|
||||||
|
Download int64
|
||||||
|
}
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx.Request.Context()).
|
||||||
|
Table("traffic_log").
|
||||||
|
Select("COALESCE(SUM(upload), 0) as upload, COALESCE(SUM(download), 0) as download").
|
||||||
|
Where("user_id = ? AND subscribe_id = ? AND timestamp >= ? AND timestamp < ?",
|
||||||
|
userSub.UserId, userSub.Id, startTime, endTime).
|
||||||
|
Scan(&usedTraffic).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[calculateEffectiveSpeedLimit] Failed to query traffic usage",
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
logger.Field("user_id", userSub.UserId),
|
||||||
|
logger.Field("subscribe_id", userSub.Id))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算已使用流量(GB)
|
||||||
|
usedGB := float64(usedTraffic.Upload+usedTraffic.Download) / (1024 * 1024 * 1024)
|
||||||
|
|
||||||
|
// 如果已使用流量达到或超过阈值,应用限速
|
||||||
|
if usedGB >= float64(rule.TrafficUsage) {
|
||||||
|
// 如果规则限速大于0,应用该限速
|
||||||
|
if rule.SpeedLimit > 0 {
|
||||||
|
// 如果基础限速为0(无限速)或规则限速更严格,使用规则限速
|
||||||
|
if baseSpeedLimit == 0 || rule.SpeedLimit < baseSpeedLimit {
|
||||||
|
return rule.SpeedLimit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseSpeedLimit
|
||||||
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/perfect-panel/server/adapter"
|
"github.com/perfect-panel/server/adapter"
|
||||||
"github.com/perfect-panel/server/internal/model/client"
|
"github.com/perfect-panel/server/internal/model/client"
|
||||||
|
"github.com/perfect-panel/server/internal/model/group"
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/model/node"
|
"github.com/perfect-panel/server/internal/model/node"
|
||||||
"github.com/perfect-panel/server/internal/report"
|
"github.com/perfect-panel/server/internal/report"
|
||||||
@ -206,6 +207,19 @@ func (l *SubscribeLogic) logSubscribeActivity(subscribeStatus bool, userSub *use
|
|||||||
|
|
||||||
func (l *SubscribeLogic) getServers(userSub *user.Subscribe) ([]*node.Node, error) {
|
func (l *SubscribeLogic) getServers(userSub *user.Subscribe) ([]*node.Node, error) {
|
||||||
if l.isSubscriptionExpired(userSub) {
|
if l.isSubscriptionExpired(userSub) {
|
||||||
|
// 尝试获取过期节点组的节点
|
||||||
|
expiredNodes, err := l.getExpiredGroupNodes(userSub)
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[Generate Subscribe]get expired group nodes error", logger.Field("error", err.Error()))
|
||||||
|
return l.createExpiredServers(), nil
|
||||||
|
}
|
||||||
|
// 如果有符合条件的过期节点组节点,返回它们
|
||||||
|
if len(expiredNodes) > 0 {
|
||||||
|
l.Debugf("[Generate Subscribe]user %d can use expired node group, nodes count: %d", userSub.UserId, len(expiredNodes))
|
||||||
|
return expiredNodes, nil
|
||||||
|
}
|
||||||
|
// 否则返回假的过期节点
|
||||||
|
l.Debugf("[Generate Subscribe]user %d cannot use expired node group, return fake expired nodes", userSub.UserId)
|
||||||
return l.createExpiredServers(), nil
|
return l.createExpiredServers(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,14 +229,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())
|
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)
|
nodeIds := tool.StringToInt64Slice(subDetails.Nodes)
|
||||||
tags := tool.RemoveStringElement(strings.Split(subDetails.NodeTags, ","), "")
|
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 {
|
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
|
return []*node.Node{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
enable := true
|
enable := true
|
||||||
var nodes []*node.Node
|
var nodes []*node.Node
|
||||||
_, nodes, err = l.svc.NodeModel.FilterNodeList(l.ctx.Request.Context(), &node.FilterNodeParams{
|
_, nodes, err = l.svc.NodeModel.FilterNodeList(l.ctx.Request.Context(), &node.FilterNodeParams{
|
||||||
@ -231,16 +364,15 @@ func (l *SubscribeLogic) getServers(userSub *user.Subscribe) ([]*node.Node, erro
|
|||||||
NodeId: nodeIds,
|
NodeId: nodeIds,
|
||||||
Tag: tool.RemoveDuplicateElements(tags...),
|
Tag: tool.RemoveDuplicateElements(tags...),
|
||||||
Preload: true,
|
Preload: true,
|
||||||
Enabled: &enable, // Only get enabled nodes
|
Enabled: &enable,
|
||||||
})
|
})
|
||||||
|
|
||||||
l.Debugf("[Query Subscribe]found servers: %v", len(nodes))
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[Generate Subscribe]find server details error: %v", logger.Field("error", err.Error()))
|
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())
|
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
|
return nodes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -290,3 +422,66 @@ func (l *SubscribeLogic) getFirstHostLine() string {
|
|||||||
}
|
}
|
||||||
return host
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getExpiredGroupNodes 获取过期节点组的节点
|
||||||
|
func (l *SubscribeLogic) getExpiredGroupNodes(userSub *user.Subscribe) ([]*node.Node, error) {
|
||||||
|
// 1. 查询过期节点组
|
||||||
|
var expiredGroup group.NodeGroup
|
||||||
|
err := l.svc.DB.Where("is_expired_group = ?", true).First(&expiredGroup).Error
|
||||||
|
if err != nil {
|
||||||
|
l.Debugw("[SubscribeLogic]no expired node group configured", logger.Field("error", err.Error()))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查用户是否在过期天数限制内
|
||||||
|
expiredDays := int(time.Since(userSub.ExpireTime).Hours() / 24)
|
||||||
|
if expiredDays > expiredGroup.ExpiredDaysLimit {
|
||||||
|
l.Debugf("[SubscribeLogic]user %d subscription expired %d days, exceeds limit %d days", userSub.UserId, expiredDays, expiredGroup.ExpiredDaysLimit)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查用户已使用流量是否超过限制(仅使用过期期间的流量)
|
||||||
|
if expiredGroup.MaxTrafficGBExpired != nil && *expiredGroup.MaxTrafficGBExpired > 0 {
|
||||||
|
usedTrafficGB := (userSub.ExpiredDownload + userSub.ExpiredUpload) / (1024 * 1024 * 1024)
|
||||||
|
if usedTrafficGB >= *expiredGroup.MaxTrafficGBExpired {
|
||||||
|
l.Debugf("[SubscribeLogic]user %d expired traffic %d GB, exceeds expired group limit %d GB", userSub.UserId, usedTrafficGB, *expiredGroup.MaxTrafficGBExpired)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 查询过期节点组的节点
|
||||||
|
enable := true
|
||||||
|
_, nodes, err := l.svc.NodeModel.FilterNodeList(l.ctx.Request.Context(), &node.FilterNodeParams{
|
||||||
|
Page: 0,
|
||||||
|
Size: 1000,
|
||||||
|
NodeGroupIds: []int64{expiredGroup.Id},
|
||||||
|
Enabled: &enable,
|
||||||
|
Preload: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[SubscribeLogic]failed to query expired group nodes", logger.Field("error", err.Error()))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
l.Debug("[SubscribeLogic]no nodes found in expired group")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Infof("[SubscribeLogic]returned %d nodes from expired group for user %d (expired %d days)", len(nodes), userSub.UserId, expiredDays)
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
|||||||
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{},
|
||||||
|
)
|
||||||
|
}
|
||||||
34
internal/model/group/node_group.go
Normal file
34
internal/model/group/node_group.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
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"`
|
||||||
|
IsExpiredGroup *bool `gorm:"default:false;not null;index:idx_is_expired_group;comment:Is Expired Group"`
|
||||||
|
ExpiredDaysLimit int `gorm:"default:7;not null;comment:Expired days limit (days)"`
|
||||||
|
MaxTrafficGBExpired *int64 `gorm:"default:0;comment:Max traffic for expired users (GB)"`
|
||||||
|
SpeedLimit int `gorm:"default:0;not null;comment:Speed limit (KB/s)"`
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -39,6 +39,7 @@ type FilterNodeParams struct {
|
|||||||
NodeId []int64 // Node IDs
|
NodeId []int64 // Node IDs
|
||||||
ServerId []int64 // Server IDs
|
ServerId []int64 // Server IDs
|
||||||
Tag []string // Tags
|
Tag []string // Tags
|
||||||
|
NodeGroupIds []int64 // Node Group IDs
|
||||||
Search string // Search Address or Name
|
Search string // Search Address or Name
|
||||||
Protocol string // Protocol
|
Protocol string // Protocol
|
||||||
Preload bool // Preload Server
|
Preload bool // Preload Server
|
||||||
@ -97,6 +98,18 @@ func (m *customServerModel) FilterNodeList(ctx context.Context, params *FilterNo
|
|||||||
if len(params.Tag) > 0 {
|
if len(params.Tag) > 0 {
|
||||||
query = query.Scopes(InSet("tags", params.Tag))
|
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 != "" {
|
if params.Protocol != "" {
|
||||||
query = query.Where("protocol = ?", params.Protocol)
|
query = query.Where("protocol = ?", params.Protocol)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,59 @@
|
|||||||
package node
|
package node
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
"gorm.io/gorm"
|
"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 {
|
type Node struct {
|
||||||
Id int64 `gorm:"primary_key"`
|
Id int64 `gorm:"primary_key"`
|
||||||
Name string `gorm:"type:varchar(100);not null;default:'';comment:Node Name"`
|
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"`
|
Protocol string `gorm:"type:varchar(100);not null;default:'';comment:Protocol"`
|
||||||
Enabled *bool `gorm:"type:boolean;not null;default:true;comment:Enabled"`
|
Enabled *bool `gorm:"type:boolean;not null;default:true;comment:Enabled"`
|
||||||
Sort int `gorm:"uniqueIndex;not null;default:0;comment:Sort"`
|
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"`
|
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
|
||||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package subscribe
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/pkg/tool"
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
@ -19,6 +20,13 @@ type FilterParams struct {
|
|||||||
Language string // Language
|
Language string // Language
|
||||||
DefaultLanguage bool // Default Subscribe Language Data
|
DefaultLanguage bool // Default Subscribe Language Data
|
||||||
Search string // Search Keywords
|
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() {
|
func (p *FilterParams) Normalize() {
|
||||||
@ -32,6 +40,7 @@ func (p *FilterParams) Normalize() {
|
|||||||
|
|
||||||
type customSubscribeLogicModel interface {
|
type customSubscribeLogicModel interface {
|
||||||
FilterList(ctx context.Context, params *FilterParams) (int64, []*Subscribe, error)
|
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
|
ClearCache(ctx context.Context, id ...int64) error
|
||||||
QuerySubscribeMinSortByIds(ctx context.Context, ids []int64) (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 {
|
if len(params.Tags) > 0 {
|
||||||
query = query.Scopes(InSet("node_tags", params.Tags))
|
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 != "" {
|
if lang != "" {
|
||||||
query = query.Where("language = ?", lang)
|
query = query.Where("language = ?", lang)
|
||||||
} else if params.DefaultLanguage {
|
} else if params.DefaultLanguage {
|
||||||
@ -154,3 +167,67 @@ func InSet(field string, values []string) func(db *gorm.DB) *gorm.DB {
|
|||||||
return query
|
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
|
package subscribe
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"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 {
|
type Subscribe struct {
|
||||||
Id int64 `gorm:"primaryKey"`
|
Id int64 `gorm:"primaryKey"`
|
||||||
Name string `gorm:"type:varchar(255);not null;default:'';comment:Subscribe Name"`
|
Name string `gorm:"type:varchar(255);not null;default:'';comment:Subscribe Name"`
|
||||||
|
|||||||
@ -29,6 +29,7 @@ type SubscribeDetails struct {
|
|||||||
OrderId int64 `gorm:"index:idx_order_id;not null;comment:Order 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"`
|
SubscribeId int64 `gorm:"index:idx_subscribe_id;not null;comment:Subscription ID"`
|
||||||
Subscribe *subscribe.Subscribe `gorm:"foreignKey:SubscribeId;references: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"`
|
StartTime time.Time `gorm:"default:CURRENT_TIMESTAMP(3);not null;comment:Subscription Start Time"`
|
||||||
ExpireTime time.Time `gorm:"default:NULL;comment:Subscription Expire Time"`
|
ExpireTime time.Time `gorm:"default:NULL;comment:Subscription Expire Time"`
|
||||||
FinishedAt *time.Time `gorm:"default:NULL;comment:Finished Time"`
|
FinishedAt *time.Time `gorm:"default:NULL;comment:Finished Time"`
|
||||||
@ -89,7 +90,7 @@ type customUserLogicModel interface {
|
|||||||
FindOneSubscribeDetailsById(ctx context.Context, id int64) (*SubscribeDetails, error)
|
FindOneSubscribeDetailsById(ctx context.Context, id int64) (*SubscribeDetails, error)
|
||||||
FindOneUserSubscribe(ctx context.Context, id int64) (*SubscribeDetails, error)
|
FindOneUserSubscribe(ctx context.Context, id int64) (*SubscribeDetails, error)
|
||||||
FindUsersSubscribeBySubscribeId(ctx context.Context, subscribeId int64) ([]*Subscribe, error)
|
FindUsersSubscribeBySubscribeId(ctx context.Context, subscribeId int64) ([]*Subscribe, error)
|
||||||
UpdateUserSubscribeWithTraffic(ctx context.Context, id, download, upload int64, tx ...*gorm.DB) error
|
UpdateUserSubscribeWithTraffic(ctx context.Context, id, download, upload int64, isExpired bool, tx ...*gorm.DB) error
|
||||||
QueryResisterUserTotalByDate(ctx context.Context, date time.Time) (int64, error)
|
QueryResisterUserTotalByDate(ctx context.Context, date time.Time) (int64, error)
|
||||||
QueryResisterUserTotalByMonthly(ctx context.Context, date time.Time) (int64, error)
|
QueryResisterUserTotalByMonthly(ctx context.Context, date time.Time) (int64, error)
|
||||||
QueryResisterUserTotal(ctx context.Context) (int64, error)
|
QueryResisterUserTotal(ctx context.Context) (int64, error)
|
||||||
@ -276,7 +277,7 @@ func (m *customUserModel) BatchDeleteUser(ctx context.Context, ids []int64, tx .
|
|||||||
}, m.batchGetCacheKeys(users...)...)
|
}, m.batchGetCacheKeys(users...)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *customUserModel) UpdateUserSubscribeWithTraffic(ctx context.Context, id, download, upload int64, tx ...*gorm.DB) error {
|
func (m *customUserModel) UpdateUserSubscribeWithTraffic(ctx context.Context, id, download, upload int64, isExpired bool, tx ...*gorm.DB) error {
|
||||||
sub, err := m.FindOneSubscribe(ctx, id)
|
sub, err := m.FindOneSubscribe(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -293,10 +294,21 @@ func (m *customUserModel) UpdateUserSubscribeWithTraffic(ctx context.Context, id
|
|||||||
if len(tx) > 0 {
|
if len(tx) > 0 {
|
||||||
conn = tx[0]
|
conn = tx[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 根据订阅状态更新对应的流量字段
|
||||||
|
if isExpired {
|
||||||
|
// 过期期间,更新过期流量字段
|
||||||
|
return conn.Model(&Subscribe{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
|
"expired_download": gorm.Expr("expired_download + ?", download),
|
||||||
|
"expired_upload": gorm.Expr("expired_upload + ?", upload),
|
||||||
|
}).Error
|
||||||
|
} else {
|
||||||
|
// 正常期间,更新正常流量字段
|
||||||
return conn.Model(&Subscribe{}).Where("id = ?", id).Updates(map[string]interface{}{
|
return conn.Model(&Subscribe{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"download": gorm.Expr("download + ?", download),
|
"download": gorm.Expr("download + ?", download),
|
||||||
"upload": gorm.Expr("upload + ?", upload),
|
"upload": gorm.Expr("upload + ?", upload),
|
||||||
}).Error
|
}).Error
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,58 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"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 {
|
type User struct {
|
||||||
Id int64 `gorm:"primaryKey"`
|
Id int64 `gorm:"primaryKey"`
|
||||||
Password string `gorm:"type:varchar(100);not null;comment:User Password"`
|
Password string `gorm:"type:varchar(100);not null;comment:User Password"`
|
||||||
@ -46,12 +93,16 @@ type Subscribe struct {
|
|||||||
User User `gorm:"foreignKey:UserId;references:Id"`
|
User User `gorm:"foreignKey:UserId;references:Id"`
|
||||||
OrderId int64 `gorm:"index:idx_order_id;not null;comment:Order 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"`
|
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"`
|
StartTime time.Time `gorm:"default:CURRENT_TIMESTAMP(3);not null;comment:Subscription Start Time"`
|
||||||
ExpireTime time.Time `gorm:"default:NULL;comment:Subscription Expire Time"`
|
ExpireTime time.Time `gorm:"default:NULL;comment:Subscription Expire Time"`
|
||||||
FinishedAt *time.Time `gorm:"default:NULL;comment:Finished Time"`
|
FinishedAt *time.Time `gorm:"default:NULL;comment:Finished Time"`
|
||||||
Traffic int64 `gorm:"default:0;comment:Traffic"`
|
Traffic int64 `gorm:"default:0;comment:Traffic"`
|
||||||
Download int64 `gorm:"default:0;comment:Download Traffic"`
|
Download int64 `gorm:"default:0;comment:Download Traffic"`
|
||||||
Upload int64 `gorm:"default:0;comment:Upload Traffic"`
|
Upload int64 `gorm:"default:0;comment:Upload Traffic"`
|
||||||
|
ExpiredDownload int64 `gorm:"default:0;comment:Expired period download traffic (bytes)"`
|
||||||
|
ExpiredUpload int64 `gorm:"default:0;comment:Expired period upload traffic (bytes)"`
|
||||||
Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"`
|
Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"`
|
||||||
UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"`
|
UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"`
|
||||||
Status uint8 `gorm:"type:tinyint(1);default:0;comment:Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired 4: Deducted 5: stopped"`
|
Status uint8 `gorm:"type:tinyint(1);default:0;comment:Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired 4: Deducted 5: stopped"`
|
||||||
|
|||||||
@ -361,6 +361,19 @@ type CreateDocumentRequest struct {
|
|||||||
Show *bool `json:"show"`
|
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"`
|
||||||
|
IsExpiredGroup *bool `json:"is_expired_group"`
|
||||||
|
ExpiredDaysLimit *int `json:"expired_days_limit"`
|
||||||
|
MaxTrafficGBExpired *int64 `json:"max_traffic_gb_expired,omitempty"`
|
||||||
|
SpeedLimit *int `json:"speed_limit"`
|
||||||
|
MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"`
|
||||||
|
MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type CreateNodeRequest struct {
|
type CreateNodeRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
@ -369,6 +382,7 @@ type CreateNodeRequest struct {
|
|||||||
ServerId int64 `json:"server_id"`
|
ServerId int64 `json:"server_id"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
|
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateOrderRequest struct {
|
type CreateOrderRequest struct {
|
||||||
@ -462,6 +476,9 @@ type CreateSubscribeRequest struct {
|
|||||||
NewUserOnly *bool `json:"new_user_only"`
|
NewUserOnly *bool `json:"new_user_only"`
|
||||||
Nodes []int64 `json:"nodes"`
|
Nodes []int64 `json:"nodes"`
|
||||||
NodeTags []string `json:"node_tags"`
|
NodeTags []string `json:"node_tags"`
|
||||||
|
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||||
|
NodeGroupId int64 `json:"node_group_id"`
|
||||||
|
TrafficLimit []TrafficLimit `json:"traffic_limit"`
|
||||||
Show *bool `json:"show"`
|
Show *bool `json:"show"`
|
||||||
Sell *bool `json:"sell"`
|
Sell *bool `json:"sell"`
|
||||||
DeductionRatio int64 `json:"deduction_ratio"`
|
DeductionRatio int64 `json:"deduction_ratio"`
|
||||||
@ -469,6 +486,7 @@ type CreateSubscribeRequest struct {
|
|||||||
ResetCycle int64 `json:"reset_cycle"`
|
ResetCycle int64 `json:"reset_cycle"`
|
||||||
RenewalReset *bool `json:"renewal_reset"`
|
RenewalReset *bool `json:"renewal_reset"`
|
||||||
ShowOriginalPrice bool `json:"show_original_price"`
|
ShowOriginalPrice bool `json:"show_original_price"`
|
||||||
|
AutoCreateGroup bool `json:"auto_create_group"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateTicketFollowRequest struct {
|
type CreateTicketFollowRequest struct {
|
||||||
@ -559,6 +577,10 @@ type DeleteDocumentRequest struct {
|
|||||||
Id int64 `json:"id" validate:"required"`
|
Id int64 `json:"id" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeleteNodeGroupRequest struct {
|
||||||
|
Id int64 `json:"id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
type DeleteNodeRequest struct {
|
type DeleteNodeRequest struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
}
|
}
|
||||||
@ -764,6 +786,7 @@ type FilterNodeListRequest struct {
|
|||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
Size int `form:"size"`
|
Size int `form:"size"`
|
||||||
Search string `form:"search,omitempty"`
|
Search string `form:"search,omitempty"`
|
||||||
|
NodeGroupId *int64 `form:"node_group_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterNodeListResponse struct {
|
type FilterNodeListResponse struct {
|
||||||
@ -855,6 +878,12 @@ type Follow struct {
|
|||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GenerateCaptchaResponse struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
type GetAdsDetailRequest struct {
|
type GetAdsDetailRequest struct {
|
||||||
Id int64 `form:"id"`
|
Id int64 `form:"id"`
|
||||||
}
|
}
|
||||||
@ -1107,6 +1136,17 @@ type GetMessageLogListResponse struct {
|
|||||||
List []MessageLog `json:"list"`
|
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 {
|
type GetNodeMultiplierResponse struct {
|
||||||
Periods []TimePeriod `json:"periods"`
|
Periods []TimePeriod `json:"periods"`
|
||||||
}
|
}
|
||||||
@ -1234,11 +1274,19 @@ type GetSubscribeGroupListResponse struct {
|
|||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GetSubscribeGroupMappingRequest struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetSubscribeGroupMappingResponse struct {
|
||||||
|
List []SubscribeGroupMappingItem `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
type GetSubscribeListRequest struct {
|
type GetSubscribeListRequest struct {
|
||||||
Page int64 `form:"page" validate:"required"`
|
Page int64 `form:"page" validate:"required"`
|
||||||
Size int64 `form:"size" validate:"required"`
|
Size int64 `form:"size" validate:"required"`
|
||||||
Language string `form:"language,omitempty"`
|
Language string `form:"language,omitempty"`
|
||||||
Search string `form:"search,omitempty"`
|
Search string `form:"search,omitempty"`
|
||||||
|
NodeGroupId int64 `form:"node_group_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetSubscribeListResponse struct {
|
type GetSubscribeListResponse struct {
|
||||||
@ -1423,6 +1471,18 @@ type GetUserTicketListResponse struct {
|
|||||||
List []Ticket `json:"list"`
|
List []Ticket `json:"list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GetUserTrafficStatsRequest struct {
|
||||||
|
UserSubscribeId string `form:"user_subscribe_id" validate:"required"`
|
||||||
|
Days int `form:"days" validate:"required,oneof=7 30"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetUserTrafficStatsResponse struct {
|
||||||
|
List []DailyTrafficStats `json:"list"`
|
||||||
|
TotalUpload int64 `json:"total_upload"`
|
||||||
|
TotalDownload int64 `json:"total_download"`
|
||||||
|
TotalTraffic int64 `json:"total_traffic"`
|
||||||
|
}
|
||||||
|
|
||||||
type GiftLog struct {
|
type GiftLog struct {
|
||||||
Type uint16 `json:"type"`
|
Type uint16 `json:"type"`
|
||||||
UserId int64 `json:"user_id"`
|
UserId int64 `json:"user_id"`
|
||||||
@ -1439,6 +1499,25 @@ type GoogleLoginCallbackRequest struct {
|
|||||||
State string `form:"state"`
|
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 {
|
type HasMigrateSeverNodeResponse struct {
|
||||||
HasMigrate bool `json:"has_migrate"`
|
HasMigrate bool `json:"has_migrate"`
|
||||||
}
|
}
|
||||||
@ -1536,6 +1615,8 @@ type Node struct {
|
|||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
Sort int `json:"sort,omitempty"`
|
Sort int `json:"sort,omitempty"`
|
||||||
|
NodeGroupId int64 `json:"node_group_id,omitempty"`
|
||||||
|
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@ -1557,6 +1638,29 @@ type NodeDNS struct {
|
|||||||
Domains []string `json:"domains"`
|
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"`
|
||||||
|
IsExpiredGroup bool `json:"is_expired_group"`
|
||||||
|
ExpiredDaysLimit int `json:"expired_days_limit"`
|
||||||
|
MaxTrafficGBExpired int64 `json:"max_traffic_gb_expired,omitempty"`
|
||||||
|
SpeedLimit int `json:"speed_limit"`
|
||||||
|
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 {
|
type NodeOutbound struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
@ -1774,6 +1878,15 @@ type PreviewSubscribeTemplateResponse struct {
|
|||||||
Template string `json:"template"` // 预览的模板内容
|
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 {
|
type PrivacyPolicyConfig struct {
|
||||||
PrivacyPolicy string `json:"privacy_policy"`
|
PrivacyPolicy string `json:"privacy_policy"`
|
||||||
}
|
}
|
||||||
@ -2055,6 +2168,17 @@ type QuotaTask struct {
|
|||||||
UpdatedAt int64 `json:"updated_at"`
|
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 {
|
type RechargeOrderRequest struct {
|
||||||
Amount int64 `json:"amount" validate:"required,gt=0,lte=2000000000"`
|
Amount int64 `json:"amount" validate:"required,gt=0,lte=2000000000"`
|
||||||
Payment int64 `json:"payment"`
|
Payment int64 `json:"payment"`
|
||||||
@ -2139,6 +2263,10 @@ type ResetAllSubscribeTokenResponse struct {
|
|||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResetGroupsRequest struct {
|
||||||
|
Confirm bool `json:"confirm" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
type ResetPasswordRequest struct {
|
type ResetPasswordRequest struct {
|
||||||
Identifier string `json:"identifier"`
|
Identifier string `json:"identifier"`
|
||||||
Email string `json:"email" validate:"required"`
|
Email string `json:"email" validate:"required"`
|
||||||
@ -2148,6 +2276,8 @@ type ResetPasswordRequest struct {
|
|||||||
UserAgent string `header:"User-Agent"`
|
UserAgent string `header:"User-Agent"`
|
||||||
LoginType string `header:"Login-Type"`
|
LoginType string `header:"Login-Type"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
|
CaptchaId string `json:"captcha_id,optional"`
|
||||||
|
CaptchaCode string `json:"captcha_code,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResetSortRequest struct {
|
type ResetSortRequest struct {
|
||||||
@ -2412,6 +2542,9 @@ type Subscribe struct {
|
|||||||
NewUserOnly bool `json:"new_user_only"`
|
NewUserOnly bool `json:"new_user_only"`
|
||||||
Nodes []int64 `json:"nodes"`
|
Nodes []int64 `json:"nodes"`
|
||||||
NodeTags []string `json:"node_tags"`
|
NodeTags []string `json:"node_tags"`
|
||||||
|
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||||
|
NodeGroupId int64 `json:"node_group_id"`
|
||||||
|
TrafficLimit []TrafficLimit `json:"traffic_limit"`
|
||||||
Show bool `json:"show"`
|
Show bool `json:"show"`
|
||||||
Sell bool `json:"sell"`
|
Sell bool `json:"sell"`
|
||||||
Sort int64 `json:"sort"`
|
Sort int64 `json:"sort"`
|
||||||
@ -2472,6 +2605,11 @@ type SubscribeGroup struct {
|
|||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SubscribeGroupMappingItem struct {
|
||||||
|
SubscribeName string `json:"subscribe_name"`
|
||||||
|
NodeGroupName string `json:"node_group_name"`
|
||||||
|
}
|
||||||
|
|
||||||
type SubscribeItem struct {
|
type SubscribeItem struct {
|
||||||
Subscribe
|
Subscribe
|
||||||
Sold int64 `json:"sold"`
|
Sold int64 `json:"sold"`
|
||||||
@ -2520,6 +2658,8 @@ type TelephoneLoginRequest struct {
|
|||||||
UserAgent string `header:"User-Agent"`
|
UserAgent string `header:"User-Agent"`
|
||||||
LoginType string `header:"Login-Type"`
|
LoginType string `header:"Login-Type"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
|
CaptchaId string `json:"captcha_id,optional"`
|
||||||
|
CaptchaCode string `json:"captcha_code,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TelephoneRegisterRequest struct {
|
type TelephoneRegisterRequest struct {
|
||||||
@ -2533,6 +2673,8 @@ type TelephoneRegisterRequest struct {
|
|||||||
UserAgent string `header:"User-Agent"`
|
UserAgent string `header:"User-Agent"`
|
||||||
LoginType string `header:"Login-Type,optional"`
|
LoginType string `header:"Login-Type,optional"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
|
CaptchaId string `json:"captcha_id,optional"`
|
||||||
|
CaptchaCode string `json:"captcha_code,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TelephoneResetPasswordRequest struct {
|
type TelephoneResetPasswordRequest struct {
|
||||||
@ -2545,6 +2687,8 @@ type TelephoneResetPasswordRequest struct {
|
|||||||
UserAgent string `header:"User-Agent"`
|
UserAgent string `header:"User-Agent"`
|
||||||
LoginType string `header:"Login-Type,optional"`
|
LoginType string `header:"Login-Type,optional"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
|
CaptchaId string `json:"captcha_id,optional"`
|
||||||
|
CaptchaCode string `json:"captcha_code,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TestEmailSendRequest struct {
|
type TestEmailSendRequest struct {
|
||||||
@ -2595,6 +2739,13 @@ type TosConfig struct {
|
|||||||
TosContent string `json:"tos_content"`
|
TosContent string `json:"tos_content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TrafficLimit struct {
|
||||||
|
StatType string `json:"stat_type"`
|
||||||
|
StatValue int64 `json:"stat_value"`
|
||||||
|
TrafficUsage int64 `json:"traffic_usage"`
|
||||||
|
SpeedLimit int64 `json:"speed_limit"`
|
||||||
|
}
|
||||||
|
|
||||||
type TrafficLog struct {
|
type TrafficLog struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
ServerId int64 `json:"server_id"`
|
ServerId int64 `json:"server_id"`
|
||||||
@ -2739,6 +2890,7 @@ type UpdateNodeRequest struct {
|
|||||||
ServerId int64 `json:"server_id"`
|
ServerId int64 `json:"server_id"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
|
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateOrderStatusRequest struct {
|
type UpdateOrderStatusRequest struct {
|
||||||
@ -2821,6 +2973,9 @@ type UpdateSubscribeRequest struct {
|
|||||||
NewUserOnly *bool `json:"new_user_only"`
|
NewUserOnly *bool `json:"new_user_only"`
|
||||||
Nodes []int64 `json:"nodes"`
|
Nodes []int64 `json:"nodes"`
|
||||||
NodeTags []string `json:"node_tags"`
|
NodeTags []string `json:"node_tags"`
|
||||||
|
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||||
|
NodeGroupId int64 `json:"node_group_id"`
|
||||||
|
TrafficLimit []TrafficLimit `json:"traffic_limit"`
|
||||||
Show *bool `json:"show"`
|
Show *bool `json:"show"`
|
||||||
Sell *bool `json:"sell"`
|
Sell *bool `json:"sell"`
|
||||||
Sort int64 `json:"sort"`
|
Sort int64 `json:"sort"`
|
||||||
@ -2982,6 +3137,8 @@ type UserLoginRequest struct {
|
|||||||
UserAgent string `header:"User-Agent"`
|
UserAgent string `header:"User-Agent"`
|
||||||
LoginType string `header:"Login-Type"`
|
LoginType string `header:"Login-Type"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
|
CaptchaId string `json:"captcha_id,optional"`
|
||||||
|
CaptchaCode string `json:"captcha_code,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRegisterRequest struct {
|
type UserRegisterRequest struct {
|
||||||
@ -2994,6 +3151,8 @@ type UserRegisterRequest struct {
|
|||||||
UserAgent string `header:"User-Agent"`
|
UserAgent string `header:"User-Agent"`
|
||||||
LoginType string `header:"Login-Type"`
|
LoginType string `header:"Login-Type"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
|
CaptchaId string `json:"captcha_id,optional"`
|
||||||
|
CaptchaCode string `json:"captcha_code,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserStatistics struct {
|
type UserStatistics struct {
|
||||||
@ -3041,6 +3200,8 @@ type UserSubscribeDetail struct {
|
|||||||
OrderId int64 `json:"order_id"`
|
OrderId int64 `json:"order_id"`
|
||||||
SubscribeId int64 `json:"subscribe_id"`
|
SubscribeId int64 `json:"subscribe_id"`
|
||||||
Subscribe Subscribe `json:"subscribe"`
|
Subscribe Subscribe `json:"subscribe"`
|
||||||
|
NodeGroupId int64 `json:"node_group_id"`
|
||||||
|
GroupLocked bool `json:"group_locked"`
|
||||||
StartTime int64 `json:"start_time"`
|
StartTime int64 `json:"start_time"`
|
||||||
ExpireTime int64 `json:"expire_time"`
|
ExpireTime int64 `json:"expire_time"`
|
||||||
ResetTime int64 `json:"reset_time"`
|
ResetTime int64 `json:"reset_time"`
|
||||||
@ -3127,10 +3288,12 @@ type UserTrafficData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type VeifyConfig struct {
|
type VeifyConfig struct {
|
||||||
|
CaptchaType string `json:"captcha_type"`
|
||||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||||
EnableLoginVerify bool `json:"enable_login_verify"`
|
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"`
|
||||||
EnableRegisterVerify bool `json:"enable_register_verify"`
|
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"`
|
||||||
EnableResetPasswordVerify bool `json:"enable_reset_password_verify"`
|
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"`
|
||||||
|
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VerifyCodeConfig struct {
|
type VerifyCodeConfig struct {
|
||||||
@ -3140,11 +3303,13 @@ type VerifyCodeConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type VerifyConfig struct {
|
type VerifyConfig struct {
|
||||||
|
CaptchaType string `json:"captcha_type"` // local or turnstile
|
||||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||||
TurnstileSecret string `json:"turnstile_secret"`
|
TurnstileSecret string `json:"turnstile_secret"`
|
||||||
EnableLoginVerify bool `json:"enable_login_verify"`
|
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` // User login captcha
|
||||||
EnableRegisterVerify bool `json:"enable_register_verify"`
|
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` // User register captcha
|
||||||
EnableResetPasswordVerify bool `json:"enable_reset_password_verify"`
|
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` // Admin login captcha
|
||||||
|
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"` // User reset password captcha
|
||||||
}
|
}
|
||||||
|
|
||||||
type VerifyEmailRequest struct {
|
type VerifyEmailRequest struct {
|
||||||
|
|||||||
109
pkg/captcha/local.go
Normal file
109
pkg/captcha/local.go
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
package captcha
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mojocn/base64Captcha"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
type localService struct {
|
||||||
|
redis *redis.Client
|
||||||
|
driver base64Captcha.Driver
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLocalService(redisClient *redis.Client) Service {
|
||||||
|
// Configure captcha driver - alphanumeric with visual effects (letters + numbers)
|
||||||
|
driver := base64Captcha.NewDriverString(
|
||||||
|
80, // height
|
||||||
|
240, // width
|
||||||
|
20, // noise count (more interference)
|
||||||
|
base64Captcha.OptionShowSlimeLine|base64Captcha.OptionShowSineLine, // show curved lines
|
||||||
|
5, // length (5 characters)
|
||||||
|
"abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", // source (exclude confusing chars)
|
||||||
|
nil, // bg color (use default)
|
||||||
|
nil, // fonts (use default)
|
||||||
|
nil, // fonts storage (use default)
|
||||||
|
)
|
||||||
|
return &localService{
|
||||||
|
redis: redisClient,
|
||||||
|
driver: driver,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *localService) Generate(ctx context.Context) (id string, image string, err error) {
|
||||||
|
// Generate captcha
|
||||||
|
captcha := base64Captcha.NewCaptcha(s.driver, &redisStore{
|
||||||
|
redis: s.redis,
|
||||||
|
ctx: ctx,
|
||||||
|
})
|
||||||
|
|
||||||
|
id, b64s, answer, err := captcha.Generate()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store answer in Redis with 5 minute expiration
|
||||||
|
key := fmt.Sprintf("captcha:%s", id)
|
||||||
|
err = s.redis.Set(ctx, key, answer, 5*time.Minute).Err()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, b64s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *localService) Verify(ctx context.Context, id string, code string, ip string) (bool, error) {
|
||||||
|
if id == "" || code == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fmt.Sprintf("captcha:%s", id)
|
||||||
|
|
||||||
|
// Get answer from Redis
|
||||||
|
answer, err := s.redis.Get(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete captcha after verification (one-time use)
|
||||||
|
s.redis.Del(ctx, key)
|
||||||
|
|
||||||
|
// Verify code (case-insensitive)
|
||||||
|
return strings.EqualFold(answer, code), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *localService) GetType() CaptchaType {
|
||||||
|
return CaptchaTypeLocal
|
||||||
|
}
|
||||||
|
|
||||||
|
// redisStore implements base64Captcha.Store interface
|
||||||
|
type redisStore struct {
|
||||||
|
redis *redis.Client
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisStore) Set(id string, value string) error {
|
||||||
|
key := fmt.Sprintf("captcha:%s", id)
|
||||||
|
return r.redis.Set(r.ctx, key, value, 5*time.Minute).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisStore) Get(id string, clear bool) string {
|
||||||
|
key := fmt.Sprintf("captcha:%s", id)
|
||||||
|
val, err := r.redis.Get(r.ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if clear {
|
||||||
|
r.redis.Del(r.ctx, key)
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisStore) Verify(id, answer string, clear bool) bool {
|
||||||
|
v := r.Get(id, clear)
|
||||||
|
return strings.EqualFold(v, answer)
|
||||||
|
}
|
||||||
49
pkg/captcha/service.go
Normal file
49
pkg/captcha/service.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
package captcha
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CaptchaType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CaptchaTypeLocal CaptchaType = "local"
|
||||||
|
CaptchaTypeTurnstile CaptchaType = "turnstile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service defines the captcha service interface
|
||||||
|
type Service interface {
|
||||||
|
// Generate generates a new captcha
|
||||||
|
// For local captcha: returns id and base64 image
|
||||||
|
// For turnstile: returns empty strings
|
||||||
|
Generate(ctx context.Context) (id string, image string, err error)
|
||||||
|
|
||||||
|
// Verify verifies the captcha
|
||||||
|
// For local captcha: token is captcha id, code is user input
|
||||||
|
// For turnstile: token is cf-turnstile-response, code is ignored
|
||||||
|
Verify(ctx context.Context, token string, code string, ip string) (bool, error)
|
||||||
|
|
||||||
|
// GetType returns the captcha type
|
||||||
|
GetType() CaptchaType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds the configuration for captcha service
|
||||||
|
type Config struct {
|
||||||
|
Type CaptchaType
|
||||||
|
RedisClient *redis.Client
|
||||||
|
TurnstileSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new captcha service based on the config
|
||||||
|
func NewService(config Config) Service {
|
||||||
|
switch config.Type {
|
||||||
|
case CaptchaTypeTurnstile:
|
||||||
|
return newTurnstileService(config.TurnstileSecret)
|
||||||
|
case CaptchaTypeLocal:
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
return newLocalService(config.RedisClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
pkg/captcha/turnstile.go
Normal file
37
pkg/captcha/turnstile.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package captcha
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/pkg/turnstile"
|
||||||
|
)
|
||||||
|
|
||||||
|
type turnstileService struct {
|
||||||
|
service turnstile.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTurnstileService(secret string) Service {
|
||||||
|
return &turnstileService{
|
||||||
|
service: turnstile.New(turnstile.Config{
|
||||||
|
Secret: secret,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *turnstileService) Generate(ctx context.Context) (id string, image string, err error) {
|
||||||
|
// Turnstile doesn't need server-side generation
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *turnstileService) Verify(ctx context.Context, token string, code string, ip string) (bool, error) {
|
||||||
|
if token == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify with Cloudflare Turnstile
|
||||||
|
return s.service.Verify(ctx, token, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *turnstileService) GetType() CaptchaType {
|
||||||
|
return CaptchaTypeTurnstile
|
||||||
|
}
|
||||||
@ -56,7 +56,9 @@ func (s *service) verify(ctx context.Context, secret string, token string, ip st
|
|||||||
_ = writer.WriteField("idempotency_key", key)
|
_ = writer.WriteField("idempotency_key", key)
|
||||||
}
|
}
|
||||||
_ = writer.Close()
|
_ = writer.Close()
|
||||||
client := &http.Client{}
|
client := &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
req, _ := http.NewRequest("POST", s.url, body)
|
req, _ := http.NewRequest("POST", s.url, body)
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
firstResult, err := client.Do(req)
|
firstResult, err := client.Do(req)
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import (
|
|||||||
"apis/admin/ads.api"
|
"apis/admin/ads.api"
|
||||||
"apis/admin/marketing.api"
|
"apis/admin/marketing.api"
|
||||||
"apis/admin/application.api"
|
"apis/admin/application.api"
|
||||||
|
"apis/admin/group.api"
|
||||||
"apis/public/user.api"
|
"apis/public/user.api"
|
||||||
"apis/public/subscribe.api"
|
"apis/public/subscribe.api"
|
||||||
"apis/public/redemption.api"
|
"apis/public/redemption.api"
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/logic/admin/group"
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@ -24,9 +25,10 @@ import (
|
|||||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"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/tool"
|
||||||
"github.com/perfect-panel/server/pkg/uuidx"
|
"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"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -126,8 +128,8 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parsePayload unMarshals the task payload into a structured format
|
// parsePayload unMarshals the task payload into a structured format
|
||||||
func (l *ActivateOrderLogic) parsePayload(ctx context.Context, payload []byte) (*types.ForthwithActivateOrderPayload, error) {
|
func (l *ActivateOrderLogic) parsePayload(ctx context.Context, payload []byte) (*queueTypes.ForthwithActivateOrderPayload, error) {
|
||||||
var p types.ForthwithActivateOrderPayload
|
var p queueTypes.ForthwithActivateOrderPayload
|
||||||
if err := json.Unmarshal(payload, &p); err != nil {
|
if err := json.Unmarshal(payload, &p); err != nil {
|
||||||
logger.WithContext(ctx).Error("[ActivateOrderLogic] Unmarshal payload failed",
|
logger.WithContext(ctx).Error("[ActivateOrderLogic] Unmarshal payload failed",
|
||||||
logger.Field("error", err.Error()),
|
logger.Field("error", err.Error()),
|
||||||
@ -322,6 +324,9 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger user group recalculation (runs in background)
|
||||||
|
l.triggerUserGroupRecalculation(ctx, userInfo.Id)
|
||||||
|
|
||||||
// Handle commission in separate goroutine to avoid blocking
|
// Handle commission in separate goroutine to avoid blocking
|
||||||
go l.handleCommission(context.Background(), userInfo, orderInfo)
|
go l.handleCommission(context.Background(), userInfo, orderInfo)
|
||||||
|
|
||||||
@ -782,6 +787,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,
|
// Renewal handles subscription renewal including subscription extension,
|
||||||
// traffic reset (if configured), commission processing, and notifications
|
// traffic reset (if configured), commission processing, and notifications
|
||||||
func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order, iapExpireAt int64) error {
|
func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order, iapExpireAt int64) error {
|
||||||
@ -907,6 +969,9 @@ func (l *ActivateOrderLogic) updateSubscriptionForRenewal(ctx context.Context, u
|
|||||||
|
|
||||||
userSub.ExpireTime = tool.AddTime(sub.UnitTime, orderInfo.Quantity, userSub.ExpireTime)
|
userSub.ExpireTime = tool.AddTime(sub.UnitTime, orderInfo.Quantity, userSub.ExpireTime)
|
||||||
userSub.Status = 1
|
userSub.Status = 1
|
||||||
|
// 续费时重置过期流量字段
|
||||||
|
userSub.ExpiredDownload = 0
|
||||||
|
userSub.ExpiredUpload = 0
|
||||||
|
|
||||||
if err := l.svc.UserModel.UpdateSubscribe(ctx, userSub); err != nil {
|
if err := l.svc.UserModel.UpdateSubscribe(ctx, userSub); err != nil {
|
||||||
logger.WithContext(ctx).Error("Update user subscribe failed", logger.Field("error", err.Error()))
|
logger.WithContext(ctx).Error("Update user subscribe failed", logger.Field("error", err.Error()))
|
||||||
@ -931,6 +996,8 @@ func (l *ActivateOrderLogic) ResetTraffic(ctx context.Context, orderInfo *order.
|
|||||||
// Reset traffic
|
// Reset traffic
|
||||||
userSub.Download = 0
|
userSub.Download = 0
|
||||||
userSub.Upload = 0
|
userSub.Upload = 0
|
||||||
|
userSub.ExpiredDownload = 0
|
||||||
|
userSub.ExpiredUpload = 0
|
||||||
userSub.Status = 1
|
userSub.Status = 1
|
||||||
|
|
||||||
if err := l.svc.UserModel.UpdateSubscribe(ctx, userSub); err != nil {
|
if err := l.svc.UserModel.UpdateSubscribe(ctx, userSub); err != nil {
|
||||||
@ -1242,6 +1309,7 @@ func (l *ActivateOrderLogic) RedemptionActivate(ctx context.Context, orderInfo *
|
|||||||
Traffic: us.Traffic,
|
Traffic: us.Traffic,
|
||||||
Download: us.Download,
|
Download: us.Download,
|
||||||
Upload: us.Upload,
|
Upload: us.Upload,
|
||||||
|
NodeGroupId: us.NodeGroupId,
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -1328,6 +1396,7 @@ func (l *ActivateOrderLogic) RedemptionActivate(ctx context.Context, orderInfo *
|
|||||||
Token: uuidx.SubscribeToken(orderInfo.OrderNo),
|
Token: uuidx.SubscribeToken(orderInfo.OrderNo),
|
||||||
UUID: uuid.New().String(),
|
UUID: uuid.New().String(),
|
||||||
Status: 1,
|
Status: 1,
|
||||||
|
NodeGroupId: sub.NodeGroupId, // Inherit node_group_id from subscription plan
|
||||||
}
|
}
|
||||||
|
|
||||||
err = l.svc.UserModel.InsertSubscribe(ctx, newSubscribe, tx)
|
err = l.svc.UserModel.InsertSubscribe(ctx, newSubscribe, tx)
|
||||||
@ -1374,6 +1443,9 @@ func (l *ActivateOrderLogic) RedemptionActivate(ctx context.Context, orderInfo *
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger user group recalculation (runs in background)
|
||||||
|
l.triggerUserGroupRecalculation(ctx, userInfo.Id)
|
||||||
|
|
||||||
// 7. 清理缓存(关键步骤:让节点获取最新订阅)
|
// 7. 清理缓存(关键步骤:让节点获取最新订阅)
|
||||||
l.clearServerCache(ctx, sub)
|
l.clearServerCache(ctx, sub)
|
||||||
|
|
||||||
|
|||||||
@ -62,7 +62,6 @@ func (l *CheckSubscriptionLogic) ProcessTask(ctx context.Context, _ *asynq.Task)
|
|||||||
}
|
}
|
||||||
l.clearServerCache(ctx, list...)
|
l.clearServerCache(ctx, list...)
|
||||||
logger.Infow("[Check Subscription Traffic] Update subscribe status", logger.Field("user_ids", ids), logger.Field("count", int64(len(ids))))
|
logger.Infow("[Check Subscription Traffic] Update subscribe status", logger.Field("user_ids", ids), logger.Field("count", int64(len(ids))))
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
logger.Info("[Check Subscription Traffic] No subscribe need to update")
|
logger.Info("[Check Subscription Traffic] No subscribe need to update")
|
||||||
}
|
}
|
||||||
@ -108,6 +107,7 @@ func (l *CheckSubscriptionLogic) ProcessTask(ctx context.Context, _ *asynq.Task)
|
|||||||
} else {
|
} else {
|
||||||
logger.Info("[Check Subscription Expire] No subscribe need to update")
|
logger.Info("[Check Subscription Expire] No subscribe need to update")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user