This commit is contained in:
shanshanzhong 2025-08-12 07:52:04 -07:00
commit e898e6afbe
59 changed files with 2869 additions and 908 deletions

View File

@ -89,16 +89,20 @@ type (
CreateRuleGroupRequest { CreateRuleGroupRequest {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Icon string `json:"icon"` Icon string `json:"icon"`
Type string `json:"type"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Rules string `json:"rules"` Rules string `json:"rules"`
Default bool `json:"default"`
Enable bool `json:"enable"` Enable bool `json:"enable"`
} }
UpdateRuleGroupRequest { UpdateRuleGroupRequest {
Id int64 `json:"id" validate:"required"` Id int64 `json:"id" validate:"required"`
Icon string `json:"icon"` Icon string `json:"icon"`
Type string `json:"type"`
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Rules string `json:"rules"` Rules string `json:"rules"`
Default bool `json:"default"`
Enable bool `json:"enable"` Enable bool `json:"enable"`
} }
DeleteRuleGroupRequest { DeleteRuleGroupRequest {

View File

@ -153,9 +153,10 @@ type (
NodePushInterval int64 `json:"node_push_interval"` NodePushInterval int64 `json:"node_push_interval"`
} }
InviteConfig { InviteConfig {
ForcedInvite bool `json:"forced_invite"` ForcedInvite bool `json:"forced_invite"`
ReferralPercentage int64 `json:"referral_percentage"` FirstPurchasePercentage int64 `json:"first_purchase_percentage"`
OnlyFirstPurchase bool `json:"only_first_purchase"` FirstYearlyPurchasePercentage int64 `json:"first_yearly_purchase_percentage"`
NonFirstPurchasePercentage int64 `json:"non_first_purchase_percentage"`
} }
TelegramConfig { TelegramConfig {
TelegramBotToken string `json:"telegram_bot_token"` TelegramBotToken string `json:"telegram_bot_token"`
@ -515,9 +516,11 @@ type (
Id int64 `json:"id"` Id int64 `json:"id"`
Icon string `json:"icon"` Icon string `json:"icon"`
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Type string `json:"type"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Rules string `json:"rules"` Rules string `json:"rules"`
Enable bool `json:"enable"` Enable bool `json:"enable"`
Default bool `json:"default"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }

View File

@ -1,5 +1,6 @@
Host: 0.0.0.0 Host: 0.0.0.0
Port: 8080 Port: 8080
<<<<<<< HEAD
TLS: TLS:
Enable: false Enable: false
CertFile: "" CertFile: ""
@ -37,3 +38,41 @@ Redis:
Host: redis:6379 Host: redis:6379
Pass: Pass:
DB: 0 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

8
go.mod
View File

@ -56,6 +56,7 @@ require (
) )
require ( require (
github.com/Masterminds/sprig/v3 v3.3.0
github.com/fatih/color v1.18.0 github.com/fatih/color v1.18.0
github.com/goccy/go-json v0.10.4 github.com/goccy/go-json v0.10.4
github.com/golang-migrate/migrate/v4 v4.18.2 github.com/golang-migrate/migrate/v4 v4.18.2
@ -66,7 +67,10 @@ require (
require ( require (
cloud.google.com/go/compute/metadata v0.6.0 // indirect cloud.google.com/go/compute/metadata v0.6.0 // indirect
dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.1.0 // 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/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/endpoint-util v1.1.0 // 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/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // 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/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // 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/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/openzipkin/zipkin-go v0.4.2 // indirect github.com/openzipkin/zipkin-go v0.4.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/robfig/cron/v3 v3.0.1 // 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/ncrypto v1.0.4 // indirect
github.com/smartwalle/ngx v1.0.9 // indirect github.com/smartwalle/ngx v1.0.9 // indirect
github.com/smartwalle/nsign v1.0.9 // indirect github.com/smartwalle/nsign v1.0.9 // indirect

16
go.sum
View File

@ -1,6 +1,8 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 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 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= 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 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= 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/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 h1:RDkg3pyE1qGbBpRWmvSN9RNZC5nUrOaEPiEpEb8y2f0=
github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f/go.mod h1:zA7AF9RTfpluCfz0omI4t5KCMaWHUMicsZoMccnaT44= 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 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 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= 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/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 h1:+5iIEAyA9K/lcSPvx3qoPtsKJeKI5u9aOIvUmSsazEw=
github.com/hibiken/asynq v0.24.1/go.mod h1:u5qVeSbrnfT+vtG5Mq8ZPzQu/BmCKMHvTGb91uy9Tts= 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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= 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.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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 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= 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 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 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/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 h1:i1VwJeu70EmwpsXXz6GZZnMAtRx5MTfn2dPoql/L3zE=
github.com/smartwalle/alipay/v3 v3.2.23/go.mod h1:lVqFiupPf8YsAXaq5JXcwqnOUC2MCF+2/5vub+RlagE= 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= github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8=

View File

@ -17,13 +17,11 @@ func Email(ctx *svc.ServiceContext) {
logger.Debug("Email config initialization") logger.Debug("Email config initialization")
method, err := ctx.AuthModel.FindOneByMethod(context.Background(), "email") method, err := ctx.AuthModel.FindOneByMethod(context.Background(), "email")
if err != nil { 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 cfg config.EmailConfig
var emailConfig = new(auth.EmailAuthConfig) var emailConfig = new(auth.EmailAuthConfig)
if err := emailConfig.Unmarshal(method.Config); err != nil { emailConfig.Unmarshal(method.Config)
panic(fmt.Sprintf("failed to unmarshal email auth config: %v", err.Error()))
}
tool.DeepCopy(&cfg, emailConfig) tool.DeepCopy(&cfg, emailConfig)
cfg.Enable = *method.Enabled cfg.Enable = *method.Enabled
value, _ := json.Marshal(emailConfig.PlatformConfig) value, _ := json.Marshal(emailConfig.PlatformConfig)

View File

@ -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'), (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'), (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'), (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'), (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'), (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'), (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'), '2025-04-22 14:25:16.640'),
(23, 'invite', 'ForcedInvite', 'false', 'bool', 'Forced invite', '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'), '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'), '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'), '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'), '2025-04-22 14:25:16.640'),
(27, 'register', 'EnableTrial', 'false', 'bool', 'is enable trial', '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'), '2025-04-22 14:25:16.640'),

View File

@ -0,0 +1,3 @@
ALTER TABLE `server_rule_group`
DROP COLUMN `default`,
DROP COLUMN `type`;

View File

@ -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';

View File

@ -3,7 +3,6 @@ package initialize
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
@ -21,9 +20,7 @@ func Mobile(ctx *svc.ServiceContext) {
} }
var cfg config.MobileConfig var cfg config.MobileConfig
var mobileConfig auth.MobileAuthConfig var mobileConfig auth.MobileAuthConfig
if err := mobileConfig.Unmarshal(method.Config); err != nil { mobileConfig.Unmarshal(method.Config)
panic(fmt.Sprintf("failed to unmarshal mobile auth config: %v", err.Error()))
}
tool.DeepCopy(&cfg, mobileConfig) tool.DeepCopy(&cfg, mobileConfig)
cfg.Enable = *method.Enabled cfg.Enable = *method.Enabled
value, _ := json.Marshal(mobileConfig.PlatformConfig) value, _ := json.Marshal(mobileConfig.PlatformConfig)

View File

@ -119,9 +119,10 @@ type File struct {
} }
type InviteConfig struct { type InviteConfig struct {
ForcedInvite bool `yaml:"ForcedInvite" default:"false"` ForcedInvite bool `yaml:"ForcedInvite" default:"false"`
ReferralPercentage int64 `yaml:"ReferralPercentage" default:"0"` FirstPurchasePercentage int64 `yaml:"FirstPurchasePercentage" default:"20"`
OnlyFirstPurchase bool `yaml:"OnlyFirstPurchase" default:"false"` FirstYearlyPurchasePercentage int64 `yaml:"FirstYearlyPurchasePercentage" default:"25"`
NonFirstPurchasePercentage int64 `yaml:"NonFirstPurchasePercentage" default:"10"`
} }
type Telegram struct { type Telegram struct {

View File

@ -8,7 +8,7 @@ import (
"github.com/perfect-panel/server/pkg/result" "github.com/perfect-panel/server/pkg/result"
) )
// Create rule group // CreateRuleGroupHandler Create rule group
func CreateRuleGroupHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { func CreateRuleGroupHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) { return func(c *gin.Context) {
var req types.CreateRuleGroupRequest var req types.CreateRuleGroupRequest

View File

@ -18,7 +18,7 @@ type GetAuthMethodConfigLogic struct {
svcCtx *svc.ServiceContext svcCtx *svc.ServiceContext
} }
// Get auth method config // NewGetAuthMethodConfigLogic Get auth method config
func NewGetAuthMethodConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAuthMethodConfigLogic { func NewGetAuthMethodConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAuthMethodConfigLogic {
return &GetAuthMethodConfigLogic{ return &GetAuthMethodConfigLogic{
Logger: logger.WithContext(ctx), Logger: logger.WithContext(ctx),

View File

@ -40,34 +40,32 @@ func (l *UpdateAuthMethodConfigLogic) UpdateAuthMethodConfig(req *types.UpdateAu
tool.DeepCopy(method, req) tool.DeepCopy(method, req)
if req.Config != nil { if req.Config != nil {
if value, ok := req.Config.(map[string]interface{}); ok { _, exist := req.Config.(map[string]interface{})
if req.Method == "email" && value["verify_email_template"] == "" { if !exist {
value["verify_email_template"] = email.DefaultEmailVerifyTemplate req.Config = initializePlatformConfig(req.Method).(string)
}
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
}
} }
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) bytes, err := json.Marshal(req.Config)
if err != nil { if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "marshal config failed: %v", err.Error()) return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "marshal config failed: %v", err.Error())
} }
method.Config = string(bytes) method.Config = string(bytes)
} else {
// initialize platform config
method.Config = initializePlatformConfig(req.Method).(string)
} }
err = l.svcCtx.AuthModel.Update(l.ctx, method) err = l.svcCtx.AuthModel.Update(l.ctx, method)
if err != nil { if err != nil {
@ -124,3 +122,26 @@ func validatePlatformConfig(platform string, cfg map[string]interface{}) (interf
} }
return config, nil 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
}

View File

@ -53,17 +53,26 @@ func (l *CreateRuleGroupLogic) CreateRuleGroup(req *types.CreateRuleGroupRequest
if err != nil { if err != nil {
return err return err
} }
info := &server.RuleGroup{
err = l.svcCtx.ServerModel.InsertRuleGroup(l.ctx, &server.RuleGroup{ Name: req.Name,
Name: req.Name, Icon: req.Icon,
Icon: req.Icon, Type: req.Type,
Tags: tool.StringSliceToString(req.Tags), Tags: tool.StringSliceToString(req.Tags),
Rules: strings.Join(rs, "\n"), Rules: strings.Join(rs, "\n"),
Enable: req.Enable, Default: req.Default,
}) Enable: req.Enable,
}
err = l.svcCtx.ServerModel.InsertRuleGroup(l.ctx, info)
if err != nil { if err != nil {
l.Errorw("[CreateRuleGroup] Insert Database Error: ", logger.Field("error", err.Error())) 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) 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 return nil
} }

View File

@ -38,9 +38,11 @@ func (l *GetRuleGroupListLogic) GetRuleGroupList() (resp *types.GetRuleGroupResp
Id: v.Id, Id: v.Id,
Icon: v.Icon, Icon: v.Icon,
Name: v.Name, Name: v.Name,
Type: v.Type,
Tags: strings.Split(v.Tags, ","), Tags: strings.Split(v.Tags, ","),
Rules: v.Rules, Rules: v.Rules,
Enable: v.Enable, Enable: v.Enable,
Default: v.Default,
CreatedAt: v.CreatedAt.UnixMilli(), CreatedAt: v.CreatedAt.UnixMilli(),
UpdatedAt: v.UpdatedAt.UnixMilli(), UpdatedAt: v.UpdatedAt.UnixMilli(),
} }

View File

@ -36,15 +36,23 @@ func (l *UpdateRuleGroupLogic) UpdateRuleGroup(req *types.UpdateRuleGroupRequest
return err return err
} }
err = l.svcCtx.ServerModel.UpdateRuleGroup(l.ctx, &server.RuleGroup{ err = l.svcCtx.ServerModel.UpdateRuleGroup(l.ctx, &server.RuleGroup{
Id: req.Id, Id: req.Id,
Icon: req.Icon, Icon: req.Icon,
Name: req.Name, Type: req.Type,
Tags: tool.StringSliceToString(req.Tags), Name: req.Name,
Rules: strings.Join(rs, "\n"), Tags: tool.StringSliceToString(req.Tags),
Enable: req.Enable, Rules: strings.Join(rs, "\n"),
Default: req.Default,
Enable: req.Enable,
}) })
if err != nil { if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error()) 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 return nil
} }

View File

@ -44,6 +44,7 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
userInfo.Balance = req.Balance userInfo.Balance = req.Balance
userInfo.GiftAmount = req.GiftAmount userInfo.GiftAmount = req.GiftAmount
userInfo.Commission = req.Commission userInfo.Commission = req.Commission
// 手动设置 IsAdmin 字段,因为类型不匹配(*bool vs bool
userInfo.IsAdmin = &req.IsAdmin userInfo.IsAdmin = &req.IsAdmin
userInfo.Enable = &req.Enable userInfo.Enable = &req.Enable

View File

@ -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)) 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()) 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{ err = l.svcCtx.UserModel.UpdateSubscribe(l.ctx, &user.Subscribe{
Id: req.UserSubscribeId, Id: userSub.Id,
UserId: userSub.UserId, UserId: userSub.UserId,
OrderId: userSub.OrderId, OrderId: userSub.OrderId,
SubscribeId: req.SubscribeId, SubscribeId: req.SubscribeId,

View File

@ -120,10 +120,11 @@ func (l *GetStatLogic) GetStat() (resp *types.GetStatResponse, err error) {
protocol = append(protocol, p) protocol = append(protocol, p)
} }
resp = &types.GetStatResponse{ resp = &types.GetStatResponse{
User: u, User: u,
Node: n, Node: n,
Country: int64(len(country)), Country: int64(len(country)),
Protocol: protocol, Protocol: protocol,
OnlineDevice: l.svcCtx.DeviceManager.GetOnlineDeviceCount(),
} }
val, _ := json.Marshal(*resp) val, _ := json.Marshal(*resp)
_ = l.svcCtx.Redis.Set(l.ctx, config.CommonStatCacheKey, string(val), time.Duration(3600)*time.Second).Err() _ = l.svcCtx.Redis.Set(l.ctx, config.CommonStatCacheKey, string(val), time.Duration(3600)*time.Second).Err()

View File

@ -82,7 +82,7 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
return err return err
} }
deduction := userInfo.GiftAmount + orderInfo.GiftAmount 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 { if err != nil {
l.Errorw("[CloseOrder] Refund deduction amount failed", l.Errorw("[CloseOrder] Refund deduction amount failed",
logger.Field("error", err.Error()), logger.Field("error", err.Error()),

View File

@ -10,5 +10,6 @@ func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64) float64
finalDiscount = discount.Discount finalDiscount = discount.Discount
} }
} }
return float64(finalDiscount) / float64(100) return float64(finalDiscount) / float64(100)
} }

View File

@ -24,7 +24,8 @@ type PreCreateOrderLogic struct {
svcCtx *svc.ServiceContext 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 { func NewPreCreateOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PreCreateOrderLogic {
return &PreCreateOrderLogic{ return &PreCreateOrderLogic{
Logger: logger.WithContext(ctx), 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) { func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (resp *types.PreOrderResponse, err error) {
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok { if !ok {
logger.Error("current user is not found in context") logger.Error("current user is not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") 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 // find subscribe plan
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, req.SubscribeId) sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, req.SubscribeId)
if err != nil { if err != nil {
@ -52,6 +62,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
discount = getDiscount(dis, req.Quantity) discount = getDiscount(dis, req.Quantity)
} }
price := sub.UnitPrice * req.Quantity price := sub.UnitPrice * req.Quantity
amount := int64(float64(price) * discount) amount := int64(float64(price) * discount)
discountAmount := price - amount discountAmount := price - amount
var couponAmount int64 var couponAmount int64
@ -72,7 +83,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
}) })
if err != nil { 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()) 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 { if req.Payment != 0 {
payment, err := l.svcCtx.PaymentModel.FindOne(l.ctx, req.Payment) payment, err := l.svcCtx.PaymentModel.FindOne(l.ctx, req.Payment)
if err != nil { 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()) return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find payment method error: %v", err.Error())
} }
// Calculate the handling fee // Calculate the handling fee

View File

@ -31,7 +31,8 @@ const (
CloseOrderTimeMinutes = 15 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 { func NewPurchaseLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PurchaseLogic {
return &PurchaseLogic{ return &PurchaseLogic{
Logger: logger.WithContext(ctx), 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) { func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.PurchaseOrderResponse, err error) {
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) 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") logger.Error("current user is not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") 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 // find user subscription
userSub, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, u.Id) userSub, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, u.Id)
if err != nil { if err != nil {
@ -142,7 +152,7 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
// find payment method // find payment method
payment, err := l.svcCtx.PaymentModel.FindOne(l.ctx, req.Payment) payment, err := l.svcCtx.PaymentModel.FindOne(l.ctx, req.Payment)
if err != nil { 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()) return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find payment method error: %v", err.Error())
} }
var feeAmount int64 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 // update user deduction && Pre deduction ,Return after canceling the order
if orderInfo.GiftAmount > 0 { if orderInfo.GiftAmount > 0 {
// update user deduction && Pre deduction ,Return after canceling the order // update user deduction && Pre deduction ,Return after canceling the order
if e := l.svcCtx.UserModel.Update(l.ctx, u, db); err != nil { if e := l.svcCtx.UserModel.Update(l.ctx, u, db); e != nil {
l.Errorw("[Purchase] Database update error", logger.Field("error", err.Error()), logger.Field("user", u)) l.Errorw("[Purchase] Database update error", logger.Field("error", e.Error()), logger.Field("user", u))
return e return e
} }
// create deduction record // 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 { if e := db.Model(&user.GiftAmountLog{}).Create(&giftAmountLog).Error; e != nil {
l.Errorw("[Purchase] Database insert error", l.Errorw("[Purchase] Database insert error",
logger.Field("error", err.Error()), logger.Field("error", e.Error()),
logger.Field("deductionLog", giftAmountLog), logger.Field("deductionLog", giftAmountLog),
) )
return e return e
@ -213,14 +223,14 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
} }
val, err := json.Marshal(payload) val, err := json.Marshal(payload)
if err != nil { 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)) task := asynq.NewTask(queue.DeferCloseOrder, val, asynq.MaxRetry(3))
taskInfo, err := l.svcCtx.Queue.Enqueue(task, asynq.ProcessIn(CloseOrderTimeMinutes*time.Minute)) taskInfo, err := l.svcCtx.Queue.Enqueue(task, asynq.ProcessIn(CloseOrderTimeMinutes*time.Minute))
if err != nil { 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 { } 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{ return &types.PurchaseOrderResponse{

View File

@ -27,7 +27,7 @@ type RenewalLogic struct {
svcCtx *svc.ServiceContext 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 { func NewRenewalLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RenewalLogic {
return &RenewalLogic{ return &RenewalLogic{
Logger: logger.WithContext(ctx), 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) { func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.RenewalOrderResponse, err error) {
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok { if !ok {
logger.Error("current user is not found in context") logger.Error("current user is not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") 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() orderNo := tool.GenerateTradeNo()
// find user subscribe // find user subscribe
userSubscribe, err := l.svcCtx.UserModel.FindOneUserSubscribe(l.ctx, req.UserSubscribeID) 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) payment, err := l.svcCtx.PaymentModel.FindOne(l.ctx, req.Payment)
if err != nil { if err != nil {
l.Errorw("[Renewal] Database query error", logger.Field("error", err.Error()), logger.Field("payment", req.Payment)) 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 amount -= coupon
@ -109,8 +116,8 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
if u.GiftAmount > 0 { if u.GiftAmount > 0 {
if u.GiftAmount >= amount { if u.GiftAmount >= amount {
deductionAmount = amount deductionAmount = amount
u.GiftAmount -= deductionAmount
amount = 0 amount = 0
u.GiftAmount -= amount
} else { } else {
deductionAmount = u.GiftAmount deductionAmount = u.GiftAmount
amount -= u.GiftAmount amount -= u.GiftAmount
@ -152,7 +159,7 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
if orderInfo.GiftAmount > 0 { if orderInfo.GiftAmount > 0 {
// update user deduction && Pre deduction ,Return after canceling the order // update user deduction && Pre deduction ,Return after canceling the order
if err := l.svcCtx.UserModel.Update(l.ctx, u, db); err != nil { 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 return err
} }
// create deduction record // create deduction record

View File

@ -43,10 +43,26 @@ func (l *GetSubscriptionLogic) GetSubscription() (resp *types.GetSubscriptionRes
tool.DeepCopy(&sub, item) tool.DeepCopy(&sub, item)
if item.Discount != "" { if item.Discount != "" {
var discount []types.SubscribeDiscount var discount []types.SubscribeDiscount
_ = json.Unmarshal([]byte(item.Discount), &discount) _ = json.Unmarshal([]byte(item.Discount), &discount)
sub.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 list[i] = sub
} }
resp.List = list resp.List = list

View File

@ -122,6 +122,7 @@ func (l *PurchaseCheckoutLogic) PurchaseCheckout(req *types.CheckoutOrderRequest
if err = l.balancePayment(userInfo, orderInfo); err != nil { if err = l.balancePayment(userInfo, orderInfo); err != nil {
return nil, err return nil, err
} }
resp = &types.CheckoutOrderResponse{ resp = &types.CheckoutOrderResponse{
Type: "balance", // Payment completed immediately 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 // balancePayment processes balance payment with gift amount priority logic
// It prioritizes using gift amount first, then regular balance, and creates proper audit logs // 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 { func (l *PurchaseCheckoutLogic) balancePayment(u *user.User, o *order.Order) error {
var userInfo user.User
var err error
if o.Amount == 0 { if o.Amount == 0 {
// No payment required for zero-amount orders // 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 // Retrieve latest user information with row-level locking
err := db.Model(&user.User{}).Where("id = ?", u.Id).First(&userInfo).Error err := db.Model(&user.User{}).Where("id = ?", u.Id).First(&userInfo).Error
if err != nil { if err != nil {
@ -420,6 +435,7 @@ func (l *PurchaseCheckoutLogic) balancePayment(u *user.User, o *order.Order) err
return err return err
} }
activation:
// Enqueue order activation task for immediate processing // Enqueue order activation task for immediate processing
payload := queueType.ForthwithActivateOrderPayload{ payload := queueType.ForthwithActivateOrderPayload{
OrderNo: o.OrderNo, OrderNo: o.OrderNo,

View File

@ -40,6 +40,7 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList() (resp *types.QuerySubscri
list := make([]types.Subscribe, len(data)) list := make([]types.Subscribe, len(data))
for i, item := range data { for i, item := range data {
var sub types.Subscribe var sub types.Subscribe
tool.DeepCopy(&sub, item) tool.DeepCopy(&sub, item)
if item.Discount != "" { if item.Discount != "" {
var discount []types.SubscribeDiscount var discount []types.SubscribeDiscount
@ -48,6 +49,15 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList() (resp *types.QuerySubscri
list[i] = sub list[i] = sub
} }
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 resp.List = list
return return

View File

@ -52,8 +52,10 @@ func (l *BindOAuthCallbackLogic) BindOAuthCallback(req *types.BindOAuthCallbackR
err = l.google(req) err = l.google(req)
case "apple": case "apple":
err = l.apple(req) err = l.apple(req)
case "telegram":
err = l.telegram(req)
default: 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) return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "oauth login method not support: %v", req.Method)
} }
if err != nil { if err != nil {
@ -212,3 +214,7 @@ func (l *BindOAuthCallbackLogic) apple(req *types.BindOAuthCallbackRequest) erro
} }
return nil return nil
} }
func (l *BindOAuthCallbackLogic) telegram(req *types.BindOAuthCallbackRequest) error {
return nil
}

View File

@ -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) sub.ResetTime = calculateNextResetTime(&sub)
resp.List = append(resp.List, sub) resp.List = append(resp.List, sub)
} }

View File

@ -48,7 +48,7 @@ func (l *UpdateBindEmailLogic) UpdateBindEmail(req *types.UpdateBindEmailRequest
if m.Id > 0 { if m.Id > 0 {
return errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "email already bind") return errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "email already bind")
} }
if errors.Is(err, gorm.ErrRecordNotFound) { if method.Id == 0 {
method = &user.AuthMethods{ method = &user.AuthMethods{
UserId: u.Id, UserId: u.Id,
AuthType: "email", AuthType: "email",

View File

@ -9,6 +9,7 @@ import (
"github.com/perfect-panel/server/pkg/adapter" "github.com/perfect-panel/server/pkg/adapter"
"github.com/perfect-panel/server/pkg/adapter/shadowrocket" "github.com/perfect-panel/server/pkg/adapter/shadowrocket"
"github.com/perfect-panel/server/pkg/adapter/surfboard" "github.com/perfect-panel/server/pkg/adapter/surfboard"
"github.com/perfect-panel/server/pkg/adapter/surge"
"github.com/perfect-panel/server/internal/model/server" "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) serverIds := tool.StringToInt64Slice(subDetails.Server)
groupIds := tool.StringToInt64Slice(subDetails.ServerGroup) 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) 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) 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)) l.Debugf("[Query Subscribe]found servers: %v", len(servers))
if err != nil { if err != nil {
@ -235,8 +273,9 @@ func (l *SubscribeLogic) buildClientConfig(req *types.SubscribeRequest, userSub
}) })
case "loon": case "loon":
resp = proxyManager.BuildLoon(userSub.UUID) resp = proxyManager.BuildLoon(userSub.UUID)
l.setLoonHeaders()
case "surfboard": case "surfboard":
subsURL := l.getSubscribeURL(userSub.Token) subsURL := l.getSubscribeURL(userSub.Token, "surfboard")
resp = proxyManager.BuildSurfboard(l.svc.Config.Site.SiteName, surfboard.UserInfo{ resp = proxyManager.BuildSurfboard(l.svc.Config.Site.SiteName, surfboard.UserInfo{
Upload: userSub.Upload, Upload: userSub.Upload,
Download: userSub.Download, Download: userSub.Download,
@ -248,7 +287,17 @@ func (l *SubscribeLogic) buildClientConfig(req *types.SubscribeRequest, userSub
l.setSurfboardHeaders() l.setSurfboardHeaders()
case "v2rayn": case "v2rayn":
resp = proxyManager.BuildV2rayN(userSub.UUID) 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: default:
resp = proxyManager.BuildGeneral(userSub.UUID) resp = proxyManager.BuildGeneral(userSub.UUID)
} }
@ -270,14 +319,24 @@ func (l *SubscribeLogic) setSurfboardHeaders() {
l.ctx.Header("Content-Type", "application/octet-stream; charset=UTF-8") 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 { if l.svc.Config.Subscribe.PanDomain {
return fmt.Sprintf("https://%s", l.ctx.Request.Host) return fmt.Sprintf("https://%s", l.ctx.Request.Host)
} }
if l.svc.Config.Subscribe.SubscribeDomain != "" { if l.svc.Config.Subscribe.SubscribeDomain != "" {
domains := strings.Split(l.svc.Config.Subscribe.SubscribeDomain, "\n") 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) return fmt.Sprintf("https://%s%s?token=%s&flag=surfboard", l.ctx.Request.Host, l.svc.Config.Subscribe.SubscribePath, token)

View File

@ -3,6 +3,8 @@ package auth
import ( import (
"encoding/json" "encoding/json"
"time" "time"
"github.com/perfect-panel/server/pkg/email"
) )
type Auth struct { type Auth struct {
@ -124,15 +126,55 @@ type EmailAuthConfig struct {
} }
func (l *EmailAuthConfig) Marshal() string { 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) bytes, err := json.Marshal(l)
if err != nil { 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) return string(bytes)
} }
func (l *EmailAuthConfig) Unmarshal(data string) error { func (l *EmailAuthConfig) Unmarshal(data string) {
return json.Unmarshal([]byte(data), &l) 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 // SMTPConfig Email SMTP configuration
@ -167,13 +209,28 @@ type MobileAuthConfig struct {
func (l *MobileAuthConfig) Marshal() string { func (l *MobileAuthConfig) Marshal() string {
bytes, err := json.Marshal(l) bytes, err := json.Marshal(l)
if err != nil { 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) return string(bytes)
} }
func (l *MobileAuthConfig) Unmarshal(data string) error { func (l *MobileAuthConfig) Unmarshal(data string) {
return json.Unmarshal([]byte(data), &l) 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 { type AlibabaCloudConfig struct {

View File

@ -32,6 +32,8 @@ type customServerLogicModel interface {
QueryAllRuleGroup(ctx context.Context) ([]*RuleGroup, error) QueryAllRuleGroup(ctx context.Context) ([]*RuleGroup, error)
FindServersByTag(ctx context.Context, tag string) ([]*Server, error) FindServersByTag(ctx context.Context, tag string) ([]*Server, error)
FindServerTags(ctx context.Context) ([]string, error) FindServerTags(ctx context.Context) ([]string, error)
SetDefaultRuleGroup(ctx context.Context, id int64) error
} }
var ( var (
@ -275,3 +277,16 @@ func (m *customServerModel) FindServersByTag(ctx context.Context, tag string) ([
}) })
return data, err 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))
}

View File

@ -9,9 +9,12 @@ import (
) )
const ( const (
RelayModeNone = "none" RelayModeNone = "none"
RelayModeAll = "all" RelayModeAll = "all"
RelayModeRandom = "random" RelayModeRandom = "random"
RuleGroupTypeReject = "reject"
RuleGroupTypeDefault = "default"
RuleGroupTypeDirect = "direct"
) )
type ServerFilter struct { type ServerFilter struct {
@ -178,9 +181,11 @@ type RuleGroup struct {
Id int64 `gorm:"primary_key"` Id int64 `gorm:"primary_key"`
Icon string `gorm:"type:MEDIUMTEXT;comment:Rule Group Icon"` Icon string `gorm:"type:MEDIUMTEXT;comment:Rule Group Icon"`
Name string `gorm:"type:varchar(100);not null;default:'';comment:Rule Group Name"` 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"` Tags string `gorm:"type:text;comment:Selected Node Tags"`
Rules string `gorm:"type:MEDIUMTEXT;comment:Rules"` Rules string `gorm:"type:MEDIUMTEXT;comment:Rules"`
Enable bool `gorm:"type:tinyint(1);not null;default:1;comment:Rule Group Enable"` 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"` CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"` UpdatedAt time.Time `gorm:"comment:Update Time"`
} }

View File

@ -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)). return conn.Where("`expire_time` > ? OR `finished_at` >= ? OR `expire_time` = ?", now, sevenDaysAgo, time.UnixMilli(0)).
Preload("Subscribe"). Preload("Subscribe").
Order("created_at DESC").
Find(&list).Error Find(&list).Error
}) })
return list, err return list, err

View File

@ -32,6 +32,11 @@ type Announcement struct {
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
type AnyTLS struct {
Port int `json:"port" validate:"required"`
SecurityConfig SecurityConfig `json:"security_config"`
}
type AppAuthCheckRequest struct { type AppAuthCheckRequest struct {
Method string `json:"method" validate:"required" validate:"required,oneof=device email mobile"` Method string `json:"method" validate:"required" validate:"required,oneof=device email mobile"`
Account string `json:"account"` Account string `json:"account"`
@ -441,11 +446,13 @@ type CreatePaymentMethodRequest struct {
} }
type CreateRuleGroupRequest struct { type CreateRuleGroupRequest struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Icon string `json:"icon"` Icon string `json:"icon"`
Tags []string `json:"tags"` Type string `json:"type"`
Rules string `json:"rules"` Tags []string `json:"tags"`
Enable bool `json:"enable"` Rules string `json:"rules"`
Default bool `json:"default"`
Enable bool `json:"enable"`
} }
type CreateSubscribeGroupRequest struct { type CreateSubscribeGroupRequest struct {
@ -468,6 +475,7 @@ type CreateSubscribeRequest struct {
GroupId int64 `json:"group_id"` GroupId int64 `json:"group_id"`
ServerGroup []int64 `json:"server_group"` ServerGroup []int64 `json:"server_group"`
Server []int64 `json:"server"` Server []int64 `json:"server"`
ServerCount int64 `json:"server_count"`
Show *bool `json:"show"` Show *bool `json:"show"`
Sell *bool `json:"sell"` Sell *bool `json:"sell"`
DeductionRatio int64 `json:"deduction_ratio"` DeductionRatio int64 `json:"deduction_ratio"`
@ -854,10 +862,11 @@ type GetServerUserListResponse struct {
} }
type GetStatResponse struct { type GetStatResponse struct {
User int64 `json:"user"` User int64 `json:"user"`
Node int64 `json:"node"` Node int64 `json:"node"`
Country int64 `json:"country"` Country int64 `json:"country"`
Protocol []string `json:"protocol"` Protocol []string `json:"protocol"`
OnlineDevice int64 `json:"online_device"`
} }
type GetSubscribeDetailsRequest struct { type GetSubscribeDetailsRequest struct {
@ -1037,9 +1046,10 @@ type Hysteria2 struct {
} }
type InviteConfig struct { type InviteConfig struct {
ForcedInvite bool `json:"forced_invite"` ForcedInvite bool `json:"forced_invite"`
ReferralPercentage int64 `json:"referral_percentage"` FirstPurchasePercentage int64 `json:"first_purchase_percentage"`
OnlyFirstPurchase bool `json:"only_first_purchase"` FirstYearlyPurchasePercentage int64 `json:"first_yearly_purchase_percentage"`
NonFirstPurchasePercentage int64 `json:"non_first_purchase_percentage"`
} }
type KickOfflineRequest struct { type KickOfflineRequest struct {
@ -1553,9 +1563,11 @@ type ServerRuleGroup struct {
Id int64 `json:"id"` Id int64 `json:"id"`
Icon string `json:"icon"` Icon string `json:"icon"`
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Type string `json:"type"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Rules string `json:"rules"` Rules string `json:"rules"`
Enable bool `json:"enable"` Enable bool `json:"enable"`
Default bool `json:"default"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
@ -1647,6 +1659,7 @@ type Subscribe struct {
GroupId int64 `json:"group_id"` GroupId int64 `json:"group_id"`
ServerGroup []int64 `json:"server_group"` ServerGroup []int64 `json:"server_group"`
Server []int64 `json:"server"` Server []int64 `json:"server"`
ServerCount int64 `json:"server_count"`
Show bool `json:"show"` Show bool `json:"show"`
Sell bool `json:"sell"` Sell bool `json:"sell"`
Sort int64 `json:"sort"` Sort int64 `json:"sort"`
@ -1955,12 +1968,14 @@ type UpdatePaymentMethodRequest struct {
} }
type UpdateRuleGroupRequest struct { type UpdateRuleGroupRequest struct {
Id int64 `json:"id" validate:"required"` Id int64 `json:"id" validate:"required"`
Icon string `json:"icon"` Icon string `json:"icon"`
Name string `json:"name" validate:"required"` Type string `json:"type"`
Tags []string `json:"tags"` Name string `json:"name" validate:"required"`
Rules string `json:"rules"` Tags []string `json:"tags"`
Enable bool `json:"enable"` Rules string `json:"rules"`
Default bool `json:"default"`
Enable bool `json:"enable"`
} }
type UpdateSubscribeGroupRequest struct { type UpdateSubscribeGroupRequest struct {

View File

@ -1,6 +1,8 @@
package adapter package adapter
import ( import (
"embed"
"github.com/perfect-panel/server/internal/model/server" "github.com/perfect-panel/server/internal/model/server"
"github.com/perfect-panel/server/pkg/adapter/clash" "github.com/perfect-panel/server/pkg/adapter/clash"
"github.com/perfect-panel/server/pkg/adapter/general" "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/shadowrocket"
"github.com/perfect-panel/server/pkg/adapter/singbox" "github.com/perfect-panel/server/pkg/adapter/singbox"
"github.com/perfect-panel/server/pkg/adapter/surfboard" "github.com/perfect-panel/server/pkg/adapter/surfboard"
"github.com/perfect-panel/server/pkg/adapter/surge"
"github.com/perfect-panel/server/pkg/adapter/v2rayn" "github.com/perfect-panel/server/pkg/adapter/v2rayn"
) )
//go:embed template/*
var TemplateFS embed.FS
var (
AutoSelect = "Auto - UrlTest"
)
type Config struct { type Config struct {
Nodes []*server.Server Nodes []*server.Server
Rules []*server.RuleGroup Rules []*server.RuleGroup
@ -25,62 +35,68 @@ type Adapter struct {
func NewAdapter(cfg *Config) *Adapter { func NewAdapter(cfg *Config) *Adapter {
// 转换服务器列表 // 转换服务器列表
proxies := adapterProxies(cfg.Nodes) proxies, nodes, tags := adapterProxies(cfg.Nodes)
// 生成代理组
proxyGroup, region := generateProxyGroup(proxies)
// 转换规则组 // 转换规则组
g, r := adapterRules(cfg.Rules) g, r, d := adapterRules(cfg.Rules)
if d == "" {
// 加入兜底节点 d = AutoSelect
for i, group := range g {
if len(group.Proxies) == 0 {
g[i].Proxies = append([]string{"DIRECT"}, region...)
}
} }
// 生成默认代理组
proxyGroup := append(generateDefaultGroup(), g...)
// 合并代理组 // 合并代理组
proxyGroup = RemoveEmptyGroup(append(proxyGroup, g...)) proxyGroup = SortGroups(proxyGroup, nodes, tags, d)
// 处理标签
proxyGroup = adapterTags(cfg.Tags, proxyGroup)
return &Adapter{ return &Adapter{
Adapter: proxy.Adapter{ Adapter: proxy.Adapter{
Proxies: proxies, Proxies: proxies,
Group: proxyGroup, Group: proxyGroup,
Rules: r, Rules: r,
Region: region, Nodes: nodes,
Default: d,
TemplateFS: &TemplateFS,
}, },
} }
} }
// BuildClash generates a Clash configuration for the given UUID.
func (m *Adapter) BuildClash(uuid string) ([]byte, error) { func (m *Adapter) BuildClash(uuid string) ([]byte, error) {
client := clash.NewClash(m.Adapter) client := clash.NewClash(m.Adapter)
return client.Build(uuid) return client.Build(uuid)
} }
// BuildGeneral generates a general configuration for the given UUID.
func (m *Adapter) BuildGeneral(uuid string) []byte { func (m *Adapter) BuildGeneral(uuid string) []byte {
return general.GenerateBase64General(m.Proxies, uuid) return general.GenerateBase64General(m.Proxies, uuid)
} }
// BuildLoon generates a Loon configuration for the given UUID.
func (m *Adapter) BuildLoon(uuid string) []byte { func (m *Adapter) BuildLoon(uuid string) []byte {
return loon.BuildLoon(m.Proxies, uuid) return loon.BuildLoon(m.Proxies, uuid)
} }
// BuildQuantumultX generates a Quantumult X configuration for the given UUID.
func (m *Adapter) BuildQuantumultX(uuid string) string { func (m *Adapter) BuildQuantumultX(uuid string) string {
return quantumultx.BuildQuantumultX(m.Proxies, uuid) return quantumultx.BuildQuantumultX(m.Proxies, uuid)
} }
// BuildSingbox generates a Singbox configuration for the given UUID.
func (m *Adapter) BuildSingbox(uuid string) ([]byte, error) { func (m *Adapter) BuildSingbox(uuid string) ([]byte, error) {
return singbox.BuildSingbox(m.Adapter, uuid) return singbox.BuildSingbox(m.Adapter, uuid)
} }
func (m *Adapter) BuildShadowrocket(uuid string, userInfo shadowrocket.UserInfo) []byte { func (m *Adapter) BuildShadowrocket(uuid string, userInfo shadowrocket.UserInfo) []byte {
return shadowrocket.BuildShadowrocket(m.Proxies, uuid, userInfo) 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 { func (m *Adapter) BuildSurfboard(siteName string, user surfboard.UserInfo) []byte {
return surfboard.BuildSurfboard(m.Adapter, siteName, user) return surfboard.BuildSurfboard(m.Adapter, siteName, user)
} }
// BuildV2rayN generates a V2rayN configuration for the given UUID.
func (m *Adapter) BuildV2rayN(uuid string) []byte { func (m *Adapter) BuildV2rayN(uuid string) []byte {
return v2rayn.NewV2rayN(m.Adapter).Build(uuid) 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)
}

View File

@ -1,8 +1,11 @@
package clash package clash
import ( import (
"bytes"
"fmt" "fmt"
"text/template"
"github.com/Masterminds/sprig/v3"
"github.com/perfect-panel/server/pkg/adapter/proxy" "github.com/perfect-panel/server/pkg/adapter/proxy"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -20,22 +23,16 @@ func NewClash(adapter proxy.Adapter) *Clash {
func (c *Clash) Build(uuid string) ([]byte, error) { func (c *Clash) Build(uuid string) ([]byte, error) {
var proxies []Proxy var proxies []Proxy
for _, v := range c.Proxies { for _, proxied := range c.Adapter.Proxies {
p, err := c.parseProxy(v, uuid) p, err := c.parseProxy(proxied, uuid)
if err != nil { 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 continue
} }
proxies = append(proxies, *p) 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 var groups []ProxyGroup
for _, group := range c.Group { for _, group := range c.Adapter.Group {
groups = append(groups, ProxyGroup{ groups = append(groups, ProxyGroup{
Name: group.Name, Name: group.Name,
Type: string(group.Type), Type: string(group.Type),
@ -44,9 +41,38 @@ func (c *Clash) Build(uuid string) ([]byte, error) {
Interval: group.Interval, Interval: group.Interval,
}) })
} }
rawConfig.ProxyGroups = groups var rules = append(c.Rules, fmt.Sprintf("MATCH,%s", c.Default))
rawConfig.Rules = append(c.Rules, "MATCH,手动选择")
return yaml.Marshal(&rawConfig) 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) { func (c *Clash) parseProxy(p proxy.Proxy, uuid string) (*Proxy, error) {

View File

@ -1,31 +1,50 @@
package clash package clash
const DefaultTemplate = ` const DefaultTemplate = `
mixed-port: 7890 mode: rule
ipv6: true
allow-lan: true allow-lan: true
bind-address: "*" bind-address: "*"
mode: rule mixed-port: 7890
log-level: info log-level: error
external-controller: 127.0.0.1:9090
global-client-fingerprint: chrome
unified-delay: true unified-delay: true
geox-url: tcp-concurrent: true
mmdb: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.metadb" external-controller: 0.0.0.0:9090
tun:
enable: true
stack: system
auto-route: true
dns: dns:
enable: true enable: true
cache-algorithm: arc
listen: 0.0.0.0:1053
ipv6: true ipv6: true
enhanced-mode: fake-ip enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16 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: default-nameserver:
- 120.53.53.53 - 119.29.29.29
- 1.12.12.12 - 223.5.5.5
nameserver: nameserver:
- https://120.53.53.53/dns-query#skip-cert-verify=true - system
- tls://1.12.12.12#skip-cert-verify=true - 119.29.29.29
proxy-server-nameserver: - 223.5.5.5
- https://120.53.53.53/dns-query#skip-cert-verify=true fallback:
- tls://1.12.12.12#skip-cert-verify=true - 8.8.8.8
- 1.1.1.1
fallback-filter:
geoip: true
geoip-code: CN
proxies: proxies:

View File

@ -63,6 +63,8 @@ func buildProxy(data proxy.Proxy, uuid string) string {
return Hysteria2Uri(data, uuid) return Hysteria2Uri(data, uuid)
case "tuic": case "tuic":
return TuicUri(data, uuid) return TuicUri(data, uuid)
case "anytls":
return AnyTLSUri(data, uuid)
default: default:
return "" return ""
} }
@ -271,6 +273,36 @@ func TuicUri(data proxy.Proxy, uuid string) string {
return u.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) { func setQuery(q *url.Values, k, v string) {
if v != "" { if v != "" {
q.Set(k, v) q.Set(k, v)

View File

@ -1,27 +1,61 @@
package loon package loon
import ( import (
"bytes"
"embed"
"strings"
"text/template"
"github.com/perfect-panel/server/pkg/adapter/proxy" "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 { func BuildLoon(servers []proxy.Proxy, uuid string) []byte {
uri := "" uri := ""
nodes := make([]string, 0)
for _, s := range servers { for _, s := range servers {
switch s.Protocol { switch s.Protocol {
case "vmess": case "vmess":
nodes = append(nodes, s.Name)
uri += buildVMess(s, uuid) uri += buildVMess(s, uuid)
case "shadowsocks": case "shadowsocks":
nodes = append(nodes, s.Name)
uri += buildShadowsocks(s, uuid) uri += buildShadowsocks(s, uuid)
case "trojan": case "trojan":
nodes = append(nodes, s.Name)
uri += buildTrojan(s, uuid) uri += buildTrojan(s, uuid)
case "vless": case "vless":
nodes = append(nodes, s.Name)
uri += buildVless(s, uuid) uri += buildVless(s, uuid)
case "hysteria2": case "hysteria2":
nodes = append(nodes, s.Name)
uri += buildHysteria2(s, uuid) uri += buildHysteria2(s, uuid)
default: default:
continue 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()
} }

View File

@ -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

View File

@ -1,21 +1,26 @@
package proxy package proxy
import "embed"
// Adapter represents a proxy adapter // Adapter represents a proxy adapter
type Adapter struct { type Adapter struct {
Proxies []Proxy Proxies []Proxy
Group []Group Group []Group
Rules []string Rules []string // rule
Region []string Nodes []string // all node
Default string // Default Node
TemplateFS *embed.FS // Template file system
} }
// Proxy represents a proxy server // Proxy represents a proxy server
type Proxy struct { type Proxy struct {
Name string Name string // Name of the proxy
Server string Server string // Server address of the proxy
Port int Port int // Port of the proxy server
Protocol string Protocol string // Protocol type (e.g., shadowsocks, vless, vmess, trojan, hysteria2, tuic, anytls)
Country string Country string // Country of the proxy
Option any Tags []string // Tags for the proxy
Option any // Additional options for the proxy configuration
} }
// Group represents a group of proxies // Group represents a group of proxies
@ -25,6 +30,10 @@ type Group struct {
Proxies []string Proxies []string
URL string URL string
Interval int Interval int
Reject bool // Reject group
Direct bool // Direct group
Tags []string // Tags for the group
Default bool // Default group
} }
type GroupType string type GroupType string

View File

@ -79,7 +79,7 @@ func BuildSingbox(adapter proxy.Adapter, uuid string) ([]byte, error) {
rawConfig["outbounds"] = proxies rawConfig["outbounds"] = proxies
route := RouteOptions{ route := RouteOptions{
Final: "手动选择", Final: adapter.Default,
Rules: []Rule{ Rules: []Rule{
{ {
Inbound: []string{ Inbound: []string{
@ -114,7 +114,7 @@ func BuildSingbox(adapter proxy.Adapter, uuid string) ([]byte, error) {
}, },
{ {
ClashMode: "global", ClashMode: "global",
Outbound: "手动选择", Outbound: adapter.Default,
}, },
{ {
IPIsPrivate: true, IPIsPrivate: true,

View File

@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"embed" "embed"
"fmt" "fmt"
"net/url"
"strings" "strings"
"text/template" "text/template"
"time" "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 { func BuildSurfboard(servers proxy.Adapter, siteName string, user UserInfo) []byte {
var proxies, proxyGroup string var proxies, proxyGroup string
var removed []string var removed []string
for _, node := range servers.Proxies { var ps []string
if uri := buildProxy(node, user.UUID); uri != "" {
proxies += uri for _, p := range servers.Proxies {
} else { switch p.Protocol {
removed = append(removed, node.Name) 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") file, err := configFiles.ReadFile("default.tpl")
if err != nil { if err != nil {
logger.Errorf("read default surfboard config error: %v", err.Error()) 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 { } else {
expiredAt = user.ExpiredDate.Format("2006-01-02 15:04:05") expiredAt = user.ExpiredDate.Format("2006-01-02 15:04:05")
} }
ps = tool.RemoveStringElement(ps, removed...)
proxyGroup = strings.Join(ps, ",")
// convert traffic // convert traffic
upload := traffic.AutoConvert(user.Upload, false) upload := traffic.AutoConvert(user.Upload, false)
download := traffic.AutoConvert(user.Download, false) download := traffic.AutoConvert(user.Download, false)
total := traffic.AutoConvert(user.TotalTraffic, false) total := traffic.AutoConvert(user.TotalTraffic, false)
unusedTraffic := traffic.AutoConvert(user.TotalTraffic-user.Upload-user.Download, false) unusedTraffic := traffic.AutoConvert(user.TotalTraffic-user.Upload-user.Download, false)
// query Host // query Host
urlParse, err := url.Parse(user.SubscribeURL) if err = tpl.Execute(&buf, map[string]interface{}{
if err != nil { "Proxies": proxies,
return nil "ProxyGroup": proxyGroup,
} "SubscribeURL": user.SubscribeURL,
if err := tpl.Execute(&buf, map[string]interface{}{ "SubscribeInfo": fmt.Sprintf("title=%s订阅信息, content=上传流量:%s\\n下载流量%s\\n剩余流量: %s\\n套餐流量%s\\n到期时间%s", siteName, upload, download, unusedTraffic, total, expiredAt),
"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,
}); err != nil { }); err != nil {
logger.Errorf("build surfboard config error: %v", err.Error()) logger.Errorf("build Surge config error: %v", err.Error())
return nil return nil
} }
return buf.Bytes() 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
}

View File

@ -1,29 +1,62 @@
#!MANAGED-CONFIG {{ .SubscribeURL }} interval=43200 strict=true #!MANAGED-CONFIG {{.SubscribeURL}} interval=43200 strict=true
[General] [General]
loglevel = notify dns-server = system, 119.29.29.29, 223.5.5.5
ipv6 = false skip-proxy = 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 127.0.0.0/8, localhost, *.local
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 always-real-ip = *.lan, lens.l.google.com, *.srv.nintendo.net, *.stun.playstation.net, *.xboxlive.com, xbox.*.*.microsoft.com, *.msftncsi.com, *.msftconnecttest.com
tls-provider = default proxy-test-url = http://www.gstatic.com/generate_204
show-error-page-for-reject = true internet-test-url = http://connectivitycheck.platform.hicloud.com/generate_204
dns-server = 223.6.6.6, 119.29.29.29, 119.28.28.28
test-timeout = 5 test-timeout = 5
internet-test-url = http://bing.com http-listen = 0.0.0.0:6088
proxy-test-url = http://bing.com socks5-listen = 0.0.0.0:6089
[Panel] [Panel]
SubscribeInfo = {{ .SubscribeInfo }}, style=info SubscribeInfo = {{.SubscribeInfo}}, style=info
# Surfboard 配置文档https://manual.getsurfboard.com/
[Proxy] [Proxy]
# 代理列表 {{.Proxies}}
{{ .Proxies }}
[Proxy Group] [Proxy Group]
# 代理组列表 🚀 Proxy = select, 🌏 Auto, 🎯 Direct, include-other-group=🇺🇳 Nodes
{{ .ProxyGroup }} 🍎 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] [Rule]
# 规则列表 RULE-SET, https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/refs/heads/master/rule/Surge/Apple/Apple_All.list, 🍎 Apple
{{ .Rules }} 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

View File

@ -1,61 +1,79 @@
#!MANAGED-CONFIG {{ .SubscribeURL }} interval=43200 strict=true #!MANAGED-CONFIG {{.SubscribeURL}} interval=43200 strict=true
# Surge 的规则配置手册: https://manual.nssurge.com/
[General] [General]
loglevel = notify loglevel = notify
# 从 Surge iOS 4 / Surge Mac 3.3.0 起,工具开始支持 DoH external-controller-access = purinio@0.0.0.0:6170
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
exclude-simple-hostnames = true exclude-simple-hostnames = true
show-error-page-for-reject = true
udp-priority = true
udp-policy-not-supported-behaviour = reject
ipv6 = true ipv6 = true
ipv6-vif = auto
test-timeout = 4
proxy-test-url = http://www.gstatic.com/generate_204 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] # > Surge Mac Parameters
hide-apple-request = true http-listen = 0.0.0.0:6088
hide-crashlytics-request = true socks5-listen = 0.0.0.0:6089
use-keyword-filter = false
hide-udp = false # > Surge iOS Parameters
allow-wifi-access = true
allow-hotspot-access = true
wifi-access-http-port = 6088
wifi-access-socks5-port = 6089
[Panel] [Panel]
SubscribeInfo = {{ .SubscribeInfo }}, style=info 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
# -----------------------------
[Proxy] [Proxy]
{{ .Proxies }} {{.Proxies}}
[Proxy Group] [Proxy Group]
# 代理组列表 🚀 Proxy = select, 🌏 Auto, 🎯 Direct, include-other-group=🇺🇳 Nodes
{{ .ProxyGroup }} 🍎 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] [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] [URL Rewrite]
^https?://(www.)?(g|google).cn https://www.google.com 302 ^https?:\/\/(www.)?g\.cn https://www.google.com 302
^https?:\/\/(www.)?google\.cn https://www.google.com 302

View File

@ -4,14 +4,13 @@ import (
"bytes" "bytes"
"embed" "embed"
"fmt" "fmt"
"github.com/perfect-panel/server/pkg/tool"
"net/url"
"strings" "strings"
"text/template" "text/template"
"time" "time"
"github.com/perfect-panel/server/pkg/adapter/proxy" "github.com/perfect-panel/server/pkg/adapter/proxy"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/traffic" "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 { func (m *Surge) Build(siteName string, user UserInfo) []byte {
var proxies, proxyGroup, rules string var proxies, proxyGroup string
var removed []string var removed []string
var ps []string
for _, p := range m.Adapter.Proxies { for _, p := range m.Adapter.Proxies {
switch p.Protocol { switch p.Protocol {
case "shadowsocks": case "shadowsocks":
proxies += buildShadowsocks(p, uuid) proxies += buildShadowsocks(p, user.UUID)
case "trojan": case "trojan":
proxies += buildTrojan(p, uuid) proxies += buildTrojan(p, user.UUID)
case "hysteria2": case "hysteria2":
proxies += buildHysteria2(p, uuid) proxies += buildHysteria2(p, user.UUID)
case "vmess": case "vmess":
proxies += buildVMess(p, uuid) proxies += buildVMess(p, user.UUID)
default: default:
removed = append(removed, p.Name) 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") file, err := configFiles.ReadFile("default.tpl")
if err != nil { if err != nil {
@ -99,23 +78,21 @@ func (m *Surge) Build(uuid, siteName string, user UserInfo) []byte {
} else { } else {
expiredAt = user.ExpiredDate.Format("2006-01-02 15:04:05") expiredAt = user.ExpiredDate.Format("2006-01-02 15:04:05")
} }
ps = tool.RemoveStringElement(ps, removed...)
proxyGroup = strings.Join(ps, ",")
// convert traffic // convert traffic
upload := traffic.AutoConvert(user.Upload, false) upload := traffic.AutoConvert(user.Upload, false)
download := traffic.AutoConvert(user.Download, false) download := traffic.AutoConvert(user.Download, false)
total := traffic.AutoConvert(user.TotalTraffic, false) total := traffic.AutoConvert(user.TotalTraffic, false)
unusedTraffic := traffic.AutoConvert(user.TotalTraffic-user.Upload-user.Download, false) unusedTraffic := traffic.AutoConvert(user.TotalTraffic-user.Upload-user.Download, false)
// query Host // query Host
urlParse, err := url.Parse(user.SubscribeURL)
if err != nil {
return nil
}
if err := tpl.Execute(&buf, map[string]interface{}{ if err := tpl.Execute(&buf, map[string]interface{}{
"Proxies": proxies, "Proxies": proxies,
"ProxyGroup": proxyGroup, "ProxyGroup": proxyGroup,
"SubscribeURL": user.SubscribeURL, "SubscribeURL": user.SubscribeURL,
"SubscribeInfo": fmt.Sprintf("title=%s订阅信息, content=上传流量:%s\\n下载流量%s\\n剩余流量: %s\\n套餐流量%s\\n到期时间%s", siteName, upload, download, unusedTraffic, total, expiredAt), "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,
}); err != nil { }); err != nil {
logger.Errorf("build Surge config error: %v", err.Error()) logger.Errorf("build Surge config error: %v", err.Error())
return nil return nil

View File

@ -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}}

View File

@ -2,6 +2,8 @@ package adapter
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log"
"strings" "strings"
"github.com/perfect-panel/server/internal/model/server" "github.com/perfect-panel/server/internal/model/server"
@ -11,14 +13,21 @@ import (
"github.com/perfect-panel/server/pkg/tool" "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 { func addNode(data *server.Server, host string, port int) *proxy.Proxy {
var option any var option any
tags := strings.Split(data.Tags, ",")
if len(tags) > 0 {
tags = tool.RemoveDuplicateElements(tags...)
}
node := proxy.Proxy{ node := proxy.Proxy{
Name: data.Name, Name: data.Name,
Server: host, Server: host,
Port: port, Port: port,
Country: data.Country, Country: data.Country,
Protocol: data.Protocol, Protocol: data.Protocol,
Tags: tags,
} }
switch data.Protocol { switch data.Protocol {
case "shadowsocks": case "shadowsocks":
@ -75,85 +84,78 @@ func addNode(data *server.Server, host string, port int) *proxy.Proxy {
node.Port = tuic.Port node.Port = tuic.Port
} }
option = tuic 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: default:
fmt.Printf("[Error] 不支持的协议: %s", data.Protocol)
return nil return nil
} }
node.Option = option node.Option = option
return &node return &node
} }
func addProxyToGroup(proxyName, groupName string, groups []proxy.Group) []proxy.Group { func adapterRules(groups []*server.RuleGroup) (proxyGroup []proxy.Group, rules []string, defaultGroup string) {
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) {
for _, group := range groups { for _, group := range groups {
proxyGroup = append(proxyGroup, proxy.Group{ if group.Default {
Name: group.Name, log.Printf("[Debug] 规则组 %s 是默认组", group.Name)
Type: proxy.GroupTypeSelect, defaultGroup = group.Name
Proxies: RemoveEmptyString(strings.Split(group.Tags, ",")), }
}) 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")...) 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) { // generateDefaultGroup generates a default proxy group with auto-selection and manual selection options.
for tag, servers := range tags { func generateDefaultGroup() (proxyGroup []proxy.Group) {
proxies := adapterProxies(servers) proxyGroup = append(proxyGroup, proxy.Group{
if len(proxies) != 0 { Name: AutoSelect,
for _, p := range proxies { Type: proxy.GroupTypeURLTest,
group = addProxyToGroup(p.Name, tag, group) Proxies: make([]string, 0),
} URL: "https://www.gstatic.com/generate_204",
} Interval: 300,
} })
return group
return proxyGroup
} }
func generateProxyGroup(servers []proxy.Proxy) (proxyGroup []proxy.Group, region []string) { func adapterProxies(servers []*server.Server) ([]proxy.Proxy, []string, map[string][]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 {
var proxies []proxy.Proxy var proxies []proxy.Proxy
var tags = make(map[string][]string)
for _, node := range servers { for _, node := range servers {
switch node.RelayMode { switch node.RelayMode {
case server.RelayModeAll: case server.RelayModeAll:
@ -168,8 +170,20 @@ func adapterProxies(servers []*server.Server) []proxy.Proxy {
continue continue
} }
if relay.Prefix != "" { 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) proxies = append(proxies, *n)
} }
case server.RelayModeRandom: case server.RelayModeRandom:
@ -185,18 +199,46 @@ func adapterProxies(servers []*server.Server) []proxy.Proxy {
continue continue
} }
if relay.Prefix != "" { 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) proxies = append(proxies, *n)
default: default:
logger.Info("Not Relay Mode", logger.Field("node", node.Name), logger.Field("relayMode", node.RelayMode)) logger.Info("Not Relay Mode", logger.Field("node", node.Name), logger.Field("relayMode", node.RelayMode))
n := addNode(node, node.ServerAddr, 0) n := addNode(node, node.ServerAddr, 0)
if n != nil { 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) 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 切片去除空值 // RemoveEmptyString 切片去除空值
@ -210,18 +252,61 @@ func RemoveEmptyString(arr []string) []string {
return result return result
} }
func RemoveEmptyGroup(arr []proxy.Group) []proxy.Group { // SortGroups sorts the provided slice of proxy groups by their names.
var result []proxy.Group func SortGroups(groups []proxy.Group, nodes []string, tags map[string][]string, defaultName string) []proxy.Group {
var removeNames []string var sortedGroups []proxy.Group
for _, group := range arr { var defaultGroup, autoSelectGroup proxy.Group
if group.Name == "手动选择" { // 在所有分组找到默认分组并将他放到第一个
group.Proxies = tool.RemoveStringElement(group.Proxies, removeNames...) for _, group := range groups {
if group.Name == "" || group.Name == "DIRECT" || group.Name == "REJECT" {
continue
} }
if len(group.Proxies) > 0 { // 如果是默认分组
result = append(result, group) if group.Default {
} else { group.Proxies = append([]string{AutoSelect}, nodes...)
removeNames = append(removeNames, group.Name) 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
} }

View File

@ -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 // Gracefully shut down all WebSocket connections
func (dm *DeviceManager) Shutdown(ctx context.Context) { func (dm *DeviceManager) Shutdown(ctx context.Context) {
<-ctx.Done() <-ctx.Done()

View File

@ -282,7 +282,6 @@ const (
</body> </body>
</html> </html>
` `
DefaultTrafficExceedEmailTemplate = `<!doctype html> DefaultTrafficExceedEmailTemplate = `<!doctype html>
<html> <html>
<head> <head>

View File

@ -33,4 +33,7 @@ func RegisterHandlers(mux *asynq.ServeMux, serverCtx *svc.ServiceContext) {
// Schedule total server data // Schedule total server data
mux.Handle(types.SchedulerTotalServerData, traffic.NewServerDataLogic(serverCtx)) mux.Handle(types.SchedulerTotalServerData, traffic.NewServerDataLogic(serverCtx))
// Schedule reset traffic
mux.Handle(types.SchedulerResetTraffic, traffic.NewResetTrafficLogic(serverCtx))
} }

File diff suppressed because it is too large Load Diff

View File

@ -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
}

View File

@ -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
}

View File

@ -3,4 +3,5 @@ package types
const ( const (
SchedulerCheckSubscription = "scheduler:check:subscription" SchedulerCheckSubscription = "scheduler:check:subscription"
SchedulerTotalServerData = "scheduler:total:server" SchedulerTotalServerData = "scheduler:total:server"
SchedulerResetTraffic = "scheduler:reset:traffic"
) )

View File

@ -34,6 +34,11 @@ func (m *Service) Start() {
if _, err := m.server.Register("@every 180s", totalServerDataTask); err != nil { if _, err := m.server.Register("@every 180s", totalServerDataTask); err != nil {
logger.Errorf("register total server data task failed: %s", err.Error()) 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 { if err := m.server.Run(); err != nil {
logger.Errorf("run scheduler failed: %s", err.Error()) logger.Errorf("run scheduler failed: %s", err.Error())