diff --git a/apis/admin/server.api b/apis/admin/server.api index 4dfe5eb..52b7715 100644 --- a/apis/admin/server.api +++ b/apis/admin/server.api @@ -89,16 +89,20 @@ type ( CreateRuleGroupRequest { Name string `json:"name" validate:"required"` Icon string `json:"icon"` + Type string `json:"type"` Tags []string `json:"tags"` Rules string `json:"rules"` + Default bool `json:"default"` Enable bool `json:"enable"` } UpdateRuleGroupRequest { Id int64 `json:"id" validate:"required"` Icon string `json:"icon"` + Type string `json:"type"` Name string `json:"name" validate:"required"` Tags []string `json:"tags"` Rules string `json:"rules"` + Default bool `json:"default"` Enable bool `json:"enable"` } DeleteRuleGroupRequest { diff --git a/apis/types.api b/apis/types.api index 60dbf5e..8773ba5 100644 --- a/apis/types.api +++ b/apis/types.api @@ -153,9 +153,10 @@ type ( NodePushInterval int64 `json:"node_push_interval"` } InviteConfig { - ForcedInvite bool `json:"forced_invite"` - ReferralPercentage int64 `json:"referral_percentage"` - OnlyFirstPurchase bool `json:"only_first_purchase"` + ForcedInvite bool `json:"forced_invite"` + FirstPurchasePercentage int64 `json:"first_purchase_percentage"` + FirstYearlyPurchasePercentage int64 `json:"first_yearly_purchase_percentage"` + NonFirstPurchasePercentage int64 `json:"non_first_purchase_percentage"` } TelegramConfig { TelegramBotToken string `json:"telegram_bot_token"` @@ -515,9 +516,11 @@ type ( Id int64 `json:"id"` Icon string `json:"icon"` Name string `json:"name" validate:"required"` + Type string `json:"type"` Tags []string `json:"tags"` Rules string `json:"rules"` Enable bool `json:"enable"` + Default bool `json:"default"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } diff --git a/etc/ppanel.yaml b/etc/ppanel.yaml index 7060440..d687631 100644 --- a/etc/ppanel.yaml +++ b/etc/ppanel.yaml @@ -1,5 +1,6 @@ Host: 0.0.0.0 Port: 8080 +<<<<<<< HEAD TLS: Enable: false CertFile: "" @@ -37,3 +38,41 @@ Redis: Host: redis:6379 Pass: DB: 0 +======= +Debug: false +JwtAuth: + AccessSecret: 1234567890 + AccessExpire: 604800 +Logger: + ServiceName: PPanel + Mode: console + Encoding: plain + TimeFormat: '2025-01-01 00:00:00.000' + Path: logs + Level: debug + MaxContentLength: 0 + Compress: false + Stat: true + KeepDays: 0 + StackCooldownMillis: 100 + MaxBackups: 0 + MaxSize: 0 + Rotation: daily + FileTimeFormat: 2025-01-01T00:00:00.000Z00:00 +MySQL: + Addr: 172.245.180.199:3306 + Dbname: ppanel + Username: ppanel + Password: ppanelpassword + Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai + MaxIdleConns: 10 + MaxOpenConns: 10 + SlowThreshold: 1000 +Redis: + Host: ppanel-cache:6379 + Pass: + DB: 0 +Administrator: + Password: password + Email: admin@ppanel.dev +>>>>>>> old diff --git a/go.mod b/go.mod index 6891fb7..c0145a7 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,7 @@ require ( ) require ( + github.com/Masterminds/sprig/v3 v3.3.0 github.com/fatih/color v1.18.0 github.com/goccy/go-json v0.10.4 github.com/golang-migrate/migrate/v4 v4.18.2 @@ -66,7 +67,10 @@ require ( require ( cloud.google.com/go/compute/metadata v0.6.0 // indirect + dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect github.com/alibabacloud-go/debug v1.0.1 // indirect github.com/alibabacloud-go/endpoint-util v1.1.0 // indirect @@ -99,6 +103,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -107,12 +112,15 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/openzipkin/zipkin-go v0.4.2 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/smartwalle/ncrypto v1.0.4 // indirect github.com/smartwalle/ngx v1.0.9 // indirect github.com/smartwalle/nsign v1.0.9 // indirect diff --git a/go.sum b/go.sum index a0a75b1..cc90adf 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= @@ -8,6 +10,12 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg6 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f h1:RDkg3pyE1qGbBpRWmvSN9RNZC5nUrOaEPiEpEb8y2f0= github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f/go.mod h1:zA7AF9RTfpluCfz0omI4t5KCMaWHUMicsZoMccnaT44= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= @@ -208,6 +216,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hibiken/asynq v0.24.1 h1:+5iIEAyA9K/lcSPvx3qoPtsKJeKI5u9aOIvUmSsazEw= github.com/hibiken/asynq v0.24.1/go.mod h1:u5qVeSbrnfT+vtG5Mq8ZPzQu/BmCKMHvTGb91uy9Tts= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= @@ -249,6 +259,10 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= @@ -287,6 +301,8 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/smartwalle/alipay/v3 v3.2.23 h1:i1VwJeu70EmwpsXXz6GZZnMAtRx5MTfn2dPoql/L3zE= github.com/smartwalle/alipay/v3 v3.2.23/go.mod h1:lVqFiupPf8YsAXaq5JXcwqnOUC2MCF+2/5vub+RlagE= github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8= diff --git a/initialize/email.go b/initialize/email.go index e2e9be9..f57d30a 100644 --- a/initialize/email.go +++ b/initialize/email.go @@ -17,13 +17,11 @@ func Email(ctx *svc.ServiceContext) { logger.Debug("Email config initialization") method, err := ctx.AuthModel.FindOneByMethod(context.Background(), "email") if err != nil { - panic(fmt.Sprintf("failed to find email auth method: %v", err.Error())) + panic(fmt.Sprintf("[Error] Initialization Failed to find email auth method: %v", err.Error())) } var cfg config.EmailConfig var emailConfig = new(auth.EmailAuthConfig) - if err := emailConfig.Unmarshal(method.Config); err != nil { - panic(fmt.Sprintf("failed to unmarshal email auth config: %v", err.Error())) - } + emailConfig.Unmarshal(method.Config) tool.DeepCopy(&cfg, emailConfig) cfg.Enable = *method.Enabled value, _ := json.Marshal(emailConfig.PlatformConfig) diff --git a/initialize/migrate/database/00002_init_basic_data.up.sql b/initialize/migrate/database/00002_init_basic_data.up.sql index f779c28..dcd0619 100644 --- a/initialize/migrate/database/00002_init_basic_data.up.sql +++ b/initialize/migrate/database/00002_init_basic_data.up.sql @@ -37,7 +37,7 @@ VALUES (1, 'Clash', 'Clash', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648 (6, 'Netch', 'Netch', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), (7, 'Quantumult', 'Quantumult', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), (8, 'Shadowrocket', 'Shadowrocket', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), - (9, 'Singhandle', 'Singhandle', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), + (9, 'SingBox', ' SingBox', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), (10, 'Surfboard', 'Surfboard', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), (11, 'Surge', 'Surge', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), (12, 'V2box', 'V2box', '2025-04-22 14:25:16.648', '2025-04-22 14:25:16.648'), @@ -90,11 +90,15 @@ VALUES (1, 'site', 'SiteLogo', '/favicon.svg', 'string', 'Site Logo', '2025-04-2 '2025-04-22 14:25:16.640'), (23, 'invite', 'ForcedInvite', 'false', 'bool', 'Forced invite', '2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'), - (24, 'invite', 'ReferralPercentage', '20', 'int', 'Referral percentage', '2025-04-22 14:25:16.640', + (24, 'invite', 'FirstPurchasePercentage', '20', 'int', 'First purchase commission percentage', '2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'), - (25, 'invite', 'OnlyFirstPurchase', 'false', 'bool', 'Only first purchase', '2025-04-22 14:25:16.640', + (25, 'invite', 'NonFirstPurchasePercentage', '10', 'int', 'Non-first purchase commission percentage', '2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'), - (26, 'register', 'StopRegister', 'false', 'bool', 'is stop register', '2025-04-22 14:25:16.640', + (26, 'invite', 'ForcedInvite', 'false', 'bool', 'Forced invite', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (42, 'invite', 'FirstYearlyPurchasePercentage', '25', 'int', 'First yearly purchase commission percentage', '2025-04-22 14:25:16.640', + '2025-04-22 14:25:16.640'), + (27, 'register', 'StopRegister', 'false', 'bool', 'is stop register', '2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'), (27, 'register', 'EnableTrial', 'false', 'bool', 'is enable trial', '2025-04-22 14:25:16.640', '2025-04-22 14:25:16.640'), diff --git a/initialize/migrate/database/02007_adapte_rule.down.sql b/initialize/migrate/database/02007_adapte_rule.down.sql new file mode 100644 index 0000000..ed5d22f --- /dev/null +++ b/initialize/migrate/database/02007_adapte_rule.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE `server_rule_group` +DROP COLUMN `default`, +DROP COLUMN `type`; diff --git a/initialize/migrate/database/02007_adapte_rule.up.sql b/initialize/migrate/database/02007_adapte_rule.up.sql new file mode 100644 index 0000000..0c30d54 --- /dev/null +++ b/initialize/migrate/database/02007_adapte_rule.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE `server_rule_group` +ADD COLUMN `default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Is Default Group', +ADD COLUMN `type` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'Rule Group Type'; \ No newline at end of file diff --git a/initialize/mobile.go b/initialize/mobile.go index 816bae2..ac784ea 100644 --- a/initialize/mobile.go +++ b/initialize/mobile.go @@ -3,7 +3,6 @@ package initialize import ( "context" "encoding/json" - "fmt" "github.com/perfect-panel/server/pkg/logger" @@ -21,9 +20,7 @@ func Mobile(ctx *svc.ServiceContext) { } var cfg config.MobileConfig var mobileConfig auth.MobileAuthConfig - if err := mobileConfig.Unmarshal(method.Config); err != nil { - panic(fmt.Sprintf("failed to unmarshal mobile auth config: %v", err.Error())) - } + mobileConfig.Unmarshal(method.Config) tool.DeepCopy(&cfg, mobileConfig) cfg.Enable = *method.Enabled value, _ := json.Marshal(mobileConfig.PlatformConfig) diff --git a/internal/config/config.go b/internal/config/config.go index 1d51df4..d56acfa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -119,9 +119,10 @@ type File struct { } type InviteConfig struct { - ForcedInvite bool `yaml:"ForcedInvite" default:"false"` - ReferralPercentage int64 `yaml:"ReferralPercentage" default:"0"` - OnlyFirstPurchase bool `yaml:"OnlyFirstPurchase" default:"false"` + ForcedInvite bool `yaml:"ForcedInvite" default:"false"` + FirstPurchasePercentage int64 `yaml:"FirstPurchasePercentage" default:"20"` + FirstYearlyPurchasePercentage int64 `yaml:"FirstYearlyPurchasePercentage" default:"25"` + NonFirstPurchasePercentage int64 `yaml:"NonFirstPurchasePercentage" default:"10"` } type Telegram struct { diff --git a/internal/handler/admin/server/createRuleGroupHandler.go b/internal/handler/admin/server/createRuleGroupHandler.go index 08791b0..89a436a 100644 --- a/internal/handler/admin/server/createRuleGroupHandler.go +++ b/internal/handler/admin/server/createRuleGroupHandler.go @@ -8,7 +8,7 @@ import ( "github.com/perfect-panel/server/pkg/result" ) -// Create rule group +// CreateRuleGroupHandler Create rule group func CreateRuleGroupHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { return func(c *gin.Context) { var req types.CreateRuleGroupRequest diff --git a/internal/logic/admin/authMethod/getAuthMethodConfigLogic.go b/internal/logic/admin/authMethod/getAuthMethodConfigLogic.go index e79b4a9..febee66 100644 --- a/internal/logic/admin/authMethod/getAuthMethodConfigLogic.go +++ b/internal/logic/admin/authMethod/getAuthMethodConfigLogic.go @@ -18,7 +18,7 @@ type GetAuthMethodConfigLogic struct { svcCtx *svc.ServiceContext } -// Get auth method config +// NewGetAuthMethodConfigLogic Get auth method config func NewGetAuthMethodConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAuthMethodConfigLogic { return &GetAuthMethodConfigLogic{ Logger: logger.WithContext(ctx), diff --git a/internal/logic/admin/authMethod/updateAuthMethodConfigLogic.go b/internal/logic/admin/authMethod/updateAuthMethodConfigLogic.go index 3279ac2..c20a45f 100644 --- a/internal/logic/admin/authMethod/updateAuthMethodConfigLogic.go +++ b/internal/logic/admin/authMethod/updateAuthMethodConfigLogic.go @@ -40,34 +40,32 @@ func (l *UpdateAuthMethodConfigLogic) UpdateAuthMethodConfig(req *types.UpdateAu tool.DeepCopy(method, req) if req.Config != nil { - if value, ok := req.Config.(map[string]interface{}); ok { - if req.Method == "email" && value["verify_email_template"] == "" { - value["verify_email_template"] = email.DefaultEmailVerifyTemplate - } - if req.Method == "email" && value["expiration_email_template"] == "" { - value["expiration_email_template"] = email.DefaultExpirationEmailTemplate - } - if req.Method == "email" && value["maintenance_email_template"] == "" { - value["maintenance_email_template"] = email.DefaultMaintenanceEmailTemplate - } - if req.Method == "email" && value["traffic_exceed_email_template"] == "" { - value["traffic_exceed_email_template"] = email.DefaultTrafficExceedEmailTemplate - } - - if value["platform_config"] != nil { - platformConfig, err := validatePlatformConfig(value["platform"].(string), value["platform_config"].(map[string]interface{})) - if err != nil { - l.Errorw("validate platform config failed", logger.Field("config", req.Config), logger.Field("error", err.Error())) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "validate platform config failed: %v", err.Error()) - } - req.Config.(map[string]interface{})["platform_config"] = platformConfig - } + _, exist := req.Config.(map[string]interface{}) + if !exist { + req.Config = initializePlatformConfig(req.Method).(string) } + if req.Method == "email" { + configs, _ := json.Marshal(req.Config) + emailConfig := new(auth.EmailAuthConfig) + emailConfig.Unmarshal(string(configs)) + req.Config = emailConfig + } + + if req.Method == "mobile" { + configs, _ := json.Marshal(req.Config) + mobileConfig := new(auth.MobileAuthConfig) + mobileConfig.Unmarshal(string(configs)) + req.Config = mobileConfig + } + bytes, err := json.Marshal(req.Config) if err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "marshal config failed: %v", err.Error()) } method.Config = string(bytes) + } else { + // initialize platform config + method.Config = initializePlatformConfig(req.Method).(string) } err = l.svcCtx.AuthModel.Update(l.ctx, method) if err != nil { @@ -124,3 +122,26 @@ func validatePlatformConfig(platform string, cfg map[string]interface{}) (interf } return config, nil } + +func initializePlatformConfig(platform string) interface{} { + var result interface{} + switch platform { + case "email": + result = new(auth.EmailAuthConfig).Marshal() + case "mobile": + result = new(auth.MobileAuthConfig).Marshal() + case "apple": + result = new(auth.AppleAuthConfig).Marshal() + case "google": + result = new(auth.GoogleAuthConfig).Marshal() + case "github": + result = new(auth.GithubAuthConfig).Marshal() + case "facebook": + result = new(auth.FacebookAuthConfig).Marshal() + case "telegram": + result = new(auth.TelegramAuthConfig).Marshal() + case "device": + result = new(auth.DeviceConfig).Marshal() + } + return result +} diff --git a/internal/logic/admin/server/createRuleGroupLogic.go b/internal/logic/admin/server/createRuleGroupLogic.go index d136f9b..619fbe7 100644 --- a/internal/logic/admin/server/createRuleGroupLogic.go +++ b/internal/logic/admin/server/createRuleGroupLogic.go @@ -53,17 +53,26 @@ func (l *CreateRuleGroupLogic) CreateRuleGroup(req *types.CreateRuleGroupRequest if err != nil { return err } - - err = l.svcCtx.ServerModel.InsertRuleGroup(l.ctx, &server.RuleGroup{ - Name: req.Name, - Icon: req.Icon, - Tags: tool.StringSliceToString(req.Tags), - Rules: strings.Join(rs, "\n"), - Enable: req.Enable, - }) + info := &server.RuleGroup{ + Name: req.Name, + Icon: req.Icon, + Type: req.Type, + Tags: tool.StringSliceToString(req.Tags), + Rules: strings.Join(rs, "\n"), + Default: req.Default, + Enable: req.Enable, + } + err = l.svcCtx.ServerModel.InsertRuleGroup(l.ctx, info) if err != nil { l.Errorw("[CreateRuleGroup] Insert Database Error: ", logger.Field("error", err.Error())) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create server rule group error: %v", err) } + if req.Default { + if err = l.svcCtx.ServerModel.SetDefaultRuleGroup(l.ctx, info.Id); err != nil { + l.Errorw("[CreateRuleGroup] Set Default Rule Group Error: ", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "set default rule group error: %v", err) + } + } + return nil } diff --git a/internal/logic/admin/server/getRuleGroupListLogic.go b/internal/logic/admin/server/getRuleGroupListLogic.go index 4f3f96f..c3e1a3f 100644 --- a/internal/logic/admin/server/getRuleGroupListLogic.go +++ b/internal/logic/admin/server/getRuleGroupListLogic.go @@ -38,9 +38,11 @@ func (l *GetRuleGroupListLogic) GetRuleGroupList() (resp *types.GetRuleGroupResp Id: v.Id, Icon: v.Icon, Name: v.Name, + Type: v.Type, Tags: strings.Split(v.Tags, ","), Rules: v.Rules, Enable: v.Enable, + Default: v.Default, CreatedAt: v.CreatedAt.UnixMilli(), UpdatedAt: v.UpdatedAt.UnixMilli(), } diff --git a/internal/logic/admin/server/updateRuleGroupLogic.go b/internal/logic/admin/server/updateRuleGroupLogic.go index 7463bfd..500ba02 100644 --- a/internal/logic/admin/server/updateRuleGroupLogic.go +++ b/internal/logic/admin/server/updateRuleGroupLogic.go @@ -36,15 +36,23 @@ func (l *UpdateRuleGroupLogic) UpdateRuleGroup(req *types.UpdateRuleGroupRequest return err } err = l.svcCtx.ServerModel.UpdateRuleGroup(l.ctx, &server.RuleGroup{ - Id: req.Id, - Icon: req.Icon, - Name: req.Name, - Tags: tool.StringSliceToString(req.Tags), - Rules: strings.Join(rs, "\n"), - Enable: req.Enable, + Id: req.Id, + Icon: req.Icon, + Type: req.Type, + Name: req.Name, + Tags: tool.StringSliceToString(req.Tags), + Rules: strings.Join(rs, "\n"), + Default: req.Default, + Enable: req.Enable, }) if err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error()) } + if req.Default { + if err = l.svcCtx.ServerModel.SetDefaultRuleGroup(l.ctx, req.Id); err != nil { + l.Errorf("SetDefaultRuleGroup error: %v", err.Error()) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error()) + } + } return nil } diff --git a/internal/logic/admin/user/updateUserBasicInfoLogic.go b/internal/logic/admin/user/updateUserBasicInfoLogic.go index 787cfa5..fb673e4 100644 --- a/internal/logic/admin/user/updateUserBasicInfoLogic.go +++ b/internal/logic/admin/user/updateUserBasicInfoLogic.go @@ -44,6 +44,7 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi userInfo.Balance = req.Balance userInfo.GiftAmount = req.GiftAmount userInfo.Commission = req.Commission + // 手动设置 IsAdmin 字段,因为类型不匹配(*bool vs bool) userInfo.IsAdmin = &req.IsAdmin userInfo.Enable = &req.Enable diff --git a/internal/logic/admin/user/updateUserSubscribeLogic.go b/internal/logic/admin/user/updateUserSubscribeLogic.go index 61dcf56..4b50771 100644 --- a/internal/logic/admin/user/updateUserSubscribeLogic.go +++ b/internal/logic/admin/user/updateUserSubscribeLogic.go @@ -33,8 +33,15 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs l.Errorw("FindOneUserSubscribe failed:", logger.Field("error", err.Error()), logger.Field("userSubscribeId", req.UserSubscribeId)) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindOneUserSubscribe failed: %v", err.Error()) } + expiredAt := time.UnixMilli(req.ExpiredAt) + if time.Since(expiredAt).Minutes() > 0 { + userSub.Status = 3 + } else { + userSub.Status = 1 + } + err = l.svcCtx.UserModel.UpdateSubscribe(l.ctx, &user.Subscribe{ - Id: req.UserSubscribeId, + Id: userSub.Id, UserId: userSub.UserId, OrderId: userSub.OrderId, SubscribeId: req.SubscribeId, diff --git a/internal/logic/common/getStatLogic.go b/internal/logic/common/getStatLogic.go index df14af0..003b317 100644 --- a/internal/logic/common/getStatLogic.go +++ b/internal/logic/common/getStatLogic.go @@ -120,10 +120,11 @@ func (l *GetStatLogic) GetStat() (resp *types.GetStatResponse, err error) { protocol = append(protocol, p) } resp = &types.GetStatResponse{ - User: u, - Node: n, - Country: int64(len(country)), - Protocol: protocol, + User: u, + Node: n, + Country: int64(len(country)), + Protocol: protocol, + OnlineDevice: l.svcCtx.DeviceManager.GetOnlineDeviceCount(), } val, _ := json.Marshal(*resp) _ = l.svcCtx.Redis.Set(l.ctx, config.CommonStatCacheKey, string(val), time.Duration(3600)*time.Second).Err() diff --git a/internal/logic/public/order/closeOrderLogic.go b/internal/logic/public/order/closeOrderLogic.go index 95de100..fc22eec 100644 --- a/internal/logic/public/order/closeOrderLogic.go +++ b/internal/logic/public/order/closeOrderLogic.go @@ -82,7 +82,7 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error { return err } deduction := userInfo.GiftAmount + orderInfo.GiftAmount - err = tx.Model(&user.User{}).Where("id = ?", orderInfo.UserId).Update("deduction", deduction).Error + err = tx.Model(&user.User{}).Where("id = ?", orderInfo.UserId).Update("gift_amount", deduction).Error if err != nil { l.Errorw("[CloseOrder] Refund deduction amount failed", logger.Field("error", err.Error()), diff --git a/internal/logic/public/order/getDiscount.go b/internal/logic/public/order/getDiscount.go index c645e89..34c16a9 100644 --- a/internal/logic/public/order/getDiscount.go +++ b/internal/logic/public/order/getDiscount.go @@ -10,5 +10,6 @@ func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64) float64 finalDiscount = discount.Discount } } + return float64(finalDiscount) / float64(100) } diff --git a/internal/logic/public/order/preCreateOrderLogic.go b/internal/logic/public/order/preCreateOrderLogic.go index e25dc14..4e16715 100644 --- a/internal/logic/public/order/preCreateOrderLogic.go +++ b/internal/logic/public/order/preCreateOrderLogic.go @@ -24,7 +24,8 @@ type PreCreateOrderLogic struct { svcCtx *svc.ServiceContext } -// Pre create order +// NewPreCreateOrderLogic creates a new pre-create order logic instance for order preview operations. +// It initializes the logger with context and sets up the service context for database operations. func NewPreCreateOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PreCreateOrderLogic { return &PreCreateOrderLogic{ Logger: logger.WithContext(ctx), @@ -33,12 +34,21 @@ func NewPreCreateOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Pr } } +// PreCreateOrder calculates order pricing preview including discounts, coupons, gift amounts, and fees +// without actually creating an order. It validates subscription plans, coupons, and payment methods +// to provide accurate pricing information for the frontend order preview. func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (resp *types.PreOrderResponse, 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") } + + if req.Quantity <= 0 { + l.Debugf("[PreCreateOrder] Quantity is less than or equal to 0, setting to 1") + req.Quantity = 1 + } + // find subscribe plan sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, req.SubscribeId) if err != nil { @@ -52,6 +62,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r discount = getDiscount(dis, req.Quantity) } price := sub.UnitPrice * req.Quantity + amount := int64(float64(price) * discount) discountAmount := price - amount var couponAmount int64 @@ -72,7 +83,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r }) if err != nil { - l.Logger.Error("[PreCreateOrder] Database query error", logger.Field("error", err.Error()), logger.Field("user_id", u.Id), logger.Field("coupon", req.Coupon)) + l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", err.Error()), logger.Field("user_id", u.Id), logger.Field("coupon", req.Coupon)) return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find coupon error: %v", err.Error()) } @@ -103,7 +114,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r if req.Payment != 0 { payment, err := l.svcCtx.PaymentModel.FindOne(l.ctx, req.Payment) if err != nil { - l.Logger.Error("[PreCreateOrder] Database query error", logger.Field("error", err.Error()), logger.Field("payment", req.Payment)) + l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", err.Error()), logger.Field("payment", req.Payment)) return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find payment method error: %v", err.Error()) } // Calculate the handling fee diff --git a/internal/logic/public/order/purchaseLogic.go b/internal/logic/public/order/purchaseLogic.go index 98263d2..cabeecc 100644 --- a/internal/logic/public/order/purchaseLogic.go +++ b/internal/logic/public/order/purchaseLogic.go @@ -31,7 +31,8 @@ const ( CloseOrderTimeMinutes = 15 ) -// NewPurchaseLogic purchase Subscription +// NewPurchaseLogic creates a new purchase logic instance for subscription purchase operations. +// It initializes the logger with context and sets up the service context for database operations. func NewPurchaseLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PurchaseLogic { return &PurchaseLogic{ Logger: logger.WithContext(ctx), @@ -40,6 +41,9 @@ func NewPurchaseLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Purchase } } +// Purchase processes new subscription purchase orders including validation, discount calculation, +// coupon processing, gift amount deduction, fee calculation, and order creation with database transaction. +// It handles the complete purchase workflow from user validation to order creation and task scheduling. func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.PurchaseOrderResponse, err error) { u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) @@ -47,6 +51,12 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P logger.Error("current user is not found in context") return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") } + + if req.Quantity <= 0 { + l.Debugf("[Purchase] Quantity is less than or equal to 0, setting to 1") + req.Quantity = 1 + } + // find user subscription userSub, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, u.Id) if err != nil { @@ -142,7 +152,7 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P // find payment method payment, err := l.svcCtx.PaymentModel.FindOne(l.ctx, req.Payment) if err != nil { - l.Logger.Error("[Purchase] Database query error", logger.Field("error", err.Error()), logger.Field("payment", req.Payment)) + l.Errorw("[Purchase] Database query error", logger.Field("error", err.Error()), logger.Field("payment", req.Payment)) return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find payment method error: %v", err.Error()) } var feeAmount int64 @@ -180,8 +190,8 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P // update user deduction && Pre deduction ,Return after canceling the order if orderInfo.GiftAmount > 0 { // update user deduction && Pre deduction ,Return after canceling the order - if e := l.svcCtx.UserModel.Update(l.ctx, u, db); err != nil { - l.Errorw("[Purchase] Database update error", logger.Field("error", err.Error()), logger.Field("user", u)) + if e := l.svcCtx.UserModel.Update(l.ctx, u, db); e != nil { + l.Errorw("[Purchase] Database update error", logger.Field("error", e.Error()), logger.Field("user", u)) return e } // create deduction record @@ -195,7 +205,7 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P } if e := db.Model(&user.GiftAmountLog{}).Create(&giftAmountLog).Error; e != nil { l.Errorw("[Purchase] Database insert error", - logger.Field("error", err.Error()), + logger.Field("error", e.Error()), logger.Field("deductionLog", giftAmountLog), ) return e @@ -213,14 +223,14 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P } val, err := json.Marshal(payload) if err != nil { - l.Errorw("[CreateOrder] Marshal payload error", logger.Field("error", err.Error()), logger.Field("payload", payload)) + l.Errorw("[Purchase] Marshal payload error", logger.Field("error", err.Error()), logger.Field("payload", payload)) } task := asynq.NewTask(queue.DeferCloseOrder, val, asynq.MaxRetry(3)) taskInfo, err := l.svcCtx.Queue.Enqueue(task, asynq.ProcessIn(CloseOrderTimeMinutes*time.Minute)) if err != nil { - l.Errorw("[CreateOrder] Enqueue task error", logger.Field("error", err.Error()), logger.Field("task", task)) + l.Errorw("[Purchase] Enqueue task error", logger.Field("error", err.Error()), logger.Field("task", task)) } else { - l.Infow("[CreateOrder] Enqueue task success", logger.Field("TaskID", taskInfo.ID)) + l.Infow("[Purchase] Enqueue task success", logger.Field("TaskID", taskInfo.ID)) } return &types.PurchaseOrderResponse{ diff --git a/internal/logic/public/order/renewalLogic.go b/internal/logic/public/order/renewalLogic.go index 9608b7f..6692628 100644 --- a/internal/logic/public/order/renewalLogic.go +++ b/internal/logic/public/order/renewalLogic.go @@ -27,7 +27,7 @@ type RenewalLogic struct { svcCtx *svc.ServiceContext } -// Renewal Subscription +// NewRenewalLogic creates a new renewal logic instance for subscription renewal operations func NewRenewalLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RenewalLogic { return &RenewalLogic{ Logger: logger.WithContext(ctx), @@ -36,12 +36,19 @@ func NewRenewalLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RenewalLo } } +// Renewal processes subscription renewal orders including discount calculation, +// coupon validation, gift amount deduction, fee calculation, and order creation func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.RenewalOrderResponse, 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") } + if req.Quantity <= 0 { + l.Debugf("[Renewal] Quantity is less than or equal to 0, setting to 1") + req.Quantity = 1 + } + orderNo := tool.GenerateTradeNo() // find user subscribe userSubscribe, err := l.svcCtx.UserModel.FindOneUserSubscribe(l.ctx, req.UserSubscribeID) @@ -100,7 +107,7 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene payment, err := l.svcCtx.PaymentModel.FindOne(l.ctx, req.Payment) if err != nil { l.Errorw("[Renewal] Database query error", logger.Field("error", err.Error()), logger.Field("payment", req.Payment)) - return nil, errors.Wrapf(err, "find payment error: %v", err.Error()) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find payment error: %v", err.Error()) } amount -= coupon @@ -109,8 +116,8 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene if u.GiftAmount > 0 { if u.GiftAmount >= amount { deductionAmount = amount + u.GiftAmount -= deductionAmount amount = 0 - u.GiftAmount -= amount } else { deductionAmount = u.GiftAmount amount -= u.GiftAmount @@ -152,7 +159,7 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene if orderInfo.GiftAmount > 0 { // update user deduction && Pre deduction ,Return after canceling the order if err := l.svcCtx.UserModel.Update(l.ctx, u, db); err != nil { - l.Errorw("[Purchase] Database update error", logger.Field("error", err.Error()), logger.Field("user", u)) + l.Errorw("[Renewal] Database update error", logger.Field("error", err.Error()), logger.Field("user", u)) return err } // create deduction record diff --git a/internal/logic/public/portal/getSubscriptionLogic.go b/internal/logic/public/portal/getSubscriptionLogic.go index 5e85447..8796f1d 100644 --- a/internal/logic/public/portal/getSubscriptionLogic.go +++ b/internal/logic/public/portal/getSubscriptionLogic.go @@ -43,10 +43,26 @@ func (l *GetSubscriptionLogic) GetSubscription() (resp *types.GetSubscriptionRes tool.DeepCopy(&sub, item) if item.Discount != "" { var discount []types.SubscribeDiscount + _ = json.Unmarshal([]byte(item.Discount), &discount) sub.Discount = discount - list[i] = sub } + + // 计算节点数量(通过服务组查询关联的实际节点数量) + if item.ServerGroup != "" { + // 获取服务组ID列表 + groupIds := tool.StringToInt64Slice(item.ServerGroup) + + // 通过服务组查询关联的节点数量 + servers, err := l.svcCtx.ServerModel.FindServerListByGroupIds(l.ctx, groupIds) + if err != nil { + l.Errorw("[Site GetSubscription] FindServerListByGroupIds error", logger.Field("error", err.Error())) + sub.ServerCount = 0 + } else { + sub.ServerCount = int64(len(servers)) + } + } + list[i] = sub } resp.List = list diff --git a/internal/logic/public/portal/purchaseCheckoutLogic.go b/internal/logic/public/portal/purchaseCheckoutLogic.go index 7ce1f9e..1b71b08 100644 --- a/internal/logic/public/portal/purchaseCheckoutLogic.go +++ b/internal/logic/public/portal/purchaseCheckoutLogic.go @@ -122,6 +122,7 @@ func (l *PurchaseCheckoutLogic) PurchaseCheckout(req *types.CheckoutOrderRequest if err = l.balancePayment(userInfo, orderInfo); err != nil { return nil, err } + resp = &types.CheckoutOrderResponse{ Type: "balance", // Payment completed immediately } @@ -325,13 +326,27 @@ func (l *PurchaseCheckoutLogic) queryExchangeRate(to string, src int64) (amount // balancePayment processes balance payment with gift amount priority logic // It prioritizes using gift amount first, then regular balance, and creates proper audit logs func (l *PurchaseCheckoutLogic) balancePayment(u *user.User, o *order.Order) error { + var userInfo user.User + var err error if o.Amount == 0 { // No payment required for zero-amount orders - return nil + l.Logger.Info( + "[PurchaseCheckout] No payment required for zero-amount order", + logger.Field("orderNo", o.OrderNo), + logger.Field("userId", u.Id), + ) + err := l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, o.OrderNo, 2) + if err != nil { + l.Errorw("[PurchaseCheckout] Update order status error", + logger.Field("error", err.Error()), + logger.Field("orderNo", o.OrderNo), + logger.Field("userId", u.Id)) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Update order status error: %s", err.Error()) + } + goto activation } - var userInfo user.User - err := l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error { + err = l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error { // Retrieve latest user information with row-level locking err := db.Model(&user.User{}).Where("id = ?", u.Id).First(&userInfo).Error if err != nil { @@ -420,6 +435,7 @@ func (l *PurchaseCheckoutLogic) balancePayment(u *user.User, o *order.Order) err return err } +activation: // Enqueue order activation task for immediate processing payload := queueType.ForthwithActivateOrderPayload{ OrderNo: o.OrderNo, diff --git a/internal/logic/public/subscribe/querySubscribeListLogic.go b/internal/logic/public/subscribe/querySubscribeListLogic.go index 09dee07..18299dd 100644 --- a/internal/logic/public/subscribe/querySubscribeListLogic.go +++ b/internal/logic/public/subscribe/querySubscribeListLogic.go @@ -40,6 +40,7 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList() (resp *types.QuerySubscri list := make([]types.Subscribe, len(data)) for i, item := range data { var sub types.Subscribe + tool.DeepCopy(&sub, item) if item.Discount != "" { var discount []types.SubscribeDiscount @@ -48,6 +49,15 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList() (resp *types.QuerySubscri list[i] = sub } list[i] = sub + // 通过服务组查询关联的节点数量 + servers, err := l.svcCtx.ServerModel.FindServerListByGroupIds(l.ctx, sub.ServerGroup) + if err != nil { + l.Errorw("[QuerySubscribeListLogic] FindServerListByGroupIds error", logger.Field("error", err.Error())) + sub.ServerCount = 0 + } else { + sub.ServerCount = int64(len(servers)) + } + list[i] = sub } resp.List = list return diff --git a/internal/logic/public/user/bindOAuthCallbackLogic.go b/internal/logic/public/user/bindOAuthCallbackLogic.go index 49ea59d..26e8e0e 100644 --- a/internal/logic/public/user/bindOAuthCallbackLogic.go +++ b/internal/logic/public/user/bindOAuthCallbackLogic.go @@ -52,8 +52,10 @@ func (l *BindOAuthCallbackLogic) BindOAuthCallback(req *types.BindOAuthCallbackR err = l.google(req) case "apple": err = l.apple(req) + case "telegram": + err = l.telegram(req) default: - l.Errorw("oauth login method not support: %v", logger.Field("method", req.Method)) + l.Errorw("oauth login method not support", logger.Field("method", req.Method)) return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "oauth login method not support: %v", req.Method) } if err != nil { @@ -212,3 +214,7 @@ func (l *BindOAuthCallbackLogic) apple(req *types.BindOAuthCallbackRequest) erro } return nil } + +func (l *BindOAuthCallbackLogic) telegram(req *types.BindOAuthCallbackRequest) error { + return nil +} diff --git a/internal/logic/public/user/queryUserSubscribeLogic.go b/internal/logic/public/user/queryUserSubscribeLogic.go index a8c7863..f570a48 100644 --- a/internal/logic/public/user/queryUserSubscribeLogic.go +++ b/internal/logic/public/user/queryUserSubscribeLogic.go @@ -60,6 +60,25 @@ func (l *QueryUserSubscribeLogic) QueryUserSubscribe() (resp *types.QueryUserSub } } + // 计算节点数量(通过服务组关联的实际节点数量) + if item.Subscribe != nil { + // 获取服务组ID列表 + groupIds := tool.StringToInt64Slice(item.Subscribe.ServerGroup) + + // 通过服务组查询关联的节点数量 + servers, err := l.svcCtx.ServerModel.FindServerListByGroupIds(l.ctx, groupIds) + if err != nil { + l.Errorw("[QueryUserSubscribeLogic] FindServerListByGroupIds error", logger.Field("error", err.Error())) + sub.Subscribe.ServerCount = 0 + } else { + sub.Subscribe.ServerCount = int64(len(servers)) + } + + // 保留原始服务器ID列表用于其他用途 + serverIds := tool.StringToInt64Slice(item.Subscribe.Server) + sub.Subscribe.Server = serverIds + } + sub.ResetTime = calculateNextResetTime(&sub) resp.List = append(resp.List, sub) } diff --git a/internal/logic/public/user/updateBindEmailLogic.go b/internal/logic/public/user/updateBindEmailLogic.go index a722b62..f56ff8c 100644 --- a/internal/logic/public/user/updateBindEmailLogic.go +++ b/internal/logic/public/user/updateBindEmailLogic.go @@ -48,7 +48,7 @@ func (l *UpdateBindEmailLogic) UpdateBindEmail(req *types.UpdateBindEmailRequest if m.Id > 0 { return errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "email already bind") } - if errors.Is(err, gorm.ErrRecordNotFound) { + if method.Id == 0 { method = &user.AuthMethods{ UserId: u.Id, AuthType: "email", diff --git a/internal/logic/subscribe/subscribeLogic.go b/internal/logic/subscribe/subscribeLogic.go index f0a3f1d..6e8f763 100644 --- a/internal/logic/subscribe/subscribeLogic.go +++ b/internal/logic/subscribe/subscribeLogic.go @@ -9,6 +9,7 @@ import ( "github.com/perfect-panel/server/pkg/adapter" "github.com/perfect-panel/server/pkg/adapter/shadowrocket" "github.com/perfect-panel/server/pkg/adapter/surfboard" + "github.com/perfect-panel/server/pkg/adapter/surge" "github.com/perfect-panel/server/internal/model/server" @@ -117,10 +118,47 @@ func (l *SubscribeLogic) getServers(userSub *user.Subscribe) ([]*server.Server, serverIds := tool.StringToInt64Slice(subDetails.Server) groupIds := tool.StringToInt64Slice(subDetails.ServerGroup) + // 🔍 订阅ID 2的详细调试 + if userSub.SubscribeId == 2 { + l.Infof("🔍 [DEBUG Subscribe 2] === 开始调试订阅ID 2 ===") + l.Infof("🔍 [DEBUG Subscribe 2] Subscribe详情: %+v", subDetails) + l.Infof("🔍 [DEBUG Subscribe 2] Server字段: %s", subDetails.Server) + l.Infof("🔍 [DEBUG Subscribe 2] ServerGroup字段: %s", subDetails.ServerGroup) + l.Infof("🔍 [DEBUG Subscribe 2] 解析后的serverIds: %v", serverIds) + l.Infof("🔍 [DEBUG Subscribe 2] 解析后的groupIds: %v", groupIds) + } + l.Debugf("[Generate Subscribe]serverIds: %v, groupIds: %v", serverIds, groupIds) + // 查询所有服务器用于调试 + allServers, _ := l.svc.ServerModel.FindAllServer(l.ctx.Request.Context()) + if userSub.SubscribeId == 2 { + l.Infof("🔍 [DEBUG Subscribe 2] 数据库中所有服务器:") + for _, srv := range allServers { + l.Infof("🔍 [DEBUG Subscribe 2] ID:%d Name:%s Protocol:%s Enable:%v GroupID:%d", + srv.Id, srv.Name, srv.Protocol, *srv.Enable, srv.GroupId) + } + } + servers, err := l.svc.ServerModel.FindServerDetailByGroupIdsAndIds(l.ctx.Request.Context(), groupIds, serverIds) + if userSub.SubscribeId == 2 { + l.Infof("🔍 [DEBUG Subscribe 2] 查询结果服务器数量: %d", len(servers)) + for i, srv := range servers { + l.Infof("🔍 [DEBUG Subscribe 2] 结果服务器 %d: ID=%d Name=%s Protocol=%s Enable=%v", + i+1, srv.Id, srv.Name, srv.Protocol, *srv.Enable) + } + + // 检查AnyTLS服务器 + anytlsServers := []*server.Server{} + for _, srv := range servers { + if srv.Protocol == "anytls" { + anytlsServers = append(anytlsServers, srv) + } + } + l.Infof("🔍 [DEBUG Subscribe 2] AnyTLS服务器数量: %d", len(anytlsServers)) + } + l.Debugf("[Query Subscribe]found servers: %v", len(servers)) if err != nil { @@ -235,8 +273,9 @@ func (l *SubscribeLogic) buildClientConfig(req *types.SubscribeRequest, userSub }) case "loon": resp = proxyManager.BuildLoon(userSub.UUID) + l.setLoonHeaders() case "surfboard": - subsURL := l.getSubscribeURL(userSub.Token) + subsURL := l.getSubscribeURL(userSub.Token, "surfboard") resp = proxyManager.BuildSurfboard(l.svc.Config.Site.SiteName, surfboard.UserInfo{ Upload: userSub.Upload, Download: userSub.Download, @@ -248,7 +287,17 @@ func (l *SubscribeLogic) buildClientConfig(req *types.SubscribeRequest, userSub l.setSurfboardHeaders() case "v2rayn": resp = proxyManager.BuildV2rayN(userSub.UUID) - + case "surge": + subsURL := l.getSubscribeURL(userSub.Token, "surge") + resp = proxyManager.BuildSurge(l.svc.Config.Site.SiteName, surge.UserInfo{ + UUID: userSub.UUID, + Upload: userSub.Upload, + Download: userSub.Download, + TotalTraffic: userSub.Traffic, + ExpiredDate: userSub.ExpireTime, + SubscribeURL: subsURL, + }) + l.setSurgeHeaders() default: resp = proxyManager.BuildGeneral(userSub.UUID) } @@ -270,14 +319,24 @@ func (l *SubscribeLogic) setSurfboardHeaders() { l.ctx.Header("Content-Type", "application/octet-stream; charset=UTF-8") } -func (l *SubscribeLogic) getSubscribeURL(token string) string { +func (l *SubscribeLogic) setSurgeHeaders() { + l.ctx.Header("content-disposition", fmt.Sprintf("attachment;filename*=UTF-8''%s.conf", url.QueryEscape(l.svc.Config.Site.SiteName))) + l.ctx.Header("Content-Type", "application/octet-stream; charset=UTF-8") +} + +func (l *SubscribeLogic) setLoonHeaders() { + l.ctx.Header("content-disposition", fmt.Sprintf("attachment;filename*=UTF-8''%s.conf", url.QueryEscape(l.svc.Config.Site.SiteName))) + l.ctx.Header("Content-Type", "application/octet-stream; charset=UTF-8") +} + +func (l *SubscribeLogic) getSubscribeURL(token, flag string) string { if l.svc.Config.Subscribe.PanDomain { return fmt.Sprintf("https://%s", l.ctx.Request.Host) } if l.svc.Config.Subscribe.SubscribeDomain != "" { domains := strings.Split(l.svc.Config.Subscribe.SubscribeDomain, "\n") - return fmt.Sprintf("https://%s%s?token=%s&flag=surfboard", domains[0], l.svc.Config.Subscribe.SubscribePath, token) + return fmt.Sprintf("https://%s%s?token=%s&flag=%s", domains[0], l.svc.Config.Subscribe.SubscribePath, token, flag) } return fmt.Sprintf("https://%s%s?token=%s&flag=surfboard", l.ctx.Request.Host, l.svc.Config.Subscribe.SubscribePath, token) diff --git a/internal/model/auth/auth.go b/internal/model/auth/auth.go index f98d5cd..f85070c 100644 --- a/internal/model/auth/auth.go +++ b/internal/model/auth/auth.go @@ -3,6 +3,8 @@ package auth import ( "encoding/json" "time" + + "github.com/perfect-panel/server/pkg/email" ) type Auth struct { @@ -124,15 +126,55 @@ type EmailAuthConfig struct { } func (l *EmailAuthConfig) Marshal() string { + if l.ExpirationEmailTemplate == "" { + l.ExpirationEmailTemplate = email.DefaultExpirationEmailTemplate + } + if l.ExpirationEmailTemplate == "" { + l.MaintenanceEmailTemplate = email.DefaultMaintenanceEmailTemplate + } + if l.TrafficExceedEmailTemplate == "" { + l.TrafficExceedEmailTemplate = email.DefaultTrafficExceedEmailTemplate + } + if l.VerifyEmailTemplate == "" { + l.VerifyEmailTemplate = email.DefaultEmailVerifyTemplate + } bytes, err := json.Marshal(l) if err != nil { - bytes, _ = json.Marshal(new(EmailAuthConfig)) + config := &EmailAuthConfig{ + Platform: "smtp", + PlatformConfig: new(SMTPConfig), + EnableVerify: true, + EnableNotify: true, + EnableDomainSuffix: false, + DomainSuffixList: "", + VerifyEmailTemplate: email.DefaultEmailVerifyTemplate, + ExpirationEmailTemplate: email.DefaultExpirationEmailTemplate, + MaintenanceEmailTemplate: email.DefaultMaintenanceEmailTemplate, + TrafficExceedEmailTemplate: email.DefaultTrafficExceedEmailTemplate, + } + + bytes, _ = json.Marshal(config) } return string(bytes) } -func (l *EmailAuthConfig) Unmarshal(data string) error { - return json.Unmarshal([]byte(data), &l) +func (l *EmailAuthConfig) Unmarshal(data string) { + err := json.Unmarshal([]byte(data), &l) + if err != nil { + config := &EmailAuthConfig{ + Platform: "smtp", + PlatformConfig: new(SMTPConfig), + EnableVerify: true, + EnableNotify: true, + EnableDomainSuffix: false, + DomainSuffixList: "", + VerifyEmailTemplate: email.DefaultEmailVerifyTemplate, + ExpirationEmailTemplate: email.DefaultExpirationEmailTemplate, + MaintenanceEmailTemplate: email.DefaultMaintenanceEmailTemplate, + TrafficExceedEmailTemplate: email.DefaultTrafficExceedEmailTemplate, + } + _ = json.Unmarshal([]byte(config.Marshal()), &l) + } } // SMTPConfig Email SMTP configuration @@ -167,13 +209,28 @@ type MobileAuthConfig struct { func (l *MobileAuthConfig) Marshal() string { bytes, err := json.Marshal(l) if err != nil { - bytes, _ = json.Marshal(new(MobileAuthConfig)) + config := &MobileAuthConfig{ + Platform: "alibaba_cloud", + PlatformConfig: new(AlibabaCloudConfig), + EnableWhitelist: false, + Whitelist: []string{}, + } + bytes, _ = json.Marshal(config) } return string(bytes) } -func (l *MobileAuthConfig) Unmarshal(data string) error { - return json.Unmarshal([]byte(data), &l) +func (l *MobileAuthConfig) Unmarshal(data string) { + err := json.Unmarshal([]byte(data), &l) + if err != nil { + config := &MobileAuthConfig{ + Platform: "alibaba_cloud", + PlatformConfig: new(AlibabaCloudConfig), + EnableWhitelist: false, + Whitelist: []string{}, + } + _ = json.Unmarshal([]byte(config.Marshal()), &l) + } } type AlibabaCloudConfig struct { diff --git a/internal/model/server/model.go b/internal/model/server/model.go index 1af139a..daace5f 100644 --- a/internal/model/server/model.go +++ b/internal/model/server/model.go @@ -32,6 +32,8 @@ type customServerLogicModel interface { QueryAllRuleGroup(ctx context.Context) ([]*RuleGroup, error) FindServersByTag(ctx context.Context, tag string) ([]*Server, error) FindServerTags(ctx context.Context) ([]string, error) + + SetDefaultRuleGroup(ctx context.Context, id int64) error } var ( @@ -275,3 +277,16 @@ func (m *customServerModel) FindServersByTag(ctx context.Context, tag string) ([ }) return data, err } + +// SetDefaultRuleGroup sets the default rule group. + +func (m *customServerModel) SetDefaultRuleGroup(ctx context.Context, id int64) error { + return m.ExecCtx(ctx, func(conn *gorm.DB) error { + // Reset all groups to not default + if err := conn.Model(&RuleGroup{}).Where("`id` != ?", id).Update("default", false).Error; err != nil { + return err + } + // Set the specified group as default + return conn.Model(&RuleGroup{}).Where("`id` = ?", id).Update("default", true).Error + }, cacheServerRuleGroupAllKeys, fmt.Sprintf("cache:serverRuleGroup:%v", id)) +} diff --git a/internal/model/server/server.go b/internal/model/server/server.go index f36fe10..c185503 100644 --- a/internal/model/server/server.go +++ b/internal/model/server/server.go @@ -9,9 +9,12 @@ import ( ) const ( - RelayModeNone = "none" - RelayModeAll = "all" - RelayModeRandom = "random" + RelayModeNone = "none" + RelayModeAll = "all" + RelayModeRandom = "random" + RuleGroupTypeReject = "reject" + RuleGroupTypeDefault = "default" + RuleGroupTypeDirect = "direct" ) type ServerFilter struct { @@ -178,9 +181,11 @@ type RuleGroup struct { Id int64 `gorm:"primary_key"` Icon string `gorm:"type:MEDIUMTEXT;comment:Rule Group Icon"` Name string `gorm:"type:varchar(100);not null;default:'';comment:Rule Group Name"` + Type string `gorm:"type:varchar(100);not null;default:'';comment:Rule Group Type"` Tags string `gorm:"type:text;comment:Selected Node Tags"` Rules string `gorm:"type:MEDIUMTEXT;comment:Rules"` Enable bool `gorm:"type:tinyint(1);not null;default:1;comment:Rule Group Enable"` + Default bool `gorm:"type:tinyint(1);not null;default:0;comment:Rule Group is Default"` CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` UpdatedAt time.Time `gorm:"comment:Update Time"` } diff --git a/internal/model/user/subscribe.go b/internal/model/user/subscribe.go index 5eb3c64..e0f5ca6 100644 --- a/internal/model/user/subscribe.go +++ b/internal/model/user/subscribe.go @@ -82,6 +82,7 @@ func (m *defaultUserModel) QueryUserSubscribe(ctx context.Context, userId int64, // 订阅过期时间大于当前时间或者订阅结束时间大于当前时间 return conn.Where("`expire_time` > ? OR `finished_at` >= ? OR `expire_time` = ?", now, sevenDaysAgo, time.UnixMilli(0)). Preload("Subscribe"). + Order("created_at DESC"). Find(&list).Error }) return list, err diff --git a/internal/types/types.go b/internal/types/types.go index ad8157c..80f5fc1 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -32,6 +32,11 @@ type Announcement struct { UpdatedAt int64 `json:"updated_at"` } +type AnyTLS struct { + Port int `json:"port" validate:"required"` + SecurityConfig SecurityConfig `json:"security_config"` +} + type AppAuthCheckRequest struct { Method string `json:"method" validate:"required" validate:"required,oneof=device email mobile"` Account string `json:"account"` @@ -441,11 +446,13 @@ type CreatePaymentMethodRequest struct { } type CreateRuleGroupRequest struct { - Name string `json:"name" validate:"required"` - Icon string `json:"icon"` - Tags []string `json:"tags"` - Rules string `json:"rules"` - Enable bool `json:"enable"` + Name string `json:"name" validate:"required"` + Icon string `json:"icon"` + Type string `json:"type"` + Tags []string `json:"tags"` + Rules string `json:"rules"` + Default bool `json:"default"` + Enable bool `json:"enable"` } type CreateSubscribeGroupRequest struct { @@ -468,6 +475,7 @@ type CreateSubscribeRequest struct { GroupId int64 `json:"group_id"` ServerGroup []int64 `json:"server_group"` Server []int64 `json:"server"` + ServerCount int64 `json:"server_count"` Show *bool `json:"show"` Sell *bool `json:"sell"` DeductionRatio int64 `json:"deduction_ratio"` @@ -854,10 +862,11 @@ type GetServerUserListResponse struct { } type GetStatResponse struct { - User int64 `json:"user"` - Node int64 `json:"node"` - Country int64 `json:"country"` - Protocol []string `json:"protocol"` + User int64 `json:"user"` + Node int64 `json:"node"` + Country int64 `json:"country"` + Protocol []string `json:"protocol"` + OnlineDevice int64 `json:"online_device"` } type GetSubscribeDetailsRequest struct { @@ -1037,9 +1046,10 @@ type Hysteria2 struct { } type InviteConfig struct { - ForcedInvite bool `json:"forced_invite"` - ReferralPercentage int64 `json:"referral_percentage"` - OnlyFirstPurchase bool `json:"only_first_purchase"` + ForcedInvite bool `json:"forced_invite"` + FirstPurchasePercentage int64 `json:"first_purchase_percentage"` + FirstYearlyPurchasePercentage int64 `json:"first_yearly_purchase_percentage"` + NonFirstPurchasePercentage int64 `json:"non_first_purchase_percentage"` } type KickOfflineRequest struct { @@ -1553,9 +1563,11 @@ type ServerRuleGroup struct { Id int64 `json:"id"` Icon string `json:"icon"` Name string `json:"name" validate:"required"` + Type string `json:"type"` Tags []string `json:"tags"` Rules string `json:"rules"` Enable bool `json:"enable"` + Default bool `json:"default"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } @@ -1647,6 +1659,7 @@ type Subscribe struct { GroupId int64 `json:"group_id"` ServerGroup []int64 `json:"server_group"` Server []int64 `json:"server"` + ServerCount int64 `json:"server_count"` Show bool `json:"show"` Sell bool `json:"sell"` Sort int64 `json:"sort"` @@ -1955,12 +1968,14 @@ type UpdatePaymentMethodRequest struct { } type UpdateRuleGroupRequest struct { - Id int64 `json:"id" validate:"required"` - Icon string `json:"icon"` - Name string `json:"name" validate:"required"` - Tags []string `json:"tags"` - Rules string `json:"rules"` - Enable bool `json:"enable"` + Id int64 `json:"id" validate:"required"` + Icon string `json:"icon"` + Type string `json:"type"` + Name string `json:"name" validate:"required"` + Tags []string `json:"tags"` + Rules string `json:"rules"` + Default bool `json:"default"` + Enable bool `json:"enable"` } type UpdateSubscribeGroupRequest struct { diff --git a/pkg/adapter/adapter.go b/pkg/adapter/adapter.go index 4ad1e38..be121b9 100644 --- a/pkg/adapter/adapter.go +++ b/pkg/adapter/adapter.go @@ -1,6 +1,8 @@ package adapter import ( + "embed" + "github.com/perfect-panel/server/internal/model/server" "github.com/perfect-panel/server/pkg/adapter/clash" "github.com/perfect-panel/server/pkg/adapter/general" @@ -10,9 +12,17 @@ import ( "github.com/perfect-panel/server/pkg/adapter/shadowrocket" "github.com/perfect-panel/server/pkg/adapter/singbox" "github.com/perfect-panel/server/pkg/adapter/surfboard" + "github.com/perfect-panel/server/pkg/adapter/surge" "github.com/perfect-panel/server/pkg/adapter/v2rayn" ) +//go:embed template/* +var TemplateFS embed.FS + +var ( + AutoSelect = "Auto - UrlTest" +) + type Config struct { Nodes []*server.Server Rules []*server.RuleGroup @@ -25,62 +35,68 @@ type Adapter struct { func NewAdapter(cfg *Config) *Adapter { // 转换服务器列表 - proxies := adapterProxies(cfg.Nodes) - // 生成代理组 - proxyGroup, region := generateProxyGroup(proxies) - + proxies, nodes, tags := adapterProxies(cfg.Nodes) // 转换规则组 - g, r := adapterRules(cfg.Rules) - - // 加入兜底节点 - for i, group := range g { - if len(group.Proxies) == 0 { - g[i].Proxies = append([]string{"DIRECT"}, region...) - } + g, r, d := adapterRules(cfg.Rules) + if d == "" { + d = AutoSelect } - + // 生成默认代理组 + proxyGroup := append(generateDefaultGroup(), g...) // 合并代理组 - proxyGroup = RemoveEmptyGroup(append(proxyGroup, g...)) - // 处理标签 - proxyGroup = adapterTags(cfg.Tags, proxyGroup) + proxyGroup = SortGroups(proxyGroup, nodes, tags, d) return &Adapter{ Adapter: proxy.Adapter{ - Proxies: proxies, - Group: proxyGroup, - Rules: r, - Region: region, + Proxies: proxies, + Group: proxyGroup, + Rules: r, + Nodes: nodes, + Default: d, + TemplateFS: &TemplateFS, }, } } +// BuildClash generates a Clash configuration for the given UUID. func (m *Adapter) BuildClash(uuid string) ([]byte, error) { client := clash.NewClash(m.Adapter) return client.Build(uuid) } +// BuildGeneral generates a general configuration for the given UUID. func (m *Adapter) BuildGeneral(uuid string) []byte { return general.GenerateBase64General(m.Proxies, uuid) } +// BuildLoon generates a Loon configuration for the given UUID. func (m *Adapter) BuildLoon(uuid string) []byte { return loon.BuildLoon(m.Proxies, uuid) } +// BuildQuantumultX generates a Quantumult X configuration for the given UUID. func (m *Adapter) BuildQuantumultX(uuid string) string { return quantumultx.BuildQuantumultX(m.Proxies, uuid) } +// BuildSingbox generates a Singbox configuration for the given UUID. func (m *Adapter) BuildSingbox(uuid string) ([]byte, error) { return singbox.BuildSingbox(m.Adapter, uuid) } - func (m *Adapter) BuildShadowrocket(uuid string, userInfo shadowrocket.UserInfo) []byte { return shadowrocket.BuildShadowrocket(m.Proxies, uuid, userInfo) } +// BuildSurfboard generates a Surfboard configuration for the given site name and user info. func (m *Adapter) BuildSurfboard(siteName string, user surfboard.UserInfo) []byte { return surfboard.BuildSurfboard(m.Adapter, siteName, user) } + +// BuildV2rayN generates a V2rayN configuration for the given UUID. func (m *Adapter) BuildV2rayN(uuid string) []byte { return v2rayn.NewV2rayN(m.Adapter).Build(uuid) } + +// BuildSurge generates a Surge configuration for the given UUID and site name. +func (m *Adapter) BuildSurge(siteName string, user surge.UserInfo) []byte { + return surge.NewSurge(m.Adapter).Build(siteName, user) +} diff --git a/pkg/adapter/clash/clash.go b/pkg/adapter/clash/clash.go index 1a6bc68..0f9f35c 100644 --- a/pkg/adapter/clash/clash.go +++ b/pkg/adapter/clash/clash.go @@ -1,8 +1,11 @@ package clash import ( + "bytes" "fmt" + "text/template" + "github.com/Masterminds/sprig/v3" "github.com/perfect-panel/server/pkg/adapter/proxy" "github.com/perfect-panel/server/pkg/logger" "gopkg.in/yaml.v3" @@ -20,22 +23,16 @@ func NewClash(adapter proxy.Adapter) *Clash { func (c *Clash) Build(uuid string) ([]byte, error) { var proxies []Proxy - for _, v := range c.Proxies { - p, err := c.parseProxy(v, uuid) + for _, proxied := range c.Adapter.Proxies { + p, err := c.parseProxy(proxied, uuid) if err != nil { - logger.Errorf("Failed to parse proxy for %s: %s", v.Name, err.Error()) + logger.Errorw("Failed to parse proxy", logger.Field("error", err), logger.Field("proxy", p.Name)) continue } proxies = append(proxies, *p) } - var rawConfig RawConfig - if err := yaml.Unmarshal([]byte(DefaultTemplate), &rawConfig); err != nil { - return nil, fmt.Errorf("failed to unmarshal template: %w", err) - } - rawConfig.Proxies = proxies - // generate proxy groups var groups []ProxyGroup - for _, group := range c.Group { + for _, group := range c.Adapter.Group { groups = append(groups, ProxyGroup{ Name: group.Name, Type: string(group.Type), @@ -44,9 +41,38 @@ func (c *Clash) Build(uuid string) ([]byte, error) { Interval: group.Interval, }) } - rawConfig.ProxyGroups = groups - rawConfig.Rules = append(c.Rules, "MATCH,手动选择") - return yaml.Marshal(&rawConfig) + var rules = append(c.Rules, fmt.Sprintf("MATCH,%s", c.Default)) + + tmplBytes, err := c.TemplateFS.ReadFile("template/clash.tpl") + if err != nil { + logger.Errorw("Failed to read template file", logger.Field("error", err)) + return nil, fmt.Errorf("failed to read template file: %w", err) + } + tpl, err := template.New("clash.yaml").Funcs(sprig.FuncMap()).Funcs(template.FuncMap{ + "toYaml": func(v interface{}) string { + out, err := yaml.Marshal(v) + if err != nil { + return fmt.Sprintf("# YAML encode error: %v", err.Error()) + } + return string(out) + }, + }).Parse(string(tmplBytes)) + if err != nil { + logger.Errorw("[Clash] Failed to parse template", logger.Field("error", err)) + return nil, fmt.Errorf("failed to parse template: %w", err) + } + var buf bytes.Buffer + err = tpl.Execute(&buf, map[string]interface{}{ + "Proxies": proxies, + "ProxyGroups": groups, + "Rules": rules, + }) + if err != nil { + logger.Errorw("[Clash] Failed to execute template", logger.Field("error", err)) + return nil, fmt.Errorf("failed to execute template: %w", err) + } + + return buf.Bytes(), nil } func (c *Clash) parseProxy(p proxy.Proxy, uuid string) (*Proxy, error) { diff --git a/pkg/adapter/clash/default.go b/pkg/adapter/clash/default.go index 4766053..2c2ceb0 100644 --- a/pkg/adapter/clash/default.go +++ b/pkg/adapter/clash/default.go @@ -1,31 +1,50 @@ package clash const DefaultTemplate = ` -mixed-port: 7890 +mode: rule +ipv6: true allow-lan: true bind-address: "*" -mode: rule -log-level: info -external-controller: 127.0.0.1:9090 -global-client-fingerprint: chrome +mixed-port: 7890 +log-level: error unified-delay: true -geox-url: - mmdb: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.metadb" +tcp-concurrent: true +external-controller: 0.0.0.0:9090 + +tun: + enable: true + stack: system + auto-route: true + dns: enable: true + cache-algorithm: arc + listen: 0.0.0.0:1053 ipv6: true enhanced-mode: fake-ip fake-ip-range: 198.18.0.1/16 - use-hosts: true + fake-ip-filter: + - "*.lan" + - "lens.l.google.com" + - "*.srv.nintendo.net" + - "*.stun.playstation.net" + - "xbox.*.*.microsoft.com" + - "*.xboxlive.com" + - "*.msftncsi.com" + - "*.msftconnecttest.com" default-nameserver: - - 120.53.53.53 - - 1.12.12.12 + - 119.29.29.29 + - 223.5.5.5 nameserver: - - https://120.53.53.53/dns-query#skip-cert-verify=true - - tls://1.12.12.12#skip-cert-verify=true - proxy-server-nameserver: - - https://120.53.53.53/dns-query#skip-cert-verify=true - - tls://1.12.12.12#skip-cert-verify=true + - system + - 119.29.29.29 + - 223.5.5.5 + fallback: + - 8.8.8.8 + - 1.1.1.1 + fallback-filter: + geoip: true + geoip-code: CN proxies: diff --git a/pkg/adapter/general/uri.go b/pkg/adapter/general/uri.go index c1c925b..7a4ce72 100644 --- a/pkg/adapter/general/uri.go +++ b/pkg/adapter/general/uri.go @@ -63,6 +63,8 @@ func buildProxy(data proxy.Proxy, uuid string) string { return Hysteria2Uri(data, uuid) case "tuic": return TuicUri(data, uuid) + case "anytls": + return AnyTLSUri(data, uuid) default: return "" } @@ -271,6 +273,36 @@ func TuicUri(data proxy.Proxy, uuid string) string { return u.String() } +func AnyTLSUri(data proxy.Proxy, uuid string) string { + anytls, ok := data.Option.(proxy.AnyTLS) + if !ok { + return "" + } + + securityConfig := anytls.SecurityConfig + var query = make(url.Values) + + // 根据AnyTLS官方URI规范实现 + // 格式: anytls://[auth@]hostname[:port]/?[key=value]&[key=value]... + + // TLS配置 + setQuery(&query, "sni", securityConfig.SNI) + + // 是否允许不安全连接 + if securityConfig.AllowInsecure { + setQuery(&query, "insecure", "1") + } + + u := url.URL{ + Scheme: "anytls", + User: url.User(uuid), + Host: net.JoinHostPort(data.Server, strconv.Itoa(anytls.Port)), + RawQuery: query.Encode(), + Fragment: data.Name, + } + return u.String() +} + func setQuery(q *url.Values, k, v string) { if v != "" { q.Set(k, v) diff --git a/pkg/adapter/loon/build.go b/pkg/adapter/loon/build.go index d5eb35a..ebbc642 100644 --- a/pkg/adapter/loon/build.go +++ b/pkg/adapter/loon/build.go @@ -1,27 +1,61 @@ package loon import ( + "bytes" + "embed" + "strings" + "text/template" + "github.com/perfect-panel/server/pkg/adapter/proxy" + "github.com/perfect-panel/server/pkg/logger" ) +//go:embed *.tpl +var configFiles embed.FS + func BuildLoon(servers []proxy.Proxy, uuid string) []byte { uri := "" + nodes := make([]string, 0) for _, s := range servers { switch s.Protocol { case "vmess": + nodes = append(nodes, s.Name) uri += buildVMess(s, uuid) case "shadowsocks": + nodes = append(nodes, s.Name) uri += buildShadowsocks(s, uuid) case "trojan": + nodes = append(nodes, s.Name) uri += buildTrojan(s, uuid) case "vless": + nodes = append(nodes, s.Name) uri += buildVless(s, uuid) case "hysteria2": + nodes = append(nodes, s.Name) uri += buildHysteria2(s, uuid) default: continue } } + file, err := configFiles.ReadFile("default.tpl") + if err != nil { + logger.Errorf("read default surfboard config error: %v", err.Error()) + return nil + } + // replace template + tpl, err := template.New("default").Parse(string(file)) + if err != nil { + logger.Errorf("read default surfboard config error: %v", err.Error()) + return nil + } + var buf bytes.Buffer + if err = tpl.Execute(&buf, map[string]interface{}{ + "Proxies": uri, + "Nodes": strings.Join(nodes, ","), + }); err != nil { + logger.Errorf("Execute Loon template error: %v", err.Error()) + return nil + } - return []byte(uri) + return buf.Bytes() } diff --git a/pkg/adapter/loon/default.tpl b/pkg/adapter/loon/default.tpl new file mode 100644 index 0000000..bca1046 --- /dev/null +++ b/pkg/adapter/loon/default.tpl @@ -0,0 +1,58 @@ +[General] +ipv6-vif = auto +ip-mode = dual +skip-proxy = 192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,127.0.0.0/8,localhost,*.local +bypass-tun = 192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,127.0.0.0/8,localhost,*.local +dns-server = system,119.29.29.29,223.5.5.5 +hijack-dns = 8.8.8.8:53,8.8.4.4:53,1.1.1.1:53,1.0.0.1:53 +allow-wifi-access = true +wifi-access-http-port = 6888 +wifi-access-socks5-port = 6889 +proxy-test-url = http://bing.com/generate_204 +internet-test-url = http://wifi.vivo.com.cn/generate_204 +test-timeout = 5 +interface-mode = auto + +[Proxy] +{{.Proxies}} + +[Proxy Group] +🚀 Proxy = select,🌏 Auto,{{.Nodes}} +🌏 Auto = fallback,{{.Nodes}},interval = 600,max-timeout = 3000 +🍎 Apple = select,🚀 Proxy,🎯 Direct,{{.Nodes}} +🔍 Google = select,🚀 Proxy,{{.Nodes}} +🪟 Microsoft = select,🚀 Proxy,🎯 Direct,{{.Nodes}} +📠 X = select,🚀 Proxy,{{.Nodes}} +🤖 AI = select,🚀 Proxy,🎯 Direct,{{.Nodes}} +📟 Telegram = select,🚀 Proxy,{{.Nodes}} +📺 YouTube = select,🚀 Proxy,{{.Nodes}} +🇨🇳 China = select,🎯 Direct,🚀 Proxy,{{.Nodes}} +🐠 Final = select,🚀 Proxy,🎯 Direct,{{.Nodes}} +🎯 Direct = select,DIRECT + +[Remote Rule] +https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Loon/Apple/Apple.list, policy=🍎 Apple, tag=Apple, enabled=true +https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Loon/Apple/Apple_Domain.list, policy=🍎 Apple, tag=Apple_Domain, enabled=true +https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Loon/Google/Google.list, policy=🔍 Google, tag=Google, enabled=true +https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Loon/Microsoft/Microsoft.list, policy=🪟 Microsoft, tag=Microsoft, enabled=true +https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Loon/Twitter/Twitter.list, policy=📠 X, tag=X, enabled=true +https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Loon/OpenAI/OpenAI.list, policy=🤖 AI, tag=OpenAI, enabled=true +https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Loon/Telegram/Telegram.list, policy=📟 Telegram, tag=Telegram, enabled=true +https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Loon/YouTube/YouTube.list, policy=📺 YouTube, tag=YouTube, enabled=true +https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Loon/YouTubeMusic/YouTubeMusic.list, policy=📺 YouTube, tag=YouTubeMusic, enabled=true +https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Loon/Global/Global.list, policy=🚀 Proxy, tag=Global, enabled=true +https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Loon/Global/Global_Domain.list, policy=🚀 Proxy, tag=Global_Domain, enabled=true +https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Loon/ChinaMax/ChinaMax.list, policy=🇨🇳 China, tag=ChinaMax, enabled=true +https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Loon/ChinaMax/ChinaMax_Domain.list, policy=🇨🇳 China, tag=ChinaMax_Domain, enabled=true +https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Loon/Lan/Lan.list, policy=🎯 Direct, tag=LAN, enabled=true + +[Rule] +GEOIP,CN,🇨🇳 China +FINAL,🐠 Final + +[Rewrite] +# Redirect Google Service +^https?:\/\/(www.)?g\.cn 302 https://www.google.com +^https?:\/\/(www.)?google\.cn 302 https://www.google.com +# Redirect Githubusercontent +^https://.*\.githubusercontent\.com\/ header-replace Accept-Language en-us \ No newline at end of file diff --git a/pkg/adapter/proxy/proxy.go b/pkg/adapter/proxy/proxy.go index ee74d72..75760ff 100644 --- a/pkg/adapter/proxy/proxy.go +++ b/pkg/adapter/proxy/proxy.go @@ -1,21 +1,26 @@ package proxy +import "embed" + // Adapter represents a proxy adapter type Adapter struct { - Proxies []Proxy - Group []Group - Rules []string - Region []string + Proxies []Proxy + Group []Group + Rules []string // rule + Nodes []string // all node + Default string // Default Node + TemplateFS *embed.FS // Template file system } // Proxy represents a proxy server type Proxy struct { - Name string - Server string - Port int - Protocol string - Country string - Option any + Name string // Name of the proxy + Server string // Server address of the proxy + Port int // Port of the proxy server + Protocol string // Protocol type (e.g., shadowsocks, vless, vmess, trojan, hysteria2, tuic, anytls) + Country string // Country of the proxy + Tags []string // Tags for the proxy + Option any // Additional options for the proxy configuration } // Group represents a group of proxies @@ -25,6 +30,10 @@ type Group struct { Proxies []string URL string Interval int + Reject bool // Reject group + Direct bool // Direct group + Tags []string // Tags for the group + Default bool // Default group } type GroupType string diff --git a/pkg/adapter/singbox/build.go b/pkg/adapter/singbox/build.go index dc2d27e..8962c2b 100644 --- a/pkg/adapter/singbox/build.go +++ b/pkg/adapter/singbox/build.go @@ -79,7 +79,7 @@ func BuildSingbox(adapter proxy.Adapter, uuid string) ([]byte, error) { rawConfig["outbounds"] = proxies route := RouteOptions{ - Final: "手动选择", + Final: adapter.Default, Rules: []Rule{ { Inbound: []string{ @@ -114,7 +114,7 @@ func BuildSingbox(adapter proxy.Adapter, uuid string) ([]byte, error) { }, { ClashMode: "global", - Outbound: "手动选择", + Outbound: adapter.Default, }, { IPIsPrivate: true, diff --git a/pkg/adapter/surfboard/build.go b/pkg/adapter/surfboard/build.go index db3c63c..a481755 100644 --- a/pkg/adapter/surfboard/build.go +++ b/pkg/adapter/surfboard/build.go @@ -4,7 +4,6 @@ import ( "bytes" "embed" "fmt" - "net/url" "strings" "text/template" "time" @@ -23,42 +22,22 @@ var shadowsocksSupportMethod = []string{"aes-128-gcm", "aes-192-gcm", "aes-256-g func BuildSurfboard(servers proxy.Adapter, siteName string, user UserInfo) []byte { var proxies, proxyGroup string var removed []string - for _, node := range servers.Proxies { - if uri := buildProxy(node, user.UUID); uri != "" { - proxies += uri - } else { - removed = append(removed, node.Name) + var ps []string + + for _, p := range servers.Proxies { + switch p.Protocol { + case "shadowsocks": + proxies += buildShadowsocks(p, user.UUID) + case "trojan": + proxies += buildTrojan(p, user.UUID) + case "vmess": + proxies += buildVMess(p, user.UUID) + default: + removed = append(removed, p.Name) } + ps = append(ps, p.Name) } - for _, group := range servers.Group { - - if len(removed) > 0 { - group.Proxies = tool.RemoveStringElement(group.Proxies, removed...) - } - - if group.Type == proxy.GroupTypeSelect { - proxyGroup += fmt.Sprintf("%s = select, %s", group.Name, strings.Join(group.Proxies, ", ")) + "\r\n" - } else if group.Type == proxy.GroupTypeURLTest { - proxyGroup += fmt.Sprintf("%s = url-test, %s, url=%s, interval=%d", group.Name, strings.Join(group.Proxies, ", "), group.URL, group.Interval) + "\r\n" - } else if group.Type == proxy.GroupTypeFallback { - proxyGroup += fmt.Sprintf("%s = fallback, %s, url=%s, interval=%d", group.Name, strings.Join(group.Proxies, ", "), group.URL, group.Interval) + "\r\n" - } else { - logger.Errorf("[BuildSurfboard] unknown group type: %s", group.Type) - } - } - - var rules string - for _, rule := range servers.Rules { - if rule == "" { - continue - } - rules += rule + "\r\n" - } - - //final rule - rules += "FINAL, 手动选择" - file, err := configFiles.ReadFile("default.tpl") if err != nil { logger.Errorf("read default surfboard config error: %v", err.Error()) @@ -78,42 +57,24 @@ func BuildSurfboard(servers proxy.Adapter, siteName string, user UserInfo) []byt } else { expiredAt = user.ExpiredDate.Format("2006-01-02 15:04:05") } + + ps = tool.RemoveStringElement(ps, removed...) + proxyGroup = strings.Join(ps, ",") + // convert traffic upload := traffic.AutoConvert(user.Upload, false) download := traffic.AutoConvert(user.Download, false) total := traffic.AutoConvert(user.TotalTraffic, false) unusedTraffic := traffic.AutoConvert(user.TotalTraffic-user.Upload-user.Download, false) // query Host - urlParse, err := url.Parse(user.SubscribeURL) - if err != nil { - return nil - } - if err := tpl.Execute(&buf, map[string]interface{}{ - "Proxies": proxies, - "ProxyGroup": proxyGroup, - "SubscribeURL": user.SubscribeURL, - "SubscribeInfo": fmt.Sprintf("title=%s订阅信息, content=上传流量:%s\\n下载流量:%s\\n剩余流量: %s\\n套餐流量:%s\\n到期时间:%s", siteName, upload, download, unusedTraffic, total, expiredAt), - "SubscribeDomain": urlParse.Host, - "Rules": rules, + if err = tpl.Execute(&buf, map[string]interface{}{ + "Proxies": proxies, + "ProxyGroup": proxyGroup, + "SubscribeURL": user.SubscribeURL, + "SubscribeInfo": fmt.Sprintf("title=%s订阅信息, content=上传流量:%s\\n下载流量:%s\\n剩余流量: %s\\n套餐流量:%s\\n到期时间:%s", siteName, upload, download, unusedTraffic, total, expiredAt), }); err != nil { - logger.Errorf("build surfboard config error: %v", err.Error()) + logger.Errorf("build Surge config error: %v", err.Error()) return nil } return buf.Bytes() } - -func buildProxy(data proxy.Proxy, uuid string) string { - var p string - switch data.Protocol { - case "vmess": - p = buildVMess(data, uuid) - case "shadowsocks": - if !tool.Contains(shadowsocksSupportMethod, data.Option.(proxy.Shadowsocks).Method) { - return "" - } - p = buildShadowsocks(data, uuid) - case "trojan": - p = buildTrojan(data, uuid) - } - return p -} diff --git a/pkg/adapter/surfboard/default.tpl b/pkg/adapter/surfboard/default.tpl index e30aac0..89c48fe 100644 --- a/pkg/adapter/surfboard/default.tpl +++ b/pkg/adapter/surfboard/default.tpl @@ -1,29 +1,62 @@ -#!MANAGED-CONFIG {{ .SubscribeURL }} interval=43200 strict=true +#!MANAGED-CONFIG {{.SubscribeURL}} interval=43200 strict=true [General] -loglevel = notify -ipv6 = false -skip-proxy = localhost, *.local, injections.adguard.org, local.adguard.org, 0.0.0.0/8, 10.0.0.0/8, 17.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 192.88.99.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4, 255.255.255.255/32 -tls-provider = default -show-error-page-for-reject = true -dns-server = 223.6.6.6, 119.29.29.29, 119.28.28.28 +dns-server = system, 119.29.29.29, 223.5.5.5 +skip-proxy = 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 127.0.0.0/8, localhost, *.local +always-real-ip = *.lan, lens.l.google.com, *.srv.nintendo.net, *.stun.playstation.net, *.xboxlive.com, xbox.*.*.microsoft.com, *.msftncsi.com, *.msftconnecttest.com +proxy-test-url = http://www.gstatic.com/generate_204 +internet-test-url = http://connectivitycheck.platform.hicloud.com/generate_204 test-timeout = 5 -internet-test-url = http://bing.com -proxy-test-url = http://bing.com +http-listen = 0.0.0.0:6088 +socks5-listen = 0.0.0.0:6089 [Panel] -SubscribeInfo = {{ .SubscribeInfo }}, style=info - -# Surfboard 配置文档:https://manual.getsurfboard.com/ +SubscribeInfo = {{.SubscribeInfo}}, style=info [Proxy] -# 代理列表 -{{ .Proxies }} +{{.Proxies}} [Proxy Group] -# 代理组列表 -{{ .ProxyGroup }} +🚀 Proxy = select, 🌏 Auto, 🎯 Direct, include-other-group=🇺🇳 Nodes +🍎 Apple = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +🔍 Google = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +🪟 Microsoft = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +📺 GlobalMedia = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +🤖 AI = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +🪙 Crypto = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +🎮 Game = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +📟 Telegram = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +🇨🇳 China = select, 🎯 Direct, 🚀 Proxy, include-other-group=🇺🇳 Nodes +🐠 Final = select, 🎯 Direct, 🚀 Proxy, include-other-group=🇺🇳 Nodes +🌏 Auto = fallback, include-other-group=🇺🇳 Nodes, url=http://www.gstatic.com/generate_204, interval=600, timeout=5 +🎯 Direct = select, DIRECT, hidden=1 +🇺🇳 Nodes = select, {{.ProxyGroup}}, hidden=1 [Rule] -# 规则列表 -{{ .Rules }} +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Apple/Apple_All.list, 🍎 Apple +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Google/Google.list, 🔍 Google +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/GitHub/GitHub.list, 🪟 Microsoft +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Microsoft/Microsoft.list, 🪟 Microsoft +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/HBO/HBO.list, 📺 GlobalMedia +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Disney/Disney.list, 📺 GlobalMedia +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/TikTok/TikTok.list, 📺 GlobalMedia +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Netflix/Netflix.list, 📺 GlobalMedia +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/GlobalMedia/GlobalMedia_All_No_Resolve.list, 📺 GlobalMedia +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Telegram/Telegram.list, 📟 Telegram +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/OpenAI/OpenAI.list, 🤖 AI +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Gemini/Gemini.list, 🤖 AI +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Copilot/Copilot.list, 🤖 AI +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Claude/Claude.list, 🤖 AI +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Crypto/Crypto.list, 🪙 Crypto +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Cryptocurrency/Cryptocurrency.list, 🪙 Crypto +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Game/Game.list, 🎮 Game +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Global/Global_All_No_Resolve.list, 🚀 Proxy +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/ChinaMax/ChinaMax_All_No_Resolve.list, 🇨🇳 China +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Lan/Lan.list, 🎯 Direct + +GEOIP, CN, 🇨🇳 China +FINAL, 🐠 Final, dns-failed + +[URL Rewrite] +^https?:\/\/(www.)?g\.cn https://www.google.com 302 +^https?:\/\/(www.)?google\.cn https://www.google.com 302 \ No newline at end of file diff --git a/pkg/adapter/surge/default.tpl b/pkg/adapter/surge/default.tpl index 7375b37..6b70844 100644 --- a/pkg/adapter/surge/default.tpl +++ b/pkg/adapter/surge/default.tpl @@ -1,61 +1,79 @@ -#!MANAGED-CONFIG {{ .SubscribeURL }} interval=43200 strict=true -# Surge 的规则配置手册: https://manual.nssurge.com/ +#!MANAGED-CONFIG {{.SubscribeURL}} interval=43200 strict=true [General] loglevel = notify -# 从 Surge iOS 4 / Surge Mac 3.3.0 起,工具开始支持 DoH -doh-server = https://doh.pub/dns-query -# https://dns.alidns.com/dns-query, https://13800000000.rubyfish.cn/, https://dns.google/dns-query -dns-server = 223.5.5.5, 114.114.114.114 -tun-excluded-routes = 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 192.88.99.0/24, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 255.255.255.255/32 -skip-proxy = localhost, *.local, injections.adguard.org, local.adguard.org, captive.apple.com, guzzoni.apple.com, 0.0.0.0/8, 10.0.0.0/8, 17.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 192.88.99.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4, 255.255.255.255/32 - -wifi-assist = true -allow-wifi-access = true -wifi-access-http-port = 6152 -wifi-access-socks5-port = 6153 -http-listen = 0.0.0.0:6152 -socks5-listen = 0.0.0.0:6153 - -external-controller-access = surgepasswd@0.0.0.0:6170 -replica = false - -tls-provider = openssl -network-framework = false +external-controller-access = purinio@0.0.0.0:6170 exclude-simple-hostnames = true +show-error-page-for-reject = true +udp-priority = true +udp-policy-not-supported-behaviour = reject ipv6 = true - -test-timeout = 4 +ipv6-vif = auto proxy-test-url = http://www.gstatic.com/generate_204 -geoip-maxmind-url = https://unpkg.zhimg.com/rulestatic@1.0.1/Country.mmdb +internet-test-url = http://connectivitycheck.platform.hicloud.com/generate_204 +test-timeout = 5 +dns-server = system, 119.29.29.29, 223.5.5.5 +hijack-dns = 8.8.8.8:53, 8.8.4.4:53, 1.1.1.1:53, 1.0.0.1:53 +skip-proxy = 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 127.0.0.0/8, localhost, *.local +always-real-ip = *.lan, lens.l.google.com, *.srv.nintendo.net, *.stun.playstation.net, *.xboxlive.com, xbox.*.*.microsoft.com, *.msftncsi.com, *.msftconnecttest.com -[Replica] -hide-apple-request = true -hide-crashlytics-request = true -use-keyword-filter = false -hide-udp = false +# > Surge Mac Parameters +http-listen = 0.0.0.0:6088 +socks5-listen = 0.0.0.0:6089 + +# > Surge iOS Parameters +allow-wifi-access = true +allow-hotspot-access = true +wifi-access-http-port = 6088 +wifi-access-socks5-port = 6089 [Panel] -SubscribeInfo = {{ .SubscribeInfo }}, style=info - -# ----------------------------- -# Surge 的几种策略配置规范,请参考 https://manual.nssurge.com/policy/proxy.html -# 不同的代理策略有*很多*可选参数,请参考上方连接的 Parameters 一段,根据需求自行添加参数。 -# -# Surge 现已支持 UDP 转发功能,请参考: https://trello.com/c/ugOMxD3u/53-udp-%E8%BD%AC%E5%8F%91 -# Surge 现已支持 TCP-Fast-Open 技术,请参考: https://trello.com/c/ij65BU6Q/48-tcp-fast-open-troubleshooting-guide -# Surge 现已支持 ss-libev 的全部加密方式和混淆,请参考: https://trello.com/c/BTr0vG1O/47-ss-libev-%E7%9A%84%E6%94%AF%E6%8C%81%E6%83%85%E5%86%B5 -# ----------------------------- +SubscribeInfo = {{.SubscribeInfo}}, style=info [Proxy] -{{ .Proxies }} +{{.Proxies}} [Proxy Group] -# 代理组列表 -{{ .ProxyGroup }} +🚀 Proxy = select, 🌏 Auto, 🎯 Direct, include-other-group=🇺🇳 Nodes +🍎 Apple = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +🔍 Google = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +🪟 Microsoft = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +📺 GlobalMedia = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +🤖 AI = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +🪙 Crypto = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +🎮 Game = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +📟 Telegram = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +🇨🇳 China = select, 🎯 Direct, 🚀 Proxy, include-other-group=🇺🇳 Nodes +🐠 Final = select, 🚀 Proxy, 🎯 Direct, include-other-group=🇺🇳 Nodes +🌏 Auto = smart, include-other-group=🇺🇳 Nodes +🎯 Direct = select, DIRECT, hidden=1 +🇺🇳 Nodes = select, {{.ProxyGroup}}, hidden=1 [Rule] -{{ .Rules }} +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Apple/Apple_All.list, 🍎 Apple +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Google/Google.list, 🔍 Google +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/GitHub/GitHub.list, 🪟 Microsoft +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Microsoft/Microsoft.list, 🪟 Microsoft +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/HBO/HBO.list, 📺 GlobalMedia +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Disney/Disney.list, 📺 GlobalMedia +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/TikTok/TikTok.list, 📺 GlobalMedia +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Netflix/Netflix.list, 📺 GlobalMedia +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/GlobalMedia/GlobalMedia_All_No_Resolve.list, 📺 GlobalMedia +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Telegram/Telegram.list, 📟 Telegram +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/OpenAI/OpenAI.list, 🤖 AI +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Gemini/Gemini.list, 🤖 AI +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Copilot/Copilot.list, 🤖 AI +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Claude/Claude.list, 🤖 AI +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Crypto/Crypto.list, 🪙 Crypto +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Cryptocurrency/Cryptocurrency.list, 🪙 Crypto +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Game/Game.list, 🎮 Game +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Global/Global_All_No_Resolve.list, 🚀 Proxy +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/ChinaMax/ChinaMax_All_No_Resolve.list, 🇨🇳 China +RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Lan/Lan.list, 🎯 Direct + +GEOIP, CN, 🇨🇳 China +FINAL, 🐠 Final, dns-failed [URL Rewrite] -^https?://(www.)?(g|google).cn https://www.google.com 302 \ No newline at end of file +^https?:\/\/(www.)?g\.cn https://www.google.com 302 +^https?:\/\/(www.)?google\.cn https://www.google.com 302 \ No newline at end of file diff --git a/pkg/adapter/surge/surge.go b/pkg/adapter/surge/surge.go index 4c075be..5215389 100644 --- a/pkg/adapter/surge/surge.go +++ b/pkg/adapter/surge/surge.go @@ -4,14 +4,13 @@ import ( "bytes" "embed" "fmt" - "github.com/perfect-panel/server/pkg/tool" - "net/url" "strings" "text/template" "time" "github.com/perfect-panel/server/pkg/adapter/proxy" "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/tool" "github.com/perfect-panel/server/pkg/traffic" ) @@ -39,46 +38,26 @@ func NewSurge(adapter proxy.Adapter) *Surge { } } -func (m *Surge) Build(uuid, siteName string, user UserInfo) []byte { - var proxies, proxyGroup, rules string +func (m *Surge) Build(siteName string, user UserInfo) []byte { + var proxies, proxyGroup string var removed []string + var ps []string for _, p := range m.Adapter.Proxies { switch p.Protocol { case "shadowsocks": - proxies += buildShadowsocks(p, uuid) + proxies += buildShadowsocks(p, user.UUID) case "trojan": - proxies += buildTrojan(p, uuid) + proxies += buildTrojan(p, user.UUID) case "hysteria2": - proxies += buildHysteria2(p, uuid) + proxies += buildHysteria2(p, user.UUID) case "vmess": - proxies += buildVMess(p, uuid) + proxies += buildVMess(p, user.UUID) default: removed = append(removed, p.Name) } + ps = append(ps, p.Name) } - for _, group := range m.Adapter.Group { - if len(removed) > 0 { - group.Proxies = tool.RemoveStringElement(group.Proxies, removed...) - } - if group.Type == proxy.GroupTypeSelect { - proxyGroup += fmt.Sprintf("%s = select, %s", group.Name, strings.Join(group.Proxies, ", ")) + "\r\n" - } else if group.Type == proxy.GroupTypeURLTest { - proxyGroup += fmt.Sprintf("%s = url-test, %s, url=%s, interval=%d", group.Name, strings.Join(group.Proxies, ", "), group.URL, group.Interval) + "\r\n" - } else if group.Type == proxy.GroupTypeFallback { - proxyGroup += fmt.Sprintf("%s = fallback, %s, url=%s, interval=%d", group.Name, strings.Join(group.Proxies, ", "), group.URL, group.Interval) + "\r\n" - } else { - logger.Errorf("[BuildSurfboard] unknown group type: %s", group.Type) - } - } - for _, rule := range m.Adapter.Rules { - if rule == "" { - continue - } - rules += rule + "\r\n" - } - //final rule - rules += "\r\n" + "FINAL,手动选择,dns-failed" file, err := configFiles.ReadFile("default.tpl") if err != nil { @@ -99,23 +78,21 @@ func (m *Surge) Build(uuid, siteName string, user UserInfo) []byte { } else { expiredAt = user.ExpiredDate.Format("2006-01-02 15:04:05") } + + ps = tool.RemoveStringElement(ps, removed...) + proxyGroup = strings.Join(ps, ",") + // convert traffic upload := traffic.AutoConvert(user.Upload, false) download := traffic.AutoConvert(user.Download, false) total := traffic.AutoConvert(user.TotalTraffic, false) unusedTraffic := traffic.AutoConvert(user.TotalTraffic-user.Upload-user.Download, false) // query Host - urlParse, err := url.Parse(user.SubscribeURL) - if err != nil { - return nil - } if err := tpl.Execute(&buf, map[string]interface{}{ - "Proxies": proxies, - "ProxyGroup": proxyGroup, - "SubscribeURL": user.SubscribeURL, - "SubscribeInfo": fmt.Sprintf("title=%s订阅信息, content=上传流量:%s\\n下载流量:%s\\n剩余流量: %s\\n套餐流量:%s\\n到期时间:%s", siteName, upload, download, unusedTraffic, total, expiredAt), - "SubscribeDomain": urlParse.Host, - "Rules": rules, + "Proxies": proxies, + "ProxyGroup": proxyGroup, + "SubscribeURL": user.SubscribeURL, + "SubscribeInfo": fmt.Sprintf("title=%s订阅信息, content=上传流量:%s\\n下载流量:%s\\n剩余流量: %s\\n套餐流量:%s\\n到期时间:%s", siteName, upload, download, unusedTraffic, total, expiredAt), }); err != nil { logger.Errorf("build Surge config error: %v", err.Error()) return nil diff --git a/pkg/adapter/template/clash.tpl b/pkg/adapter/template/clash.tpl new file mode 100644 index 0000000..6d8dd36 --- /dev/null +++ b/pkg/adapter/template/clash.tpl @@ -0,0 +1,51 @@ +mode: rule +ipv6: true +allow-lan: true +bind-address: "*" +mixed-port: 7890 +log-level: error +unified-delay: true +tcp-concurrent: true +external-controller: 0.0.0.0:9090 + +tun: + enable: true + stack: system + auto-route: true + +dns: + enable: true + cache-algorithm: arc + listen: 0.0.0.0:1053 + ipv6: true + enhanced-mode: fake-ip + fake-ip-range: 198.18.0.1/16 + fake-ip-filter: + - "*.lan" + - "lens.l.google.com" + - "*.srv.nintendo.net" + - "*.stun.playstation.net" + - "xbox.*.*.microsoft.com" + - "*.xboxlive.com" + - "*.msftncsi.com" + - "*.msftconnecttest.com" + default-nameserver: + - 119.29.29.29 + - 223.5.5.5 + nameserver: + - system + - 119.29.29.29 + - 223.5.5.5 + fallback: + - 8.8.8.8 + - 1.1.1.1 + fallback-filter: + geoip: true + geoip-code: CN + +proxies: +{{.Proxies | toYaml | indent 2}} +proxy-groups: +{{.ProxyGroups | toYaml | indent 2}} +rules: +{{.Rules | toYaml | indent 2}} \ No newline at end of file diff --git a/pkg/adapter/uilts.go b/pkg/adapter/uilts.go index a9858ed..317da61 100644 --- a/pkg/adapter/uilts.go +++ b/pkg/adapter/uilts.go @@ -2,6 +2,8 @@ package adapter import ( "encoding/json" + "fmt" + "log" "strings" "github.com/perfect-panel/server/internal/model/server" @@ -11,14 +13,21 @@ import ( "github.com/perfect-panel/server/pkg/tool" ) +// addNode creates a new proxy node based on the provided server data and host/port. func addNode(data *server.Server, host string, port int) *proxy.Proxy { var option any + tags := strings.Split(data.Tags, ",") + if len(tags) > 0 { + tags = tool.RemoveDuplicateElements(tags...) + } + node := proxy.Proxy{ Name: data.Name, Server: host, Port: port, Country: data.Country, Protocol: data.Protocol, + Tags: tags, } switch data.Protocol { case "shadowsocks": @@ -75,85 +84,78 @@ func addNode(data *server.Server, host string, port int) *proxy.Proxy { node.Port = tuic.Port } option = tuic + case "anytls": + var anytls proxy.AnyTLS + if err := json.Unmarshal([]byte(data.Config), &anytls); err != nil { + logger.Errorw("解析AnyTLS配置失败", logger.Field("error", err.Error()), logger.Field("node", data.Name)) + return nil + } + if port == 0 { + node.Port = anytls.Port + } + option = anytls + logger.Infow("成功处理AnyTLS节点", logger.Field("node", data.Name), logger.Field("port", anytls.Port)) default: + fmt.Printf("[Error] 不支持的协议: %s", data.Protocol) return nil } node.Option = option return &node } -func addProxyToGroup(proxyName, groupName string, groups []proxy.Group) []proxy.Group { - for i, group := range groups { - if group.Name == groupName { - groups[i].Proxies = tool.RemoveDuplicateElements(append(group.Proxies, proxyName)...) - return groups - } - } - groups = append(groups, proxy.Group{ - Name: groupName, - Type: proxy.GroupTypeSelect, - Proxies: []string{proxyName}, - }) - return groups -} - -func adapterRules(groups []*server.RuleGroup) (proxyGroup []proxy.Group, rules []string) { +func adapterRules(groups []*server.RuleGroup) (proxyGroup []proxy.Group, rules []string, defaultGroup string) { for _, group := range groups { - proxyGroup = append(proxyGroup, proxy.Group{ - Name: group.Name, - Type: proxy.GroupTypeSelect, - Proxies: RemoveEmptyString(strings.Split(group.Tags, ",")), - }) + if group.Default { + log.Printf("[Debug] 规则组 %s 是默认组", group.Name) + defaultGroup = group.Name + } + switch group.Type { + case server.RuleGroupTypeReject: + proxyGroup = append(proxyGroup, proxy.Group{ + Name: group.Name, + Type: proxy.GroupTypeSelect, + Proxies: []string{"REJECT", "DIRECT", AutoSelect}, + Reject: true, + }) + case server.RuleGroupTypeDirect: + proxyGroup = append(proxyGroup, proxy.Group{ + Name: group.Name, + Type: proxy.GroupTypeSelect, + Proxies: []string{"DIRECT", AutoSelect}, + Direct: true, + }) + default: + proxyGroup = append(proxyGroup, proxy.Group{ + Name: group.Name, + Type: proxy.GroupTypeSelect, + Proxies: []string{}, + Tags: RemoveEmptyString(strings.Split(group.Tags, ",")), + Default: group.Default, + }) + } + rules = append(rules, strings.Split(group.Rules, "\n")...) } - return + log.Printf("[Dapter] 生成规则组: %d", len(proxyGroup)) + return proxyGroup, tool.RemoveDuplicateElements(rules...), defaultGroup } -func adapterTags(tags map[string][]*server.Server, group []proxy.Group) (proxyGroup []proxy.Group) { - for tag, servers := range tags { - proxies := adapterProxies(servers) - if len(proxies) != 0 { - for _, p := range proxies { - group = addProxyToGroup(p.Name, tag, group) - } - } - } - return group +// generateDefaultGroup generates a default proxy group with auto-selection and manual selection options. +func generateDefaultGroup() (proxyGroup []proxy.Group) { + proxyGroup = append(proxyGroup, proxy.Group{ + Name: AutoSelect, + Type: proxy.GroupTypeURLTest, + Proxies: make([]string, 0), + URL: "https://www.gstatic.com/generate_204", + Interval: 300, + }) + + return proxyGroup } -func generateProxyGroup(servers []proxy.Proxy) (proxyGroup []proxy.Group, region []string) { - // 设置手动选择分组 - proxyGroup = append(proxyGroup, []proxy.Group{ - { - Name: "智能线路", - Type: proxy.GroupTypeURLTest, - Proxies: make([]string, 0), - URL: "https://www.gstatic.com/generate_204", - Interval: 300, - }, - { - Name: "手动选择", - Type: proxy.GroupTypeSelect, - Proxies: []string{"智能线路"}, - }, - }...) - - for _, node := range servers { - if node.Country != "" { - proxyGroup = addProxyToGroup(node.Name, node.Country, proxyGroup) - region = append(region, node.Country) - - proxyGroup = addProxyToGroup(node.Country, "智能线路", proxyGroup) - } - - proxyGroup = addProxyToGroup(node.Name, "手动选择", proxyGroup) - } - proxyGroup = addProxyToGroup("DIRECT", "手动选择", proxyGroup) - return proxyGroup, tool.RemoveDuplicateElements(region...) -} - -func adapterProxies(servers []*server.Server) []proxy.Proxy { +func adapterProxies(servers []*server.Server) ([]proxy.Proxy, []string, map[string][]string) { var proxies []proxy.Proxy + var tags = make(map[string][]string) for _, node := range servers { switch node.RelayMode { case server.RelayModeAll: @@ -168,8 +170,20 @@ func adapterProxies(servers []*server.Server) []proxy.Proxy { continue } if relay.Prefix != "" { - n.Name = relay.Prefix + "-" + n.Name + n.Name = relay.Prefix + n.Name } + if node.Tags != "" { + t := tool.RemoveDuplicateElements(strings.Split(node.Tags, ",")...) + for _, tag := range t { + if tag != "" { + if _, ok := tags[tag]; !ok { + tags[tag] = []string{} + } + tags[tag] = append(tags[tag], n.Name) + } + } + } + proxies = append(proxies, *n) } case server.RelayModeRandom: @@ -185,18 +199,46 @@ func adapterProxies(servers []*server.Server) []proxy.Proxy { continue } if relay.Prefix != "" { - n.Name = relay.Prefix + " - " + node.Name + n.Name = relay.Prefix + node.Name + } + if node.Tags != "" { + t := tool.RemoveDuplicateElements(strings.Split(node.Tags, ",")...) + for _, tag := range t { + if tag != "" { + if _, ok := tags[tag]; !ok { + tags[tag] = []string{} + } + tags[tag] = append(tags[tag], n.Name) + } + } } proxies = append(proxies, *n) default: logger.Info("Not Relay Mode", logger.Field("node", node.Name), logger.Field("relayMode", node.RelayMode)) n := addNode(node, node.ServerAddr, 0) if n != nil { + if node.Tags != "" { + t := tool.RemoveDuplicateElements(strings.Split(node.Tags, ",")...) + for _, tag := range t { + if tag != "" { + if _, ok := tags[tag]; !ok { + tags[tag] = []string{} + } + tags[tag] = append(tags[tag], n.Name) + } + } + } proxies = append(proxies, *n) } } } - return proxies + + var nodes []string + for _, p := range proxies { + nodes = append(nodes, p.Name) + } + + return proxies, tool.RemoveDuplicateElements(nodes...), tags } // RemoveEmptyString 切片去除空值 @@ -210,18 +252,61 @@ func RemoveEmptyString(arr []string) []string { return result } -func RemoveEmptyGroup(arr []proxy.Group) []proxy.Group { - var result []proxy.Group - var removeNames []string - for _, group := range arr { - if group.Name == "手动选择" { - group.Proxies = tool.RemoveStringElement(group.Proxies, removeNames...) +// SortGroups sorts the provided slice of proxy groups by their names. +func SortGroups(groups []proxy.Group, nodes []string, tags map[string][]string, defaultName string) []proxy.Group { + var sortedGroups []proxy.Group + var defaultGroup, autoSelectGroup proxy.Group + // 在所有分组找到默认分组并将他放到第一个 + for _, group := range groups { + if group.Name == "" || group.Name == "DIRECT" || group.Name == "REJECT" { + continue } - if len(group.Proxies) > 0 { - result = append(result, group) - } else { - removeNames = append(removeNames, group.Name) + // 如果是默认分组 + if group.Default { + group.Proxies = append([]string{AutoSelect}, nodes...) + group.Proxies = append(group.Proxies, "DIRECT") + defaultGroup = group + continue } + if group.Reject || group.Direct { + if defaultName != AutoSelect { + group.Proxies = append(group.Proxies, defaultName) + } + sortedGroups = append(sortedGroups, group) + continue + } + + if group.Name == AutoSelect { + group.Proxies = nodes + autoSelectGroup = group + continue + } + // Tags 分组 + if len(group.Tags) > 0 { + var proxies []string + for _, tag := range group.Tags { + if node, ok := tags[tag]; ok { + proxies = append(proxies, node...) + } + } + group.Proxies = append(tool.RemoveDuplicateElements(proxies...), AutoSelect, "DIRECT") + sortedGroups = append(sortedGroups, group) + continue + } + + group.Proxies = append([]string{AutoSelect}, nodes...) + group.Proxies = append(group.Proxies, "DIRECT") + group.Proxies = tool.RemoveElementBySlice(group.Proxies, group.Name) + sortedGroups = append(sortedGroups, group) } - return result + + if defaultGroup.Name != "" { + sortedGroups = append([]proxy.Group{defaultGroup}, sortedGroups...) + } + if autoSelectGroup.Name != "" && autoSelectGroup.Name != defaultGroup.Name { + sortedGroups = append(sortedGroups, autoSelectGroup) + } + + return sortedGroups + } diff --git a/pkg/device/device.go b/pkg/device/device.go index 9aa2239..96a1fb4 100644 --- a/pkg/device/device.go +++ b/pkg/device/device.go @@ -340,6 +340,11 @@ func (dm *DeviceManager) Broadcast(message string) { } +// GetOnlineDeviceCount returns the total number of online devices +func (dm *DeviceManager) GetOnlineDeviceCount() int64 { + return int64(atomic.LoadInt32(&dm.totalOnline)) +} + // Gracefully shut down all WebSocket connections func (dm *DeviceManager) Shutdown(ctx context.Context) { <-ctx.Done() diff --git a/pkg/email/template.go b/pkg/email/template.go index f31b9c7..5d2bd5b 100644 --- a/pkg/email/template.go +++ b/pkg/email/template.go @@ -282,7 +282,6 @@ const ( ` - DefaultTrafficExceedEmailTemplate = ` diff --git a/queue/handler/routes.go b/queue/handler/routes.go index ae98017..20089ed 100644 --- a/queue/handler/routes.go +++ b/queue/handler/routes.go @@ -33,4 +33,7 @@ func RegisterHandlers(mux *asynq.ServeMux, serverCtx *svc.ServiceContext) { // Schedule total server data mux.Handle(types.SchedulerTotalServerData, traffic.NewServerDataLogic(serverCtx)) + + // Schedule reset traffic + mux.Handle(types.SchedulerResetTraffic, traffic.NewResetTrafficLogic(serverCtx)) } diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index f9e78fa..b4e0225 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -1,3 +1,5 @@ +// Package orderLogic provides order processing logic for handling various types of orders +// including subscription purchases, renewals, traffic resets, and balance recharges. package orderLogic import ( @@ -8,7 +10,6 @@ import ( "time" "github.com/perfect-panel/server/pkg/constant" - "github.com/perfect-panel/server/pkg/logger" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" @@ -17,6 +18,7 @@ import ( "github.com/perfect-panel/server/internal/config" "github.com/perfect-panel/server/internal/logic/telegram" "github.com/perfect-panel/server/internal/model/order" + "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/pkg/tool" @@ -25,191 +27,308 @@ import ( "gorm.io/gorm" ) +// Order type constants define the different types of orders that can be processed const ( - Subscribe = 1 - Renewal = 2 - ResetTraffic = 3 - Recharge = 4 + OrderTypeSubscribe = 1 // New subscription purchase + OrderTypeRenewal = 2 // Subscription renewal + OrderTypeResetTraffic = 3 // Traffic quota reset + OrderTypeRecharge = 4 // Balance recharge ) +// Order status constants define the lifecycle states of an order +const ( + OrderStatusPending = 1 // Order created but not paid + OrderStatusPaid = 2 // Order paid and ready for processing + OrderStatusClose = 3 // Order closed/cancelled + OrderStatusFailed = 4 // Order processing failed + OrderStatusFinished = 5 // Order successfully completed +) + +// Commission type constants define the types of commission transactions +const ( + CommissionTypeRecharge = 1 // Commission from balance recharge +) + +// Predefined error variables for common error conditions +var ( + ErrInvalidOrderStatus = fmt.Errorf("invalid order status") + ErrInvalidOrderType = fmt.Errorf("invalid order type") +) + +// ActivateOrderLogic handles the activation and processing of paid orders type ActivateOrderLogic struct { - svc *svc.ServiceContext + svc *svc.ServiceContext // Service context containing dependencies } +// NewActivateOrderLogic creates a new instance of ActivateOrderLogic func NewActivateOrderLogic(svc *svc.ServiceContext) *ActivateOrderLogic { return &ActivateOrderLogic{ svc: svc, } } +// ProcessTask is the main entry point for processing order activation tasks. +// It handles the complete workflow of activating a paid order including validation, +// processing based on order type, and finalization. func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task) error { - payload := types.ForthwithActivateOrderPayload{} - if err := json.Unmarshal(task.Payload(), &payload); err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Unmarshal payload failed", - logger.Field("error", err.Error()), - logger.Field("payload", string(task.Payload())), - ) - return nil - } - // Find order by order no - orderInfo, err := l.svc.OrderModel.FindOneByOrderNo(ctx, payload.OrderNo) + payload, err := l.parsePayload(ctx, task.Payload()) if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find order failed", - logger.Field("error", err.Error()), - logger.Field("order_no", payload.OrderNo), - ) - return nil + return nil // Log and continue } - if orderInfo.Status != 2 { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Order status error", - logger.Field("order_no", orderInfo.OrderNo), - logger.Field("status", orderInfo.Status), - ) - return nil - } - switch orderInfo.Type { - case Subscribe: - err = l.NewPurchase(ctx, orderInfo) - case Renewal: - err = l.Renewal(ctx, orderInfo) - case ResetTraffic: - err = l.ResetTraffic(ctx, orderInfo) - case Recharge: - err = l.Recharge(ctx, orderInfo) - default: - logger.WithContext(ctx).Error("[ActivateOrderLogic] Order type is invalid", logger.Field("type", orderInfo.Type)) - } + orderInfo, err := l.validateAndGetOrder(ctx, payload.OrderNo) if err != nil { + return nil // Log and continue + } + + if err := l.processOrderByType(ctx, orderInfo); err != nil { logger.WithContext(ctx).Error("[ActivateOrderLogic] Process task failed", logger.Field("error", err.Error())) return nil } - // if coupon is not empty + + l.finalizeCouponAndOrder(ctx, orderInfo) + return nil +} + +// parsePayload unmarshals the task payload into a structured format +func (l *ActivateOrderLogic) parsePayload(ctx context.Context, payload []byte) (*types.ForthwithActivateOrderPayload, error) { + var p types.ForthwithActivateOrderPayload + if err := json.Unmarshal(payload, &p); err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Unmarshal payload failed", + logger.Field("error", err.Error()), + logger.Field("payload", string(payload)), + ) + return nil, err + } + return &p, nil +} + +// validateAndGetOrder retrieves an order by order number and validates its status +// Returns error if order is not found or not in paid status +func (l *ActivateOrderLogic) validateAndGetOrder(ctx context.Context, orderNo string) (*order.Order, error) { + orderInfo, err := l.svc.OrderModel.FindOneByOrderNo(ctx, orderNo) + if err != nil { + logger.WithContext(ctx).Error("Find order failed", + logger.Field("error", err.Error()), + logger.Field("order_no", orderNo), + ) + return nil, err + } + + if orderInfo.Status != OrderStatusPaid { + logger.WithContext(ctx).Error("Order status error", + logger.Field("order_no", orderInfo.OrderNo), + logger.Field("status", orderInfo.Status), + ) + return nil, ErrInvalidOrderStatus + } + + return orderInfo, nil +} + +// processOrderByType routes order processing based on the order type +func (l *ActivateOrderLogic) processOrderByType(ctx context.Context, orderInfo *order.Order) error { + switch orderInfo.Type { + case OrderTypeSubscribe: + return l.NewPurchase(ctx, orderInfo) + case OrderTypeRenewal: + return l.Renewal(ctx, orderInfo) + case OrderTypeResetTraffic: + return l.ResetTraffic(ctx, orderInfo) + case OrderTypeRecharge: + return l.Recharge(ctx, orderInfo) + default: + logger.WithContext(ctx).Error("Order type is invalid", logger.Field("type", orderInfo.Type)) + return ErrInvalidOrderType + } +} + +// finalizeCouponAndOrder handles post-processing tasks including coupon updates +// and order status finalization +func (l *ActivateOrderLogic) finalizeCouponAndOrder(ctx context.Context, orderInfo *order.Order) { + // Update coupon if exists if orderInfo.Coupon != "" { - // update coupon status - err = l.svc.CouponModel.UpdateCount(ctx, orderInfo.Coupon) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update coupon status failed", + if err := l.svc.CouponModel.UpdateCount(ctx, orderInfo.Coupon); err != nil { + logger.WithContext(ctx).Error("Update coupon status failed", logger.Field("error", err.Error()), logger.Field("coupon", orderInfo.Coupon), ) } } - // update order status - orderInfo.Status = 5 - err = l.svc.OrderModel.Update(ctx, orderInfo) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update order status failed", + + // Update order status + orderInfo.Status = OrderStatusFinished + if err := l.svc.OrderModel.Update(ctx, orderInfo); err != nil { + logger.WithContext(ctx).Error("Update order status failed", logger.Field("error", err.Error()), logger.Field("order_no", orderInfo.OrderNo), ) } +} +// NewPurchase handles new subscription purchase including user creation, +// subscription setup, commission processing, cache updates, and notifications +func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.Order) error { + userInfo, err := l.getUserOrCreate(ctx, orderInfo) + if err != nil { + return err + } + + sub, err := l.getSubscribeInfo(ctx, orderInfo.SubscribeId) + if err != nil { + return err + } + + userSub, err := l.createUserSubscription(ctx, orderInfo, sub) + if err != nil { + return err + } + + // Handle commission in separate goroutine to avoid blocking + go l.handleCommission(context.Background(), userInfo, orderInfo, true) + + // Clear cache + l.clearServerCache(ctx, sub) + + // Send notifications + l.sendNotifications(ctx, orderInfo, userInfo, sub, userSub, telegram.PurchaseNotify) + + logger.WithContext(ctx).Info("Insert user subscribe success") return nil } -// NewPurchase New purchase -func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.Order) error { - var userInfo *user.User - var err error +// getUserOrCreate retrieves an existing user or creates a new guest user based on order details +func (l *ActivateOrderLogic) getUserOrCreate(ctx context.Context, orderInfo *order.Order) (*user.User, error) { if orderInfo.UserId != 0 { - // find user by user id - userInfo, err = l.svc.UserModel.FindOne(ctx, orderInfo.UserId) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user failed", - logger.Field("error", err.Error()), - logger.Field("user_id", orderInfo.UserId), - logger.Field("user_id", orderInfo.UserId), - ) - return err - } - } else { - // If User ID is 0, it means that the order is a guest order, need to create a new user - // query info with redis - cacheKey := fmt.Sprintf(constant.TempOrderCacheKey, orderInfo.OrderNo) - data, err := l.svc.Redis.Get(ctx, cacheKey).Result() - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Get temp order cache failed", - logger.Field("error", err.Error()), - logger.Field("cache_key", cacheKey), - ) - return err - } - var tempOrder constant.TemporaryOrderInfo - if err = json.Unmarshal([]byte(data), &tempOrder); err != nil { - logger.WithContext(ctx).Errorw("[ActivateOrderLogic] Unmarshal temp order failed", - logger.Field("error", err.Error()), - ) - return err - } - // create user - - userInfo = &user.User{ - Password: tool.EncodePassWord(tempOrder.Password), - AuthMethods: []user.AuthMethods{ - { - AuthType: tempOrder.AuthType, - AuthIdentifier: tempOrder.Identifier, - }, - }, - } - err = l.svc.UserModel.Transaction(ctx, func(tx *gorm.DB) error { - // Save user information - if err := tx.Save(userInfo).Error; err != nil { - return err - } - // Generate ReferCode - userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id) - // Update ReferCode - if err := tx.Model(&user.User{}).Where("id = ?", userInfo.Id).Update("refer_code", userInfo.ReferCode).Error; err != nil { - return err - } - orderInfo.UserId = userInfo.Id - return tx.Model(&order.Order{}).Where("order_no = ?", orderInfo.OrderNo).Update("user_id", userInfo.Id).Error - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Create user failed", - logger.Field("error", err.Error()), - ) - return err - } - - if tempOrder.InviteCode != "" { - // find referer by refer code - referer, err := l.svc.UserModel.FindOneByReferCode(ctx, tempOrder.InviteCode) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find referer failed", - logger.Field("error", err.Error()), - logger.Field("refer_code", tempOrder.InviteCode), - ) - } else { - userInfo.RefererId = referer.Id - err = l.svc.UserModel.Update(ctx, userInfo) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update user referer failed", - logger.Field("error", err.Error()), - logger.Field("user_id", userInfo.Id), - ) - } - } - } - - logger.WithContext(ctx).Info("[ActivateOrderLogic] Create guest user success", logger.Field("user_id", userInfo.Id), logger.Field("Identifier", tempOrder.Identifier), logger.Field("AuthType", tempOrder.AuthType)) + return l.getExistingUser(ctx, orderInfo.UserId) } - // find subscribe by id - sub, err := l.svc.SubscribeModel.FindOne(ctx, orderInfo.SubscribeId) + return l.createGuestUser(ctx, orderInfo) +} + +// getExistingUser retrieves user information by user ID +func (l *ActivateOrderLogic) getExistingUser(ctx context.Context, userId int64) (*user.User, error) { + userInfo, err := l.svc.UserModel.FindOne(ctx, userId) if err != nil { - logger.WithContext(ctx).Errorw("[ActivateOrderLogic] Find subscribe failed", + logger.WithContext(ctx).Error("Find user failed", logger.Field("error", err.Error()), - logger.Field("subscribe_id", orderInfo.SubscribeId), + logger.Field("user_id", userId), ) - return err + return nil, err } - // create user subscribe - now := time.Now() + return userInfo, nil +} - userSub := user.Subscribe{ - Id: 0, +// createGuestUser creates a new user account for guest orders using temporary order information +// stored in Redis cache +func (l *ActivateOrderLogic) createGuestUser(ctx context.Context, orderInfo *order.Order) (*user.User, error) { + tempOrder, err := l.getTempOrderInfo(ctx, orderInfo.OrderNo) + if err != nil { + return nil, err + } + + userInfo := &user.User{ + Password: tool.EncodePassWord(tempOrder.Password), + AuthMethods: []user.AuthMethods{ + { + AuthType: tempOrder.AuthType, + AuthIdentifier: tempOrder.Identifier, + }, + }, + } + + err = l.svc.UserModel.Transaction(ctx, func(tx *gorm.DB) error { + if err := tx.Save(userInfo).Error; err != nil { + return err + } + + userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id) + if err := tx.Model(&user.User{}).Where("id = ?", userInfo.Id).Update("refer_code", userInfo.ReferCode).Error; err != nil { + return err + } + + orderInfo.UserId = userInfo.Id + return tx.Model(&order.Order{}).Where("order_no = ?", orderInfo.OrderNo).Update("user_id", userInfo.Id).Error + }) + + if err != nil { + logger.WithContext(ctx).Error("Create user failed", logger.Field("error", err.Error())) + return nil, err + } + + // Handle referrer relationship + l.handleReferrer(ctx, userInfo, tempOrder.InviteCode) + + logger.WithContext(ctx).Info("Create guest user success", + logger.Field("user_id", userInfo.Id), + logger.Field("identifier", tempOrder.Identifier), + logger.Field("auth_type", tempOrder.AuthType), + ) + + return userInfo, nil +} + +// getTempOrderInfo retrieves temporary order information from Redis cache +func (l *ActivateOrderLogic) getTempOrderInfo(ctx context.Context, orderNo string) (*constant.TemporaryOrderInfo, error) { + cacheKey := fmt.Sprintf(constant.TempOrderCacheKey, orderNo) + data, err := l.svc.Redis.Get(ctx, cacheKey).Result() + if err != nil { + logger.WithContext(ctx).Error("Get temp order cache failed", + logger.Field("error", err.Error()), + logger.Field("cache_key", cacheKey), + ) + return nil, err + } + + var tempOrder constant.TemporaryOrderInfo + if err = json.Unmarshal([]byte(data), &tempOrder); err != nil { + logger.WithContext(ctx).Error("Unmarshal temp order failed", logger.Field("error", err.Error())) + return nil, err + } + + return &tempOrder, nil +} + +// handleReferrer establishes referrer relationship if an invite code is provided +func (l *ActivateOrderLogic) handleReferrer(ctx context.Context, userInfo *user.User, inviteCode string) { + if inviteCode == "" { + return + } + + referer, err := l.svc.UserModel.FindOneByReferCode(ctx, inviteCode) + if err != nil { + logger.WithContext(ctx).Error("Find referer failed", + logger.Field("error", err.Error()), + logger.Field("refer_code", inviteCode), + ) + return + } + + userInfo.RefererId = referer.Id + if err = l.svc.UserModel.Update(ctx, userInfo); err != nil { + logger.WithContext(ctx).Error("Update user referer failed", + logger.Field("error", err.Error()), + logger.Field("user_id", userInfo.Id), + ) + } +} + +// getSubscribeInfo retrieves subscription plan details by subscription ID +func (l *ActivateOrderLogic) getSubscribeInfo(ctx context.Context, subscribeId int64) (*subscribe.Subscribe, error) { + sub, err := l.svc.SubscribeModel.FindOne(ctx, subscribeId) + if err != nil { + logger.WithContext(ctx).Error("Find subscribe failed", + logger.Field("error", err.Error()), + logger.Field("subscribe_id", subscribeId), + ) + return nil, err + } + return sub, nil +} + +// createUserSubscription creates a new user subscription record based on order and subscription plan details +func (l *ActivateOrderLogic) createUserSubscription(ctx context.Context, orderInfo *order.Order, sub *subscribe.Subscribe) (*user.Subscribe, error) { + now := time.Now() + userSub := &user.Subscribe{ UserId: orderInfo.UserId, OrderId: orderInfo.Id, SubscribeId: orderInfo.SubscribeId, @@ -222,388 +341,292 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O UUID: uuid.New().String(), Status: 1, } - err = l.svc.UserModel.InsertSubscribe(ctx, &userSub) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Insert user subscribe failed", - logger.Field("error", err.Error()), - ) - return err + if err := l.svc.UserModel.InsertSubscribe(ctx, userSub); err != nil { + logger.WithContext(ctx).Error("Insert user subscribe failed", logger.Field("error", err.Error())) + return nil, err } - // handler commission - if userInfo.RefererId != 0 && - l.svc.Config.Invite.ReferralPercentage != 0 && - (!l.svc.Config.Invite.OnlyFirstPurchase || orderInfo.IsNew) { - referer, err := l.svc.UserModel.FindOne(ctx, userInfo.RefererId) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find referer failed", - logger.Field("error", err.Error()), - logger.Field("referer_id", userInfo.RefererId), - ) - goto updateCache + + return userSub, nil +} + +// handleCommission processes referral commission for the referrer if applicable. +// This runs asynchronously to avoid blocking the main order processing flow. +func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *user.User, orderInfo *order.Order, isNewPurchase bool) { + if !l.shouldProcessCommission(userInfo, orderInfo, isNewPurchase) { + return + } + + referer, err := l.svc.UserModel.FindOne(ctx, userInfo.RefererId) + if err != nil { + logger.WithContext(ctx).Error("Find referer failed", + logger.Field("error", err.Error()), + logger.Field("referer_id", userInfo.RefererId), + ) + return + } + + amount := l.calculateCommission(orderInfo.Amount, isNewPurchase && orderInfo.IsNew, orderInfo.Quantity) + + // Use transaction for commission updates + err = l.svc.DB.Transaction(func(tx *gorm.DB) error { + referer.Commission += amount + if err := l.svc.UserModel.Update(ctx, referer, tx); err != nil { + return err } - // calculate commission - amount := float64(orderInfo.Price) * (float64(l.svc.Config.Invite.ReferralPercentage) / 100) - referer.Commission += int64(amount) - err = l.svc.UserModel.Update(ctx, referer) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update referer commission failed", - logger.Field("error", err.Error()), - ) - goto updateCache - } - // create commission log - commissionLog := user.CommissionLog{ + + commissionLog := &user.CommissionLog{ UserId: referer.Id, OrderNo: orderInfo.OrderNo, - Amount: int64(amount), + Amount: amount, } - err = l.svc.UserModel.InsertCommissionLog(ctx, &commissionLog) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Insert commission log failed", - logger.Field("error", err.Error()), - ) - } - err = l.svc.UserModel.UpdateUserCache(ctx, referer) - if err != nil { - logger.WithContext(ctx).Errorw("[ActivateOrderLogic] Update referer cache", logger.Field("error", err.Error()), logger.Field("user_id", referer.Id)) - } - } -updateCache: - for _, id := range tool.StringToInt64Slice(sub.Server) { - cacheKey := fmt.Sprintf("%s%d", config.ServerUserListCacheKey, id) - err = l.svc.Redis.Del(ctx, cacheKey).Err() - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Del server user list cache failed", - logger.Field("error", err.Error()), - logger.Field("cache_key", cacheKey), - ) - } - } - data, err := l.svc.ServerModel.FindServerListByGroupIds(ctx, tool.StringToInt64Slice(sub.ServerGroup)) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find server list failed", logger.Field("error", err.Error())) - return nil - } - for _, item := range data { - cacheKey := fmt.Sprintf("%s%d", config.ServerUserListCacheKey, item.Id) - err = l.svc.Redis.Del(ctx, cacheKey).Err() - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Del server user list cache failed", - logger.Field("error", err.Error()), - logger.Field("cache_key", cacheKey), - ) - } - } - userTelegramChatId, ok := findTelegram(userInfo) - - // sendMessage To Telegram - if ok { - text, err := tool.RenderTemplateToString(telegram.PurchaseNotify, map[string]string{ - "OrderNo": orderInfo.OrderNo, - "SubscribeName": sub.Name, - "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), - "ExpireTime": userSub.ExpireTime.Format("2006-01-02 15:04:05"), - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render template failed", - logger.Field("error", err.Error()), - ) - } - l.sendUserNotifyWithTelegram(userTelegramChatId, text) - } - // send message to admin - text, err := tool.RenderTemplateToString(telegram.AdminOrderNotify, map[string]string{ - "OrderNo": orderInfo.OrderNo, - "TradeNo": orderInfo.TradeNo, - "SubscribeName": sub.Name, - //"UserEmail": userInfo.Email, - "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), - "OrderStatus": "已支付", - "OrderTime": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), - "PaymentMethod": orderInfo.Method, + return l.svc.UserModel.InsertCommissionLog(ctx, commissionLog, tx) }) + if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render AdminOrderNotify template failed", + logger.WithContext(ctx).Error("Update referer commission failed", logger.Field("error", err.Error())) + return + } + + // Update cache + if err := l.svc.UserModel.UpdateUserCache(ctx, referer); err != nil { + logger.WithContext(ctx).Error("Update referer cache failed", logger.Field("error", err.Error()), + logger.Field("user_id", referer.Id), ) } - l.sendAdminNotifyWithTelegram(ctx, text) - logger.WithContext(ctx).Info("[ActivateOrderLogic] Insert user subscribe success") +} + +// shouldProcessCommission determines if commission should be processed based on +// referrer existence, commission settings, and order type +func (l *ActivateOrderLogic) shouldProcessCommission(userInfo *user.User, orderInfo *order.Order, isNewPurchase bool) bool { + return userInfo.RefererId != 0 && + (l.svc.Config.Invite.FirstPurchasePercentage != 0 || + l.svc.Config.Invite.FirstYearlyPurchasePercentage != 0 || + l.svc.Config.Invite.NonFirstPurchasePercentage != 0) +} + +// calculateCommission computes the commission amount based on order price and purchase type +func (l *ActivateOrderLogic) calculateCommission(price int64, isFirstPurchase bool, quantity int64) int64 { + var percentage int64 + if isFirstPurchase { + // 判断是否为年付(12个月) + if quantity == 12 { + percentage = l.svc.Config.Invite.FirstYearlyPurchasePercentage + } else { + percentage = l.svc.Config.Invite.FirstPurchasePercentage + } + } else { + percentage = l.svc.Config.Invite.NonFirstPurchasePercentage + } + return int64(float64(price) * (float64(percentage) / 100)) +} + +// clearServerCache clears user list cache for all servers associated with the subscription +func (l *ActivateOrderLogic) clearServerCache(ctx context.Context, sub *subscribe.Subscribe) { + serverIds := tool.StringToInt64Slice(sub.Server) + groupServerIds := l.getServerIdsByGroups(ctx, sub.ServerGroup) + allServerIds := append(serverIds, groupServerIds...) + + for _, id := range allServerIds { + cacheKey := fmt.Sprintf("%s%d", config.ServerUserListCacheKey, id) + if err := l.svc.Redis.Del(ctx, cacheKey).Err(); err != nil { + logger.WithContext(ctx).Error("Del server user list cache failed", + logger.Field("error", err.Error()), + logger.Field("cache_key", cacheKey), + ) + } + } +} + +// getServerIdsByGroups retrieves server IDs from server groups +func (l *ActivateOrderLogic) getServerIdsByGroups(ctx context.Context, serverGroup string) []int64 { + data, err := l.svc.ServerModel.FindServerListByGroupIds(ctx, tool.StringToInt64Slice(serverGroup)) + if err != nil { + logger.WithContext(ctx).Error("Find server list failed", logger.Field("error", err.Error())) + return nil + } + + serverIds := make([]int64, len(data)) + for i, item := range data { + serverIds[i] = item.Id + } + return serverIds +} + +// Renewal handles subscription renewal including subscription extension, +// traffic reset (if configured), commission processing, and notifications +func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order) error { + userInfo, err := l.getExistingUser(ctx, orderInfo.UserId) + if err != nil { + return err + } + + userSub, err := l.getUserSubscription(ctx, orderInfo.SubscribeToken) + if err != nil { + return err + } + + sub, err := l.getSubscribeInfo(ctx, orderInfo.SubscribeId) + if err != nil { + return err + } + + if err := l.updateSubscriptionForRenewal(ctx, userSub, sub, orderInfo); err != nil { + return err + } + + // Handle commission + go l.handleCommission(context.Background(), userInfo, orderInfo, false) + + // Send notifications + l.sendNotifications(ctx, orderInfo, userInfo, sub, userSub, telegram.RenewalNotify) + return nil } -// Renewal Renewal -func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order) error { - // find user by user id - userInfo, err := l.svc.UserModel.FindOne(ctx, orderInfo.UserId) +// getUserSubscription retrieves user subscription by token +func (l *ActivateOrderLogic) getUserSubscription(ctx context.Context, token string) (*user.Subscribe, error) { + userSub, err := l.svc.UserModel.FindOneSubscribeByToken(ctx, token) if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user failed", - logger.Field("error", err.Error()), - logger.Field("user_id", orderInfo.UserId), - ) - return err - } - // find user subscribe by subscribe token - userSub, err := l.svc.UserModel.FindOneSubscribeByOrderId(ctx, orderInfo.ParentId) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user subscribe failed", - logger.Field("error", err.Error()), - logger.Field("order_id", orderInfo.Id), - ) - return err - } - // find subscribe by id - sub, err := l.svc.SubscribeModel.FindOne(ctx, orderInfo.SubscribeId) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find subscribe failed", - logger.Field("error", err.Error()), - logger.Field("subscribe_id", orderInfo.SubscribeId), - logger.Field("order_id", orderInfo.Id), - ) - return err + logger.WithContext(ctx).Error("Find user subscribe failed", logger.Field("error", err.Error())) + return nil, err } + return userSub, nil +} + +// updateSubscriptionForRenewal updates subscription details for renewal including +// expiration time extension and traffic reset if configured +func (l *ActivateOrderLogic) updateSubscriptionForRenewal(ctx context.Context, userSub *user.Subscribe, sub *subscribe.Subscribe, orderInfo *order.Order) error { now := time.Now() if userSub.ExpireTime.Before(now) { userSub.ExpireTime = now } - // Check whether traffic reset on renewal is enabled - if *sub.RenewalReset { + // Reset traffic if enabled + if sub.RenewalReset != nil && *sub.RenewalReset { userSub.Download = 0 userSub.Upload = 0 } + if userSub.FinishedAt != nil { userSub.FinishedAt = nil } userSub.ExpireTime = tool.AddTime(sub.UnitTime, orderInfo.Quantity, userSub.ExpireTime) userSub.Status = 1 - // update user subscribe - err = l.svc.UserModel.UpdateSubscribe(ctx, userSub) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update user subscribe failed", - logger.Field("error", err.Error()), - ) + + if err := l.svc.UserModel.UpdateSubscribe(ctx, userSub); err != nil { + logger.WithContext(ctx).Error("Update user subscribe failed", logger.Field("error", err.Error())) return err } - // handler commission - if userInfo.RefererId != 0 && - l.svc.Config.Invite.ReferralPercentage != 0 && - !l.svc.Config.Invite.OnlyFirstPurchase { - referer, err := l.svc.UserModel.FindOne(ctx, userInfo.RefererId) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find referer failed", - logger.Field("error", err.Error()), - logger.Field("referer_id", userInfo.RefererId), - ) - goto sendMessage - } - // calculate commission - amount := float64(orderInfo.Price) * (float64(l.svc.Config.Invite.ReferralPercentage) / 100) - referer.Commission += int64(amount) - err = l.svc.UserModel.Update(ctx, referer) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update referer commission failed", - logger.Field("error", err.Error()), - ) - goto sendMessage - } - // create commission log - commissionLog := user.CommissionLog{ - UserId: referer.Id, - OrderNo: orderInfo.OrderNo, - Amount: int64(amount), - } - err = l.svc.UserModel.InsertCommissionLog(ctx, &commissionLog) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Insert commission log failed", - logger.Field("error", err.Error()), - ) - } - err = l.svc.UserModel.UpdateUserCache(ctx, referer) - if err != nil { - logger.WithContext(ctx).Errorw("[ActivateOrderLogic] Update referer cache", logger.Field("error", err.Error()), logger.Field("user_id", referer.Id)) - } - } -sendMessage: - userTelegramChatId, ok := findTelegram(userInfo) - // SendMessage To Telegram - if ok { - text, err := tool.RenderTemplateToString(telegram.RenewalNotify, map[string]string{ - "OrderNo": orderInfo.OrderNo, - "SubscribeName": sub.Name, - "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), - "ExpireTime": userSub.ExpireTime.Format("2006-01-02 15:04:05"), - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render template failed", - logger.Field("error", err.Error()), - ) - } - l.sendUserNotifyWithTelegram(userTelegramChatId, text) - } - - // send message to admin - text, err := tool.RenderTemplateToString(telegram.AdminOrderNotify, map[string]string{ - "OrderNo": orderInfo.OrderNo, - "TradeNo": orderInfo.TradeNo, - "SubscribeName": sub.Name, - //"UserEmail": userInfo.Email, - "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), - "OrderStatus": "已支付", - "OrderTime": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), - "PaymentMethod": orderInfo.Method, - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render AdminOrderNotify template failed", - logger.Field("error", err.Error()), - ) - } - l.sendAdminNotifyWithTelegram(ctx, text) return nil } -// ResetTraffic Reset traffic +// ResetTraffic handles traffic quota reset for existing subscriptions func (l *ActivateOrderLogic) ResetTraffic(ctx context.Context, orderInfo *order.Order) error { - // find user by user id - userInfo, err := l.svc.UserModel.FindOne(ctx, orderInfo.UserId) + userInfo, err := l.getExistingUser(ctx, orderInfo.UserId) if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user failed", - logger.Field("error", err.Error()), - logger.Field("user_id", orderInfo.UserId), - ) return err } - // Generate a Subscribe Token through orderNo - // find user subscribe by subscribe token - userSub, err := l.svc.UserModel.FindOneSubscribeByToken(ctx, orderInfo.SubscribeToken) + + userSub, err := l.getUserSubscription(ctx, orderInfo.SubscribeToken) if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user subscribe failed", - logger.Field("error", err.Error()), - logger.Field("order_id", orderInfo.Id), - ) return err } + + // Reset traffic userSub.Download = 0 userSub.Upload = 0 userSub.Status = 1 - // update user subscribe - err = l.svc.UserModel.UpdateSubscribe(ctx, userSub) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update user subscribe failed", - logger.Field("error", err.Error()), - ) + + if err := l.svc.UserModel.UpdateSubscribe(ctx, userSub); err != nil { + logger.WithContext(ctx).Error("Update user subscribe failed", logger.Field("error", err.Error())) return err } - sub, err := l.svc.SubscribeModel.FindOne(ctx, userSub.SubscribeId) + + sub, err := l.getSubscribeInfo(ctx, userSub.SubscribeId) if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find subscribe failed", - logger.Field("error", err.Error()), - logger.Field("subscribe_id", userSub.SubscribeId), - ) - return nil - } - userTelegramChatId, ok := findTelegram(userInfo) - // SendMessage To Telegram - if ok { - text, err := tool.RenderTemplateToString(telegram.ResetTrafficNotify, map[string]string{ - "OrderNo": orderInfo.OrderNo, - "SubscribeName": sub.Name, - "ResetTime": time.Now().Format("2006-01-02 15:04:05"), - "ExpireTime": userSub.ExpireTime.Format("2006-01-02 15:04:05"), - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render template failed", - logger.Field("error", err.Error()), - ) - } - l.sendUserNotifyWithTelegram(userTelegramChatId, text) + return err } - // send message to admin - text, err := tool.RenderTemplateToString(telegram.AdminOrderNotify, map[string]string{ - "OrderNo": orderInfo.OrderNo, - "TradeNo": orderInfo.TradeNo, - "SubscribeName": "流量重置", - "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), - "OrderStatus": "已支付", - "OrderTime": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), - "PaymentMethod": orderInfo.Method, - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render AdminOrderNotify template failed", - logger.Field("error", err.Error()), - ) - } - l.sendAdminNotifyWithTelegram(ctx, text) + // Send notifications + l.sendNotifications(ctx, orderInfo, userInfo, sub, userSub, telegram.ResetTrafficNotify) + return nil } -// Recharge Recharge to user +// Recharge handles balance recharge orders including balance updates, +// transaction logging, and notifications func (l *ActivateOrderLogic) Recharge(ctx context.Context, orderInfo *order.Order) error { - // find user by user id - userInfo, err := l.svc.UserModel.FindOne(ctx, orderInfo.UserId) + userInfo, err := l.getExistingUser(ctx, orderInfo.UserId) if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user failed", - logger.Field("error", err.Error()), - logger.Field("user_id", orderInfo.UserId), - ) return err } - userInfo.Balance += orderInfo.Price - // update user + + // Update balance in transaction err = l.svc.DB.Transaction(func(tx *gorm.DB) error { - err = l.svc.UserModel.Update(ctx, userInfo, tx) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update user failed", - logger.Field("error", err.Error()), - ) - return err - } - // Create Balance Log - balanceLog := user.BalanceLog{ - UserId: orderInfo.UserId, - Amount: orderInfo.Price, - Type: 1, - OrderId: orderInfo.Id, - Balance: userInfo.Balance, - } - err = l.svc.UserModel.InsertBalanceLog(ctx, &balanceLog, tx) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Insert balance log failed", - logger.Field("error", err.Error()), - ) + userInfo.Balance += orderInfo.Price + if err := l.svc.UserModel.Update(ctx, userInfo, tx); err != nil { return err } - return nil + balanceLog := &user.BalanceLog{ + UserId: orderInfo.UserId, + Amount: orderInfo.Price, + Type: CommissionTypeRecharge, + OrderId: orderInfo.Id, + Balance: userInfo.Balance, + } + return l.svc.UserModel.InsertBalanceLog(ctx, balanceLog, tx) }) + if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Database transaction failed", - logger.Field("error", err.Error()), - ) + logger.WithContext(ctx).Error("Database transaction failed", logger.Field("error", err.Error())) return err } - userTelegramChatId, ok := findTelegram(userInfo) - // SendMessage To Telegram - if ok { - text, err := tool.RenderTemplateToString(telegram.RechargeNotify, map[string]string{ + + // Send notifications + l.sendRechargeNotifications(ctx, orderInfo, userInfo) + + return nil +} + +// sendNotifications sends both user and admin notifications for order completion +func (l *ActivateOrderLogic) sendNotifications(ctx context.Context, orderInfo *order.Order, userInfo *user.User, sub *subscribe.Subscribe, userSub *user.Subscribe, notifyType string) { + // Send user notification + if telegramId, ok := findTelegram(userInfo); ok { + templateData := l.buildUserNotificationData(orderInfo, sub, userSub) + if text, err := tool.RenderTemplateToString(notifyType, templateData); err == nil { + l.sendUserNotifyWithTelegram(telegramId, text) + } + } + + // Send admin notification + adminData := l.buildAdminNotificationData(orderInfo, sub) + if text, err := tool.RenderTemplateToString(telegram.AdminOrderNotify, adminData); err == nil { + l.sendAdminNotifyWithTelegram(ctx, text) + } +} + +// sendRechargeNotifications sends specific notifications for balance recharge orders +func (l *ActivateOrderLogic) sendRechargeNotifications(ctx context.Context, orderInfo *order.Order, userInfo *user.User) { + // Send user notification + if telegramId, ok := findTelegram(userInfo); ok { + templateData := map[string]string{ "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), "PaymentMethod": orderInfo.Method, "Time": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), "Balance": fmt.Sprintf("%.2f", float64(userInfo.Balance)/100), - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render template failed", - logger.Field("error", err.Error()), - ) } - l.sendUserNotifyWithTelegram(userTelegramChatId, text) + if text, err := tool.RenderTemplateToString(telegram.RechargeNotify, templateData); err == nil { + l.sendUserNotifyWithTelegram(telegramId, text) + } } - // send message to admin - text, err := tool.RenderTemplateToString(telegram.AdminOrderNotify, map[string]string{ + + // Send admin notification + adminData := map[string]string{ "OrderNo": orderInfo.OrderNo, "TradeNo": orderInfo.TradeNo, "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), @@ -611,65 +634,83 @@ func (l *ActivateOrderLogic) Recharge(ctx context.Context, orderInfo *order.Orde "OrderStatus": "已支付", "OrderTime": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), "PaymentMethod": orderInfo.Method, - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render AdminOrderNotify template failed", - logger.Field("error", err.Error()), - ) } - l.sendAdminNotifyWithTelegram(ctx, text) - return nil + if text, err := tool.RenderTemplateToString(telegram.AdminOrderNotify, adminData); err == nil { + l.sendAdminNotifyWithTelegram(ctx, text) + } } -// sendUserNotifyWithTelegram send message to user +// buildUserNotificationData creates template data for user notifications +func (l *ActivateOrderLogic) buildUserNotificationData(orderInfo *order.Order, sub *subscribe.Subscribe, userSub *user.Subscribe) map[string]string { + data := map[string]string{ + "OrderNo": orderInfo.OrderNo, + "SubscribeName": sub.Name, + "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), + } + + if userSub != nil { + data["ExpireTime"] = userSub.ExpireTime.Format("2006-01-02 15:04:05") + data["ResetTime"] = time.Now().Format("2006-01-02 15:04:05") + } + + return data +} + +// buildAdminNotificationData creates template data for admin notifications +func (l *ActivateOrderLogic) buildAdminNotificationData(orderInfo *order.Order, sub *subscribe.Subscribe) map[string]string { + subscribeName := sub.Name + if orderInfo.Type == OrderTypeResetTraffic { + subscribeName = "流量重置" + } + + return map[string]string{ + "OrderNo": orderInfo.OrderNo, + "TradeNo": orderInfo.TradeNo, + "SubscribeName": subscribeName, + "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), + "OrderStatus": "已支付", + "OrderTime": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), + "PaymentMethod": orderInfo.Method, + } +} + +// sendUserNotifyWithTelegram sends a notification message to a user via Telegram func (l *ActivateOrderLogic) sendUserNotifyWithTelegram(chatId int64, text string) { msg := tgbotapi.NewMessage(chatId, text) msg.ParseMode = "markdown" - _, err := l.svc.TelegramBot.Send(msg) - if err != nil { - logger.Error("[ActivateOrderLogic] Send telegram user message failed", - logger.Field("error", err.Error()), - ) + if _, err := l.svc.TelegramBot.Send(msg); err != nil { + logger.Error("Send telegram user message failed", logger.Field("error", err.Error())) } } -// sendAdminNotifyWithTelegram send message to admin +// sendAdminNotifyWithTelegram sends a notification message to all admin users via Telegram func (l *ActivateOrderLogic) sendAdminNotifyWithTelegram(ctx context.Context, text string) { admins, err := l.svc.UserModel.QueryAdminUsers(ctx) if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Query admin users failed", - logger.Field("error", err.Error()), - ) + logger.WithContext(ctx).Error("Query admin users failed", logger.Field("error", err.Error())) return } + for _, admin := range admins { - telegramId, ok := findTelegram(admin) - if !ok { - continue - } - msg := tgbotapi.NewMessage(telegramId, text) - msg.ParseMode = "markdown" - _, err := l.svc.TelegramBot.Send(msg) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Send telegram admin message failed", - logger.Field("error", err.Error()), - ) + if telegramId, ok := findTelegram(admin); ok { + msg := tgbotapi.NewMessage(telegramId, text) + msg.ParseMode = "markdown" + if _, err := l.svc.TelegramBot.Send(msg); err != nil { + logger.WithContext(ctx).Error("Send telegram admin message failed", logger.Field("error", err.Error())) + } } } } -// findTelegram find user telegram id +// findTelegram extracts Telegram chat ID from user authentication methods. +// Returns the chat ID and a boolean indicating if Telegram auth was found. func findTelegram(u *user.User) (int64, bool) { for _, item := range u.AuthMethods { if item.AuthType == "telegram" { - // string to int64 - parseInt, err := strconv.ParseInt(item.AuthIdentifier, 10, 64) - if err != nil { - return 0, false + if telegramId, err := strconv.ParseInt(item.AuthIdentifier, 10, 64); err == nil { + return telegramId, true } - return parseInt, true } - } return 0, false } diff --git a/queue/logic/order/activateOrderLogic.go_bak b/queue/logic/order/activateOrderLogic.go_bak new file mode 100644 index 0000000..f57f57f --- /dev/null +++ b/queue/logic/order/activateOrderLogic.go_bak @@ -0,0 +1,675 @@ +package orderLogic + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/perfect-panel/server/pkg/constant" + + "github.com/perfect-panel/server/pkg/logger" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/google/uuid" + "github.com/hibiken/asynq" + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/logic/telegram" + "github.com/perfect-panel/server/internal/model/order" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/tool" + "github.com/perfect-panel/server/pkg/uuidx" + "github.com/perfect-panel/server/queue/types" + "gorm.io/gorm" +) + +const ( + Subscribe = 1 + Renewal = 2 + ResetTraffic = 3 + Recharge = 4 +) + +type ActivateOrderLogic struct { + svc *svc.ServiceContext +} + +func NewActivateOrderLogic(svc *svc.ServiceContext) *ActivateOrderLogic { + return &ActivateOrderLogic{ + svc: svc, + } +} + +func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task) error { + payload := types.ForthwithActivateOrderPayload{} + if err := json.Unmarshal(task.Payload(), &payload); err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Unmarshal payload failed", + logger.Field("error", err.Error()), + logger.Field("payload", string(task.Payload())), + ) + return nil + } + // Find order by order no + orderInfo, err := l.svc.OrderModel.FindOneByOrderNo(ctx, payload.OrderNo) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Find order failed", + logger.Field("error", err.Error()), + logger.Field("order_no", payload.OrderNo), + ) + return nil + } + // 1: Pending, 2: Paid, 3:Close, 4: Failed, 5:Finished + if orderInfo.Status != 2 { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Order status error", + logger.Field("order_no", orderInfo.OrderNo), + logger.Field("status", orderInfo.Status), + ) + return nil + } + switch orderInfo.Type { + case Subscribe: + err = l.NewPurchase(ctx, orderInfo) + case Renewal: + err = l.Renewal(ctx, orderInfo) + case ResetTraffic: + err = l.ResetTraffic(ctx, orderInfo) + case Recharge: + err = l.Recharge(ctx, orderInfo) + default: + logger.WithContext(ctx).Error("[ActivateOrderLogic] Order type is invalid", logger.Field("type", orderInfo.Type)) + return nil + } + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Process task failed", logger.Field("error", err.Error())) + return nil + } + // if coupon is not empty + if orderInfo.Coupon != "" { + // update coupon status + err = l.svc.CouponModel.UpdateCount(ctx, orderInfo.Coupon) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Update coupon status failed", + logger.Field("error", err.Error()), + logger.Field("coupon", orderInfo.Coupon), + ) + } + } + // update order status + orderInfo.Status = 5 + err = l.svc.OrderModel.Update(ctx, orderInfo) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Update order status failed", + logger.Field("error", err.Error()), + logger.Field("order_no", orderInfo.OrderNo), + ) + } + + return nil +} + +// NewPurchase New purchase +func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.Order) error { + var userInfo *user.User + var err error + if orderInfo.UserId != 0 { + // find user by user id + userInfo, err = l.svc.UserModel.FindOne(ctx, orderInfo.UserId) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user failed", + logger.Field("error", err.Error()), + logger.Field("user_id", orderInfo.UserId), + ) + return err + } + } else { + // If User ID is 0, it means that the order is a guest order, need to create a new user + // query info with redis + cacheKey := fmt.Sprintf(constant.TempOrderCacheKey, orderInfo.OrderNo) + data, err := l.svc.Redis.Get(ctx, cacheKey).Result() + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Get temp order cache failed", + logger.Field("error", err.Error()), + logger.Field("cache_key", cacheKey), + ) + return err + } + var tempOrder constant.TemporaryOrderInfo + if err = json.Unmarshal([]byte(data), &tempOrder); err != nil { + logger.WithContext(ctx).Errorw("[ActivateOrderLogic] Unmarshal temp order failed", + logger.Field("error", err.Error()), + ) + return err + } + // create user + + userInfo = &user.User{ + Password: tool.EncodePassWord(tempOrder.Password), + AuthMethods: []user.AuthMethods{ + { + AuthType: tempOrder.AuthType, + AuthIdentifier: tempOrder.Identifier, + }, + }, + } + err = l.svc.UserModel.Transaction(ctx, func(tx *gorm.DB) error { + // Save user information + if err := tx.Save(userInfo).Error; err != nil { + return err + } + // Generate ReferCode + userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id) + // Update ReferCode + if err := tx.Model(&user.User{}).Where("id = ?", userInfo.Id).Update("refer_code", userInfo.ReferCode).Error; err != nil { + return err + } + orderInfo.UserId = userInfo.Id + return tx.Model(&order.Order{}).Where("order_no = ?", orderInfo.OrderNo).Update("user_id", userInfo.Id).Error + }) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Create user failed", + logger.Field("error", err.Error()), + ) + return err + } + + if tempOrder.InviteCode != "" { + // find referer by refer code + referer, err := l.svc.UserModel.FindOneByReferCode(ctx, tempOrder.InviteCode) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Find referer failed", + logger.Field("error", err.Error()), + logger.Field("refer_code", tempOrder.InviteCode), + ) + } else { + userInfo.RefererId = referer.Id + err = l.svc.UserModel.Update(ctx, userInfo) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Update user referer failed", + logger.Field("error", err.Error()), + logger.Field("user_id", userInfo.Id), + ) + } + } + } + + logger.WithContext(ctx).Info("[ActivateOrderLogic] Create guest user success", logger.Field("user_id", userInfo.Id), logger.Field("Identifier", tempOrder.Identifier), logger.Field("AuthType", tempOrder.AuthType)) + } + // find subscribe by id + sub, err := l.svc.SubscribeModel.FindOne(ctx, orderInfo.SubscribeId) + if err != nil { + logger.WithContext(ctx).Errorw("[ActivateOrderLogic] Find subscribe failed", + logger.Field("error", err.Error()), + logger.Field("subscribe_id", orderInfo.SubscribeId), + ) + return err + } + // create user subscribe + now := time.Now() + + userSub := user.Subscribe{ + Id: 0, + UserId: orderInfo.UserId, + OrderId: orderInfo.Id, + SubscribeId: orderInfo.SubscribeId, + StartTime: now, + ExpireTime: tool.AddTime(sub.UnitTime, orderInfo.Quantity, now), + Traffic: sub.Traffic, + Download: 0, + Upload: 0, + Token: uuidx.SubscribeToken(orderInfo.OrderNo), + UUID: uuid.New().String(), + Status: 1, + } + err = l.svc.UserModel.InsertSubscribe(ctx, &userSub) + + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Insert user subscribe failed", + logger.Field("error", err.Error()), + ) + return err + } + // handler commission + if userInfo.RefererId != 0 && + l.svc.Config.Invite.ReferralPercentage != 0 && + (!l.svc.Config.Invite.OnlyFirstPurchase || orderInfo.IsNew) { + referer, err := l.svc.UserModel.FindOne(ctx, userInfo.RefererId) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Find referer failed", + logger.Field("error", err.Error()), + logger.Field("referer_id", userInfo.RefererId), + ) + goto updateCache + } + // calculate commission + amount := float64(orderInfo.Price) * (float64(l.svc.Config.Invite.ReferralPercentage) / 100) + referer.Commission += int64(amount) + err = l.svc.UserModel.Update(ctx, referer) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Update referer commission failed", + logger.Field("error", err.Error()), + ) + goto updateCache + } + // create commission log + commissionLog := user.CommissionLog{ + UserId: referer.Id, + OrderNo: orderInfo.OrderNo, + Amount: int64(amount), + } + err = l.svc.UserModel.InsertCommissionLog(ctx, &commissionLog) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Insert commission log failed", + logger.Field("error", err.Error()), + ) + } + err = l.svc.UserModel.UpdateUserCache(ctx, referer) + if err != nil { + logger.WithContext(ctx).Errorw("[ActivateOrderLogic] Update referer cache", logger.Field("error", err.Error()), logger.Field("user_id", referer.Id)) + } + } +updateCache: + for _, id := range tool.StringToInt64Slice(sub.Server) { + cacheKey := fmt.Sprintf("%s%d", config.ServerUserListCacheKey, id) + err = l.svc.Redis.Del(ctx, cacheKey).Err() + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Del server user list cache failed", + logger.Field("error", err.Error()), + logger.Field("cache_key", cacheKey), + ) + } + } + data, err := l.svc.ServerModel.FindServerListByGroupIds(ctx, tool.StringToInt64Slice(sub.ServerGroup)) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Find server list failed", logger.Field("error", err.Error())) + return err + } + for _, item := range data { + cacheKey := fmt.Sprintf("%s%d", config.ServerUserListCacheKey, item.Id) + err = l.svc.Redis.Del(ctx, cacheKey).Err() + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Del server user list cache failed", + logger.Field("error", err.Error()), + logger.Field("cache_key", cacheKey), + ) + } + } + userTelegramChatId, ok := findTelegram(userInfo) + + // sendMessage To Telegram + if ok { + text, err := tool.RenderTemplateToString(telegram.PurchaseNotify, map[string]string{ + "OrderNo": orderInfo.OrderNo, + "SubscribeName": sub.Name, + "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), + "ExpireTime": userSub.ExpireTime.Format("2006-01-02 15:04:05"), + }) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Render template failed", + logger.Field("error", err.Error()), + ) + } + l.sendUserNotifyWithTelegram(userTelegramChatId, text) + } + // send message to admin + text, err := tool.RenderTemplateToString(telegram.AdminOrderNotify, map[string]string{ + "OrderNo": orderInfo.OrderNo, + "TradeNo": orderInfo.TradeNo, + "SubscribeName": sub.Name, + //"UserEmail": userInfo.Email, + "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), + "OrderStatus": "已支付", + "OrderTime": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), + "PaymentMethod": orderInfo.Method, + }) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Render AdminOrderNotify template failed", + logger.Field("error", err.Error()), + ) + } + l.sendAdminNotifyWithTelegram(ctx, text) + logger.WithContext(ctx).Info("[ActivateOrderLogic] Insert user subscribe success") + return nil +} + +// Renewal Renewal +func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order) error { + // find user by user id + userInfo, err := l.svc.UserModel.FindOne(ctx, orderInfo.UserId) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user failed", + logger.Field("error", err.Error()), + logger.Field("user_id", orderInfo.UserId), + ) + return err + } + // find user subscribe by subscribe token + userSub, err := l.svc.UserModel.FindOneSubscribeByToken(ctx, orderInfo.SubscribeToken) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user subscribe failed", + logger.Field("error", err.Error()), + logger.Field("order_id", orderInfo.Id), + ) + return err + } + // find subscribe by id + sub, err := l.svc.SubscribeModel.FindOne(ctx, orderInfo.SubscribeId) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Find subscribe failed", + logger.Field("error", err.Error()), + logger.Field("subscribe_id", orderInfo.SubscribeId), + logger.Field("order_id", orderInfo.Id), + ) + return err + } + now := time.Now() + if userSub.ExpireTime.Before(now) { + userSub.ExpireTime = now + } + + // Check whether traffic reset on renewal is enabled + if sub.RenewalReset != nil && *sub.RenewalReset { + userSub.Download = 0 + userSub.Upload = 0 + } + if userSub.FinishedAt != nil { + userSub.FinishedAt = nil + } + + userSub.ExpireTime = tool.AddTime(sub.UnitTime, orderInfo.Quantity, userSub.ExpireTime) + userSub.Status = 1 + // update user subscribe + err = l.svc.UserModel.UpdateSubscribe(ctx, userSub) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Update user subscribe failed", + logger.Field("error", err.Error()), + ) + return err + } + // handler commission + if userInfo.RefererId != 0 && + l.svc.Config.Invite.ReferralPercentage != 0 && + !l.svc.Config.Invite.OnlyFirstPurchase { + referer, err := l.svc.UserModel.FindOne(ctx, userInfo.RefererId) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Find referer failed", + logger.Field("error", err.Error()), + logger.Field("referer_id", userInfo.RefererId), + ) + goto sendMessage + } + // calculate commission + amount := float64(orderInfo.Price) * (float64(l.svc.Config.Invite.ReferralPercentage) / 100) + referer.Commission += int64(amount) + err = l.svc.UserModel.Update(ctx, referer) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Update referer commission failed", + logger.Field("error", err.Error()), + ) + goto sendMessage + } + // create commission log + commissionLog := user.CommissionLog{ + UserId: referer.Id, + OrderNo: orderInfo.OrderNo, + Amount: int64(amount), + } + err = l.svc.UserModel.InsertCommissionLog(ctx, &commissionLog) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Insert commission log failed", + logger.Field("error", err.Error()), + ) + } + err = l.svc.UserModel.UpdateUserCache(ctx, referer) + if err != nil { + logger.WithContext(ctx).Errorw("[ActivateOrderLogic] Update referer cache", logger.Field("error", err.Error()), logger.Field("user_id", referer.Id)) + } + } + +sendMessage: + userTelegramChatId, ok := findTelegram(userInfo) + // SendMessage To Telegram + if ok { + text, err := tool.RenderTemplateToString(telegram.RenewalNotify, map[string]string{ + "OrderNo": orderInfo.OrderNo, + "SubscribeName": sub.Name, + "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), + "ExpireTime": userSub.ExpireTime.Format("2006-01-02 15:04:05"), + }) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Render template failed", + logger.Field("error", err.Error()), + ) + } + l.sendUserNotifyWithTelegram(userTelegramChatId, text) + } + + // send message to admin + text, err := tool.RenderTemplateToString(telegram.AdminOrderNotify, map[string]string{ + "OrderNo": orderInfo.OrderNo, + "TradeNo": orderInfo.TradeNo, + "SubscribeName": sub.Name, + //"UserEmail": userInfo.Email, + "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), + "OrderStatus": "已支付", + "OrderTime": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), + "PaymentMethod": orderInfo.Method, + }) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Render AdminOrderNotify template failed", + logger.Field("error", err.Error()), + ) + } + l.sendAdminNotifyWithTelegram(ctx, text) + return nil +} + +// ResetTraffic Reset traffic +func (l *ActivateOrderLogic) ResetTraffic(ctx context.Context, orderInfo *order.Order) error { + // find user by user id + userInfo, err := l.svc.UserModel.FindOne(ctx, orderInfo.UserId) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user failed", + logger.Field("error", err.Error()), + logger.Field("user_id", orderInfo.UserId), + ) + return err + } + // Generate a Subscribe Token through orderNo + // find user subscribe by subscribe token + userSub, err := l.svc.UserModel.FindOneSubscribeByToken(ctx, orderInfo.SubscribeToken) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user subscribe failed", + logger.Field("error", err.Error()), + logger.Field("order_id", orderInfo.Id), + ) + return err + } + userSub.Download = 0 + userSub.Upload = 0 + userSub.Status = 1 + // update user subscribe + err = l.svc.UserModel.UpdateSubscribe(ctx, userSub) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Update user subscribe failed", + logger.Field("error", err.Error()), + ) + return err + } + sub, err := l.svc.SubscribeModel.FindOne(ctx, userSub.SubscribeId) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Find subscribe failed", + logger.Field("error", err.Error()), + logger.Field("subscribe_id", userSub.SubscribeId), + ) + return err + } + userTelegramChatId, ok := findTelegram(userInfo) + // SendMessage To Telegram + if ok { + text, err := tool.RenderTemplateToString(telegram.ResetTrafficNotify, map[string]string{ + "OrderNo": orderInfo.OrderNo, + "SubscribeName": sub.Name, + "ResetTime": time.Now().Format("2006-01-02 15:04:05"), + "ExpireTime": userSub.ExpireTime.Format("2006-01-02 15:04:05"), + }) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Render template failed", + logger.Field("error", err.Error()), + ) + } + l.sendUserNotifyWithTelegram(userTelegramChatId, text) + } + + // send message to admin + text, err := tool.RenderTemplateToString(telegram.AdminOrderNotify, map[string]string{ + "OrderNo": orderInfo.OrderNo, + "TradeNo": orderInfo.TradeNo, + "SubscribeName": "流量重置", + "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), + "OrderStatus": "已支付", + "OrderTime": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), + "PaymentMethod": orderInfo.Method, + }) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Render AdminOrderNotify template failed", + logger.Field("error", err.Error()), + ) + } + l.sendAdminNotifyWithTelegram(ctx, text) + return nil +} + +// Recharge Recharge to user +func (l *ActivateOrderLogic) Recharge(ctx context.Context, orderInfo *order.Order) error { + // find user by user id + userInfo, err := l.svc.UserModel.FindOne(ctx, orderInfo.UserId) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user failed", + logger.Field("error", err.Error()), + logger.Field("user_id", orderInfo.UserId), + ) + return err + } + userInfo.Balance += orderInfo.Price + // update user + err = l.svc.DB.Transaction(func(tx *gorm.DB) error { + err = l.svc.UserModel.Update(ctx, userInfo, tx) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Update user failed", + logger.Field("error", err.Error()), + ) + return err + } + // Create Balance Log + balanceLog := user.BalanceLog{ + UserId: orderInfo.UserId, + Amount: orderInfo.Price, + Type: 1, + OrderId: orderInfo.Id, + Balance: userInfo.Balance, + } + err = l.svc.UserModel.InsertBalanceLog(ctx, &balanceLog, tx) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Insert balance log failed", + logger.Field("error", err.Error()), + ) + return err + } + + return nil + }) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Database transaction failed", + logger.Field("error", err.Error()), + ) + return err + } + userTelegramChatId, ok := findTelegram(userInfo) + // SendMessage To Telegram + if ok { + text, err := tool.RenderTemplateToString(telegram.RechargeNotify, map[string]string{ + "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), + "PaymentMethod": orderInfo.Method, + "Time": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), + "Balance": fmt.Sprintf("%.2f", float64(userInfo.Balance)/100), + }) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Render template failed", + logger.Field("error", err.Error()), + ) + } + l.sendUserNotifyWithTelegram(userTelegramChatId, text) + } + // send message to admin + text, err := tool.RenderTemplateToString(telegram.AdminOrderNotify, map[string]string{ + "OrderNo": orderInfo.OrderNo, + "TradeNo": orderInfo.TradeNo, + "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), + "SubscribeName": "余额充值", + "OrderStatus": "已支付", + "OrderTime": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), + "PaymentMethod": orderInfo.Method, + }) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Render AdminOrderNotify template failed", + logger.Field("error", err.Error()), + ) + } + l.sendAdminNotifyWithTelegram(ctx, text) + return nil +} + +// sendUserNotifyWithTelegram send message to user +func (l *ActivateOrderLogic) sendUserNotifyWithTelegram(chatId int64, text string) { + msg := tgbotapi.NewMessage(chatId, text) + msg.ParseMode = "markdown" + _, err := l.svc.TelegramBot.Send(msg) + if err != nil { + logger.Error("[ActivateOrderLogic] Send telegram user message failed", + logger.Field("error", err.Error()), + ) + } +} + +// sendAdminNotifyWithTelegram send message to admin +func (l *ActivateOrderLogic) sendAdminNotifyWithTelegram(ctx context.Context, text string) { + admins, err := l.svc.UserModel.QueryAdminUsers(ctx) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Query admin users failed", + logger.Field("error", err.Error()), + ) + return + } + for _, admin := range admins { + telegramId, ok := findTelegram(admin) + if !ok { + continue + } + msg := tgbotapi.NewMessage(telegramId, text) + msg.ParseMode = "markdown" + _, err := l.svc.TelegramBot.Send(msg) + if err != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] Send telegram admin message failed", + logger.Field("error", err.Error()), + ) + } + } +} + +// findTelegram find user telegram id +func findTelegram(u *user.User) (int64, bool) { + for _, item := range u.AuthMethods { + if item.AuthType == "telegram" { + // string to int64 + parseInt, err := strconv.ParseInt(item.AuthIdentifier, 10, 64) + if err != nil { + return 0, false + } + return parseInt, true + } + + } + return 0, false +} diff --git a/queue/logic/traffic/resetTrafficLogic.go b/queue/logic/traffic/resetTrafficLogic.go new file mode 100644 index 0000000..2332d65 --- /dev/null +++ b/queue/logic/traffic/resetTrafficLogic.go @@ -0,0 +1,540 @@ +package traffic + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "github.com/hibiken/asynq" + "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/pkg/logger" + "github.com/perfect-panel/server/queue/types" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// ResetTrafficLogic handles traffic reset logic for different subscription cycles +// Supports three reset modes: +// - reset_cycle = 1: Reset on 1st of every month +// - reset_cycle = 2: Reset monthly based on subscription start date +// - reset_cycle = 3: Reset yearly based on subscription start date +type ResetTrafficLogic struct { + svc *svc.ServiceContext +} + +// Cache and retry configuration constants +const ( + maxRetryAttempts = 3 + retryDelay = 30 * time.Minute + lockTimeout = 5 * time.Minute +) + +// Cache keys +var ( + cacheKey = "reset_traffic_cache" + retryCountKey = "reset_traffic_retry_count" + lockKey = "reset_traffic_lock" +) + +// resetTrafficCache stores the last reset time to prevent duplicate processing +type resetTrafficCache struct { + LastResetTime time.Time +} + +func NewResetTrafficLogic(svc *svc.ServiceContext) *ResetTrafficLogic { + return &ResetTrafficLogic{ + svc: svc, + } +} + +// ProcessTask executes the traffic reset task for all subscription types with enhanced retry mechanism +func (l *ResetTrafficLogic) ProcessTask(ctx context.Context, _ *asynq.Task) error { + var err error + startTime := time.Now() + + // Get current retry count + retryCount := l.getRetryCount(ctx) + logger.Infow("[ResetTraffic] Starting task execution", + logger.Field("retryCount", retryCount), + logger.Field("startTime", startTime)) + + // Acquire distributed lock to prevent duplicate execution + lockAcquired := l.acquireLock(ctx) + if !lockAcquired { + logger.Infow("[ResetTraffic] Another task is already running, skipping execution") + return nil + } + defer l.releaseLock(ctx) + + defer func() { + if err != nil { + // Check if error is retryable and within retry limit + if l.isRetryableError(err) && retryCount < maxRetryAttempts { + // Increment retry count + l.setRetryCount(ctx, retryCount+1) + + // Schedule retry with delay + task := asynq.NewTask(types.SchedulerResetTraffic, nil) + _, retryErr := l.svc.Queue.Enqueue(task, asynq.ProcessIn(retryDelay)) + if retryErr != nil { + logger.Errorw("[ResetTraffic] Failed to enqueue retry task", + logger.Field("error", retryErr.Error()), + logger.Field("retryCount", retryCount)) + } else { + logger.Infow("[ResetTraffic] Task failed, retrying in 30 minutes", + logger.Field("error", err.Error()), + logger.Field("retryCount", retryCount+1), + logger.Field("maxRetryAttempts", maxRetryAttempts)) + } + } else { + // Max retries reached or non-retryable error + if retryCount >= maxRetryAttempts { + logger.Errorw("[ResetTraffic] Max retry attempts reached, giving up", + logger.Field("retryCount", retryCount), + logger.Field("maxRetryAttempts", maxRetryAttempts), + logger.Field("error", err.Error())) + } else { + logger.Errorw("[ResetTraffic] Non-retryable error, not retrying", + logger.Field("error", err.Error()), + logger.Field("retryCount", retryCount)) + } + // Reset retry count for next scheduled task + l.clearRetryCount(ctx) + } + } else { + // Task completed successfully, reset retry count + l.clearRetryCount(ctx) + logger.Infow("[ResetTraffic] Task completed successfully", + logger.Field("processingTime", time.Since(startTime)), + logger.Field("retryCount", retryCount)) + } + }() + + // Load last reset time from cache + var cache resetTrafficCache + err = l.svc.Redis.Get(ctx, cacheKey).Scan(&cache) + if err != nil { + if !errors.Is(err, redis.Nil) { + logger.Errorw("[ResetTraffic] Failed to get cache", logger.Field("error", err.Error())) + } + // Set default value if cache not found + cache = resetTrafficCache{ + LastResetTime: time.Now().Add(-10 * time.Minute), + } + logger.Infow("[ResetTraffic] Using default cache value", logger.Field("lastResetTime", cache.LastResetTime)) + } else { + logger.Infow("[ResetTraffic] Cache loaded successfully", logger.Field("lastResetTime", cache.LastResetTime)) + } + + // Execute reset operations in order: yearly -> monthly (1st) -> monthly (cycle) + err = l.resetYear(ctx) + if err != nil { + logger.Errorw("[ResetTraffic] Yearly reset failed", logger.Field("error", err.Error())) + return err + } + + err = l.reset1st(ctx, cache) + if err != nil { + logger.Errorw("[ResetTraffic] Monthly 1st reset failed", logger.Field("error", err.Error())) + return err + } + + err = l.resetMonth(ctx) + if err != nil { + logger.Errorw("[ResetTraffic] Monthly cycle reset failed", logger.Field("error", err.Error())) + return err + } + + // Update cache with current time after successful processing + updatedCache := resetTrafficCache{ + LastResetTime: startTime, + } + cacheErr := l.svc.Redis.Set(ctx, cacheKey, updatedCache, 0).Err() + if cacheErr != nil { + logger.Errorw("[ResetTraffic] Failed to update cache", logger.Field("error", cacheErr.Error())) + // Don't return error here as the main task completed successfully + } else { + logger.Infow("[ResetTraffic] Cache updated successfully", logger.Field("newLastResetTime", startTime)) + } + + return nil +} + +// resetMonth handles monthly cycle reset based on subscription start date +// reset_cycle = 2: Reset monthly based on subscription start date +func (l *ResetTrafficLogic) resetMonth(ctx context.Context) error { + now := time.Now() + + err := l.svc.UserModel.Transaction(ctx, func(db *gorm.DB) error { + // Get all subscriptions that reset monthly based on start date + var resetMonthSubIds []int64 + err := db.Model(&subscribe.Subscribe{}).Select("`id`").Where("`reset_cycle` = ?", 2).Find(&resetMonthSubIds).Error + if err != nil { + logger.Errorw("[ResetTraffic] Failed to query monthly subscriptions", logger.Field("error", err.Error())) + return err + } + + if len(resetMonthSubIds) == 0 { + logger.Infow("[ResetTraffic] No monthly cycle subscriptions found") + return nil + } + + // Query users for monthly reset based on subscription start date cycle + var monthlyResetUsers []int64 + + // Check if today is the last day of current month + nextMonth := now.AddDate(0, 1, 0) + isLastDayOfMonth := nextMonth.Month() != now.Month() + + query := db.Model(&user.Subscribe{}).Select("`id`"). + Where("`subscribe_id` IN ?", resetMonthSubIds). + Where("`status` = ?", 1). // Only active subscriptions + Where("PERIOD_DIFF(DATE_FORMAT(CURDATE(), '%Y%m'), DATE_FORMAT(start_time, '%Y%m')) > 0"). // At least one month passed + Where("MOD(PERIOD_DIFF(DATE_FORMAT(CURDATE(), '%Y%m'), DATE_FORMAT(start_time, '%Y%m')), 1) = 0"). // Monthly cycle + Where("DATE(start_time) < CURDATE()") // Only reset subscriptions that have started + + if isLastDayOfMonth { + // Last day of month: handle subscription start dates >= today + query = query.Where("DAY(start_time) >= ?", now.Day()) + } else { + // Normal case: exact day match + query = query.Where("DAY(start_time) = ?", now.Day()) + } + + err = query.Find(&monthlyResetUsers).Error + if err != nil { + logger.Errorw("[ResetTraffic] Failed to query monthly reset users", logger.Field("error", err.Error())) + return err + } + + if len(monthlyResetUsers) > 0 { + logger.Infow("[ResetTraffic] Found users for monthly reset", + logger.Field("count", len(monthlyResetUsers)), + logger.Field("userIds", monthlyResetUsers)) + + err = db.Model(&user.Subscribe{}).Where("`id` IN ?", monthlyResetUsers). + Updates(map[string]interface{}{ + "upload": 0, + "download": 0, + }).Error + if err != nil { + logger.Errorw("[ResetTraffic] Failed to update monthly reset users", logger.Field("error", err.Error())) + return err + } + + logger.Infow("[ResetTraffic] Monthly reset completed", logger.Field("count", len(monthlyResetUsers))) + } else { + logger.Infow("[ResetTraffic] No users found for monthly reset") + } + + return nil + }) + if err != nil { + logger.Errorw("[ResetTraffic] Monthly reset transaction failed", logger.Field("error", err.Error())) + return err + } + + logger.Infow("[ResetTraffic] Monthly reset process completed") + return nil +} + +// reset1st handles reset on 1st of every month +// reset_cycle = 1: Reset on 1st of every month +func (l *ResetTrafficLogic) reset1st(ctx context.Context, cache resetTrafficCache) error { + now := time.Now() + + // Check if we already reset this month using cache + if cache.LastResetTime.Year() == now.Year() && cache.LastResetTime.Month() == now.Month() { + logger.Infow("[ResetTraffic] Already reset this month, skipping 1st reset", + logger.Field("lastResetTime", cache.LastResetTime), + logger.Field("currentTime", now)) + return nil + } + + // Only reset if it's the 1st day of the month + if now.Day() != 1 { + logger.Infow("[ResetTraffic] Not 1st day of month, skipping 1st reset", logger.Field("currentDay", now.Day())) + return nil + } + + err := l.svc.UserModel.Transaction(ctx, func(db *gorm.DB) error { + // Get all subscriptions that reset on 1st of month + var reset1stSubIds []int64 + err := db.Model(&subscribe.Subscribe{}).Select("`id`").Where("`reset_cycle` = ?", 1).Find(&reset1stSubIds).Error + if err != nil { + logger.Errorw("[ResetTraffic] Failed to query 1st reset subscriptions", logger.Field("error", err.Error())) + return err + } + + if len(reset1stSubIds) == 0 { + logger.Infow("[ResetTraffic] No 1st reset subscriptions found") + return nil + } + + // Get all active users with these subscriptions + var users1stReset []int64 + err = db.Model(&user.Subscribe{}).Select("`id`"). + Where("`subscribe_id` IN ?", reset1stSubIds). + Where("`status` = ?", 1). // Only active subscriptions + Find(&users1stReset).Error + if err != nil { + logger.Errorw("[ResetTraffic] Failed to query 1st reset users", logger.Field("error", err.Error())) + return err + } + + if len(users1stReset) > 0 { + logger.Infow("[ResetTraffic] Found users for 1st reset", + logger.Field("count", len(users1stReset)), + logger.Field("userIds", users1stReset)) + + // Reset upload and download traffic to zero + err = db.Model(&user.Subscribe{}).Where("`id` IN ?", users1stReset). + Updates(map[string]interface{}{ + "upload": 0, + "download": 0, + }).Error + if err != nil { + logger.Errorw("[ResetTraffic] Failed to update 1st reset users", logger.Field("error", err.Error())) + return err + } + + logger.Infow("[ResetTraffic] 1st reset completed", logger.Field("count", len(users1stReset))) + } else { + logger.Infow("[ResetTraffic] No users found for 1st reset") + } + + return nil + }) + + if err != nil { + logger.Errorw("[ResetTraffic] 1st reset transaction failed", logger.Field("error", err.Error())) + return err + } + + logger.Infow("[ResetTraffic] 1st reset process completed") + return nil +} + +// resetYear handles yearly reset based on subscription start date anniversary +// reset_cycle = 3: Reset yearly based on subscription start date +func (l *ResetTrafficLogic) resetYear(ctx context.Context) error { + now := time.Now() + + err := l.svc.UserModel.Transaction(ctx, func(db *gorm.DB) error { + // Get all subscriptions that reset yearly + var resetYearSubIds []int64 + err := db.Model(&subscribe.Subscribe{}).Select("`id`").Where("`reset_cycle` = ?", 3).Find(&resetYearSubIds).Error + if err != nil { + logger.Errorw("[ResetTraffic] Failed to query yearly subscriptions", logger.Field("error", err.Error())) + return err + } + + if len(resetYearSubIds) == 0 { + logger.Infow("[ResetTraffic] No yearly reset subscriptions found") + return nil + } + + // Query users for yearly reset based on subscription start date anniversary + var usersYearReset []int64 + + // Check if today is the last day of current month + nextMonth := now.AddDate(0, 1, 0) + isLastDayOfMonth := nextMonth.Month() != now.Month() + + // Check if today is February 28th (handle leap year case) + isLeapYearCase := now.Month() == 2 && now.Day() == 28 + + query := db.Model(&user.Subscribe{}).Select("`id`"). + Where("`subscribe_id` IN ?", resetYearSubIds). + Where("MONTH(start_time) = ?", now.Month()). // Same month + Where("`status` = ?", 1). // Only active subscriptions + Where("TIMESTAMPDIFF(YEAR, DATE(start_time), CURDATE()) >= 1"). // At least 1 year passed + Where("DATE(start_time) < CURDATE()") // Only reset subscriptions that have started + + if isLeapYearCase { + // February 28th: handle both Feb 28 and Feb 29 subscriptions + query = query.Where("DAY(start_time) IN (28, 29)") + } else if isLastDayOfMonth { + // Last day of month: handle subscription start dates >= today + query = query.Where("DAY(start_time) >= ?", now.Day()) + } else { + // Normal case: exact day match + query = query.Where("DAY(start_time) = ?", now.Day()) + } + + err = query.Find(&usersYearReset).Error + if err != nil { + logger.Errorw("[ResetTraffic] Query yearly reset users failed", logger.Field("error", err.Error())) + return err + } + + if len(usersYearReset) > 0 { + logger.Infow("[ResetTraffic] Found users for yearly reset", + logger.Field("count", len(usersYearReset)), + logger.Field("userIds", usersYearReset)) + + // Reset upload and download traffic to zero + err = db.Model(&user.Subscribe{}).Where("`id` IN ?", usersYearReset). + Updates(map[string]interface{}{ + "upload": 0, + "download": 0, + }).Error + if err != nil { + logger.Errorw("[ResetTraffic] Failed to update yearly reset users", logger.Field("error", err.Error())) + return err + } + + logger.Infow("[ResetTraffic] Yearly reset completed", logger.Field("count", len(usersYearReset))) + } else { + logger.Infow("[ResetTraffic] No users found for yearly reset") + } + + return nil + }) + + if err != nil { + logger.Errorw("[ResetTraffic] Yearly reset transaction failed", logger.Field("error", err.Error())) + return err + } + + logger.Infow("[ResetTraffic] Yearly reset process completed") + return nil +} + +// getRetryCount retrieves the current retry count from Redis +func (l *ResetTrafficLogic) getRetryCount(ctx context.Context) int { + countStr, err := l.svc.Redis.Get(ctx, retryCountKey).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return 0 // No retry count found, start with 0 + } + logger.Errorw("[ResetTraffic] Failed to get retry count", logger.Field("error", err.Error())) + return 0 + } + + count, err := strconv.Atoi(countStr) + if err != nil { + logger.Errorw("[ResetTraffic] Invalid retry count format", logger.Field("value", countStr)) + return 0 + } + + return count +} + +// setRetryCount sets the retry count in Redis +func (l *ResetTrafficLogic) setRetryCount(ctx context.Context, count int) { + err := l.svc.Redis.Set(ctx, retryCountKey, count, 24*time.Hour).Err() + if err != nil { + logger.Errorw("[ResetTraffic] Failed to set retry count", + logger.Field("count", count), + logger.Field("error", err.Error())) + } +} + +// clearRetryCount removes the retry count from Redis +func (l *ResetTrafficLogic) clearRetryCount(ctx context.Context) { + err := l.svc.Redis.Del(ctx, retryCountKey).Err() + if err != nil { + logger.Errorw("[ResetTraffic] Failed to clear retry count", logger.Field("error", err.Error())) + } +} + +// acquireLock attempts to acquire a distributed lock +func (l *ResetTrafficLogic) acquireLock(ctx context.Context) bool { + result := l.svc.Redis.SetNX(ctx, lockKey, "locked", lockTimeout) + acquired, err := result.Result() + if err != nil { + logger.Errorw("[ResetTraffic] Failed to acquire lock", logger.Field("error", err.Error())) + return false + } + + if acquired { + logger.Infow("[ResetTraffic] Lock acquired successfully") + } else { + logger.Infow("[ResetTraffic] Lock already exists, another task is running") + } + + return acquired +} + +// releaseLock releases the distributed lock +func (l *ResetTrafficLogic) releaseLock(ctx context.Context) { + err := l.svc.Redis.Del(ctx, lockKey).Err() + if err != nil { + logger.Errorw("[ResetTraffic] Failed to release lock", logger.Field("error", err.Error())) + } else { + logger.Infow("[ResetTraffic] Lock released successfully") + } +} + +// isRetryableError determines if an error is retryable +func (l *ResetTrafficLogic) isRetryableError(err error) bool { + if err == nil { + return false + } + + errorMessage := strings.ToLower(err.Error()) + + // Network and connection errors (retryable) + retryableErrors := []string{ + "connection refused", + "connection reset", + "connection timeout", + "network", + "timeout", + "dial", + "context deadline exceeded", + "temporary failure", + "server error", + "service unavailable", + "internal server error", + "database is locked", + "too many connections", + "deadlock", + "lock wait timeout", + } + + // Database constraint errors (non-retryable) + nonRetryableErrors := []string{ + "foreign key constraint", + "unique constraint", + "check constraint", + "not null constraint", + "invalid input syntax", + "column does not exist", + "table does not exist", + "permission denied", + "access denied", + "authentication failed", + "invalid credentials", + } + + // Check for non-retryable errors first + for _, nonRetryable := range nonRetryableErrors { + if strings.Contains(errorMessage, nonRetryable) { + logger.Infow("[ResetTraffic] Non-retryable error detected", + logger.Field("error", err.Error()), + logger.Field("pattern", nonRetryable)) + return false + } + } + + // Check for retryable errors + for _, retryable := range retryableErrors { + if strings.Contains(errorMessage, retryable) { + logger.Infow("[ResetTraffic] Retryable error detected", + logger.Field("error", err.Error()), + logger.Field("pattern", retryable)) + return true + } + } + + // Default: treat unknown errors as retryable, but log for analysis + logger.Infow("[ResetTraffic] Unknown error type, treating as retryable", + logger.Field("error", err.Error())) + return true +} diff --git a/queue/types/scheduler.go b/queue/types/scheduler.go index 2ae3e1b..26a32f0 100644 --- a/queue/types/scheduler.go +++ b/queue/types/scheduler.go @@ -3,4 +3,5 @@ package types const ( SchedulerCheckSubscription = "scheduler:check:subscription" SchedulerTotalServerData = "scheduler:total:server" + SchedulerResetTraffic = "scheduler:reset:traffic" ) diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go index bdcfeac..69a4572 100644 --- a/scheduler/scheduler.go +++ b/scheduler/scheduler.go @@ -34,6 +34,11 @@ func (m *Service) Start() { if _, err := m.server.Register("@every 180s", totalServerDataTask); err != nil { logger.Errorf("register total server data task failed: %s", err.Error()) } + // schedule reset traffic task: every 24 hours + resetTrafficTask := asynq.NewTask(types.SchedulerResetTraffic, nil) + if _, err := m.server.Register("@every 24h", resetTrafficTask); err != nil { + logger.Errorf("register reset traffic task failed: %s", err.Error()) + } if err := m.server.Run(); err != nil { logger.Errorf("run scheduler failed: %s", err.Error())