diff --git a/adapter/adapter.go b/adapter/adapter.go index 1d6e4f4..da5d049 100644 --- a/adapter/adapter.go +++ b/adapter/adapter.go @@ -127,12 +127,12 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) { HopPorts: protocol.HopPorts, HopInterval: protocol.HopInterval, ObfsPassword: protocol.ObfsPassword, + UpMbps: protocol.UpMbps, + DownMbps: protocol.DownMbps, DisableSNI: protocol.DisableSNI, ReduceRtt: protocol.ReduceRtt, UDPRelayMode: protocol.UDPRelayMode, CongestionController: protocol.CongestionController, - UpMbps: protocol.UpMbps, - DownMbps: protocol.DownMbps, PaddingScheme: protocol.PaddingScheme, Multiplex: protocol.Multiplex, XhttpMode: protocol.XhttpMode, @@ -145,6 +145,10 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) { EncryptionPrivateKey: protocol.EncryptionPrivateKey, EncryptionClientPadding: protocol.EncryptionClientPadding, EncryptionPassword: protocol.EncryptionPassword, + Ratio: protocol.Ratio, + CertMode: protocol.CertMode, + CertDNSProvider: protocol.CertDNSProvider, + CertDNSEnv: protocol.CertDNSEnv, }) } } diff --git a/apis/admin/subscribe.api b/apis/admin/subscribe.api index 1d08b65..bea205d 100644 --- a/apis/admin/subscribe.api +++ b/apis/admin/subscribe.api @@ -102,6 +102,9 @@ type ( BatchDeleteSubscribeRequest { Ids []int64 `json:"ids" validate:"required"` } + ResetAllSubscribeTokenResponse { + Success bool `json:"success"` + } ) @server ( @@ -157,5 +160,9 @@ service ppanel { @doc "Subscribe sort" @handler SubscribeSort post /sort (SubscribeSortRequest) + + @doc "Reset all subscribe tokens" + @handler ResetAllSubscribeToken + post /reset_all_token returns (ResetAllSubscribeTokenResponse) } diff --git a/apis/admin/system.api b/apis/admin/system.api index a300cda..d82d3bd 100644 --- a/apis/admin/system.api +++ b/apis/admin/system.api @@ -19,8 +19,13 @@ type ( Periods []TimePeriod `json:"periods"` } PreViewNodeMultiplierResponse { - CurrentTime string `json:"current_time"` - Ratio float32 `json:"ratio"` + CurrentTime string `json:"current_time"` + Ratio float32 `json:"ratio"` + } + ModuleConfig { + Secret string `json:"secret"` // 通讯密钥 + ServiceName string `json:"service_name"` // 服务名称 + ServiceVersion string `json:"service_version"` // 服务版本 } ) @@ -125,5 +130,9 @@ service ppanel { @doc "PreView Node Multiplier" @handler PreViewNodeMultiplier get /node_multiplier/preview returns (PreViewNodeMultiplierResponse) + + @doc "Get Module Config" + @handler GetModuleConfig + get /module returns (ModuleConfig) } diff --git a/apis/admin/tool.api b/apis/admin/tool.api index da1916b..4d1f17b 100644 --- a/apis/admin/tool.api +++ b/apis/admin/tool.api @@ -17,6 +17,14 @@ type ( VersionResponse { Version string `json:"version"` } + QueryIPLocationRequest { + IP string `form:"ip" validate:"required"` + } + QueryIPLocationResponse { + Country string `json:"country"` + Region string `json:"region,omitempty"` + City string `json:"city"` + } ) @server ( @@ -36,5 +44,9 @@ service ppanel { @doc "Get Version" @handler GetVersion get /version returns (VersionResponse) + + @doc "Query IP Location" + @handler QueryIPLocation + get /ip/location (QueryIPLocationRequest) returns (QueryIPLocationResponse) } diff --git a/apis/auth/auth.api b/apis/auth/auth.api index 154c878..8211bef 100644 --- a/apis/auth/auth.api +++ b/apis/auth/auth.api @@ -16,7 +16,7 @@ type ( Password string `json:"password" validate:"required"` IP string `header:"X-Original-Forwarded-For"` UserAgent string `header:"User-Agent"` - LoginType string `header:"Login-Type"` + LoginType string `header:"Login-Type"` CfToken string `json:"cf_token,optional"` } // Check user is exist request @@ -36,19 +36,19 @@ type ( Code string `json:"code,optional"` IP string `header:"X-Original-Forwarded-For"` UserAgent string `header:"User-Agent"` - LoginType string `header:"Login-Type"` + LoginType string `header:"Login-Type"` CfToken string `json:"cf_token,optional"` } // User login response ResetPasswordRequest { - Identifier string `json:"identifier"` - Email string `json:"email" validate:"required"` - Password string `json:"password" validate:"required"` - Code string `json:"code,optional"` - IP string `header:"X-Original-Forwarded-For"` - UserAgent string `header:"User-Agent"` - LoginType string `header:"Login-Type"` - CfToken string `json:"cf_token,optional"` + Identifier string `json:"identifier"` + Email string `json:"email" validate:"required"` + Password string `json:"password" validate:"required"` + Code string `json:"code,optional"` + IP string `header:"X-Original-Forwarded-For"` + UserAgent string `header:"User-Agent"` + LoginType string `header:"Login-Type"` + CfToken string `json:"cf_token,optional"` } LoginResponse { Token string `json:"token"` @@ -73,7 +73,7 @@ type ( Password string `json:"password"` IP string `header:"X-Original-Forwarded-For"` UserAgent string `header:"User-Agent"` - LoginType string `header:"Login-Type"` + LoginType string `header:"Login-Type"` CfToken string `json:"cf_token,optional"` } // Check user is exist request @@ -95,19 +95,19 @@ type ( Code string `json:"code,optional"` IP string `header:"X-Original-Forwarded-For"` UserAgent string `header:"User-Agent"` - LoginType string `header:"Login-Type,optional"` + LoginType string `header:"Login-Type,optional"` CfToken string `json:"cf_token,optional"` } // User login response TelephoneResetPasswordRequest { - Identifier string `json:"identifier"` + Identifier string `json:"identifier"` Telephone string `json:"telephone" validate:"required"` TelephoneAreaCode string `json:"telephone_area_code" validate:"required"` Password string `json:"password" validate:"required"` Code string `json:"code,optional"` IP string `header:"X-Original-Forwarded-For"` UserAgent string `header:"User-Agent"` - LoginType string `header:"Login-Type,optional"` + LoginType string `header:"Login-Type,optional"` CfToken string `json:"cf_token,optional"` } AppleLoginCallbackRequest { @@ -128,9 +128,9 @@ type ( ) @server ( - prefix: v1/auth - group: auth - middleware: DeviceMiddleware + prefix: v1/auth + group: auth + middleware: DeviceMiddleware ) service ppanel { @doc "User login" diff --git a/apis/common.api b/apis/common.api index d246099..db935f4 100644 --- a/apis/common.api +++ b/apis/common.api @@ -87,12 +87,17 @@ type ( Total int64 `json:"total"` List []SubscribeClient `json:"list"` } + HeartbeatResponse { + Status bool `json:"status"` + Message string `json:"message,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + } ) @server ( - prefix: v1/common - group: common - middleware: DeviceMiddleware + prefix: v1/common + group: common + middleware: DeviceMiddleware ) service ppanel { @doc "Get global config" @@ -130,5 +135,9 @@ service ppanel { @doc "Get Client" @handler GetClient get /client returns (GetSubscribeClientResponse) + + @doc "Heartbeat" + @handler Heartbeat + get /heartbeat returns (HeartbeatResponse) } diff --git a/apis/public/portal.api b/apis/public/portal.api index aba8e25..2d33861 100644 --- a/apis/public/portal.api +++ b/apis/public/portal.api @@ -68,9 +68,9 @@ type ( ) @server ( - prefix: v1/public/portal - group: public/portal - middleware: DeviceMiddleware + prefix: v1/public/portal + group: public/portal + middleware: DeviceMiddleware ) service ppanel { @doc "Get available payment methods" diff --git a/apis/public/subscribe.api b/apis/public/subscribe.api index 4c0d2aa..55e9c07 100644 --- a/apis/public/subscribe.api +++ b/apis/public/subscribe.api @@ -14,11 +14,9 @@ type ( QuerySubscribeListRequest { Language string `form:"language"` } - - QueryUserSubscribeNodeListResponse { - List []UserSubscribeInfo `json:"list"` - } - + QueryUserSubscribeNodeListResponse { + List []UserSubscribeInfo `json:"list"` + } UserSubscribeInfo { Id int64 `json:"id"` UserId int64 `json:"user_id"` diff --git a/apis/public/user.api b/apis/public/user.api index 0d4db24..82c89b2 100644 --- a/apis/public/user.api +++ b/apis/public/user.api @@ -123,6 +123,36 @@ type ( LongestSingleConnection int64 `json:"longest_single_connection"` } + UpdateUserSubscribeNoteRequest { + UserSubscribeId int64 `json:"user_subscribe_id" validate:"required"` + Note string `json:"note" validate:"max=500"` + } + + UpdateUserRulesRequest { + Rules []string `json:"rules" validate:"required"` + } + CommissionWithdrawRequest { + Amount int64 `json:"amount"` + Content string `json:"content"` + } + WithdrawalLog { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + Amount int64 `json:"amount"` + Content string `json:"content"` + Status uint8 `json:"status"` + Reason string `json:"reason,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + QueryWithdrawalLogListRequest { + Page int `form:"page"` + Size int `form:"size"` + } + QueryWithdrawalLogListResponse { + List []WithdrawalLog `json:"list"` + Total int64 `json:"total"` + } ) @server ( @@ -234,6 +264,22 @@ service ppanel { @doc "Delete Current User Account" @handler DeleteCurrentUserAccount delete /current_user_account + + @doc "Update User Subscribe Note" + @handler UpdateUserSubscribeNote + put /subscribe_note (UpdateUserSubscribeNoteRequest) + + @doc "Update User Rules" + @handler UpdateUserRules + put /rules (UpdateUserRulesRequest) + + @doc "Commission Withdraw" + @handler CommissionWithdraw + post /commission_withdraw (CommissionWithdrawRequest) returns (WithdrawalLog) + + @doc "Query Withdrawal Log" + @handler QueryWithdrawalLog + get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse) } diff --git a/apis/types.api b/apis/types.api index 0bfa68c..defa923 100644 --- a/apis/types.api +++ b/apis/types.api @@ -28,6 +28,7 @@ type ( EnableTradeNotify bool `json:"enable_trade_notify"` AuthMethods []UserAuthMethod `json:"auth_methods"` UserDevices []UserDevice `json:"user_devices"` + Rules []string `json:"rules"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` DeletedAt int64 `json:"deleted_at,omitempty"` @@ -115,7 +116,7 @@ type ( AuthConfig { Mobile MobileAuthenticateConfig `json:"mobile"` Email EmailAuthticateConfig `json:"email"` - Device DeviceAuthticateConfig `json:"device"` + Device DeviceAuthticateConfig `json:"device"` Register PubilcRegisterConfig `json:"register"` } PubilcRegisterConfig { @@ -135,14 +136,12 @@ type ( EnableDomainSuffix bool `json:"enable_domain_suffix"` DomainSuffixList string `json:"domain_suffix_list"` } - - DeviceAuthticateConfig { - Enable bool `json:"enable"` - ShowAds bool `json:"show_ads"` - EnableSecurity bool `json:"enable_security"` - OnlyRealDevice bool `json:"only_real_device"` - } - + DeviceAuthticateConfig { + Enable bool `json:"enable"` + ShowAds bool `json:"show_ads"` + EnableSecurity bool `json:"enable_security"` + OnlyRealDevice bool `json:"only_real_device"` + } RegisterConfig { StopRegister bool `json:"stop_register"` EnableTrial bool `json:"enable_trial"` @@ -472,6 +471,7 @@ type ( Upload int64 `json:"upload"` Token string `json:"token"` Status uint8 `json:"status"` + Short string `json:"short"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } @@ -656,7 +656,7 @@ type ( // public announcement QueryAnnouncementRequest { Page int `form:"page"` - Size int `form:"size,default=15"` + Size int `form:"size"` Pinned *bool `form:"pinned"` Popup *bool `form:"popup"` } @@ -673,7 +673,6 @@ type ( List []SubscribeGroup `json:"list"` Total int64 `json:"total"` } - GetUserSubscribeTrafficLogsRequest { Page int `form:"page"` Size int `form:"size"` diff --git a/cache/GeoLite2-City.mmdb b/cache/GeoLite2-City.mmdb new file mode 100644 index 0000000..5878ff4 Binary files /dev/null and b/cache/GeoLite2-City.mmdb differ diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..6a33dcb --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "fmt" + + "github.com/perfect-panel/server/pkg/updater" + "github.com/spf13/cobra" +) + +var ( + checkOnly bool +) + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Check for updates and update PPanel to the latest version", + Long: `Check for available updates from GitHub releases and automatically +update the PPanel binary to the latest version. + +Examples: + # Check for updates only + ppanel-server update --check + + # Update to the latest version + ppanel-server update`, + Run: func(cmd *cobra.Command, args []string) { + u := updater.NewUpdater() + + if checkOnly { + checkForUpdates(u) + return + } + + performUpdate(u) + }, +} + +func init() { + updateCmd.Flags().BoolVarP(&checkOnly, "check", "c", false, "Check for updates without applying them") +} + +func checkForUpdates(u *updater.Updater) { + fmt.Println("Checking for updates...") + + release, hasUpdate, err := u.CheckForUpdates() + if err != nil { + fmt.Printf("Error checking for updates: %v\n", err) + return + } + + if !hasUpdate { + fmt.Println("You are already running the latest version!") + return + } + + fmt.Printf("\nNew version available!\n") + fmt.Printf("Current version: %s\n", u.CurrentVersion) + fmt.Printf("Latest version: %s\n", release.TagName) + fmt.Printf("\nRelease notes:\n%s\n", release.Body) + fmt.Printf("\nTo update, run: ppanel-server update\n") +} + +func performUpdate(u *updater.Updater) { + fmt.Println("Starting update process...") + + if err := u.Update(); err != nil { + fmt.Printf("Update failed: %v\n", err) + return + } + + fmt.Println("\nUpdate completed successfully!") + fmt.Println("Please restart the application to use the new version.") +} diff --git a/go.mod b/go.mod index 3e110be..9d0b191 100644 --- a/go.mod +++ b/go.mod @@ -60,6 +60,7 @@ require ( github.com/fatih/color v1.18.0 github.com/goccy/go-json v0.10.4 github.com/golang-migrate/migrate/v4 v4.18.2 + github.com/oschwald/geoip2-golang v1.13.0 github.com/spaolacci/murmur3 v1.1.0 google.golang.org/grpc v1.64.1 google.golang.org/protobuf v1.36.3 @@ -117,6 +118,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/openzipkin/zipkin-go v0.4.2 // indirect + github.com/oschwald/maxminddb-golang v1.13.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 6ab9d74..aa84f0e 100644 --- a/go.sum +++ b/go.sum @@ -285,6 +285,10 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/openzipkin/zipkin-go v0.4.2 h1:zjqfqHjUpPmB3c1GlCvvgsM1G4LkvqQbBDueDOCg/jA= github.com/openzipkin/zipkin-go v0.4.2/go.mod h1:ZeVkFjuuBiSy13y8vpSDCjMi9GoI3hPpCJSBx/EYFhY= +github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI= +github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= +github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= +github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/initialize/config.go b/initialize/config.go index 025220f..0667543 100644 --- a/initialize/config.go +++ b/initialize/config.go @@ -9,6 +9,7 @@ import ( "net/http" "os" + "github.com/perfect-panel/server/internal/report" "github.com/perfect-panel/server/pkg/logger" "gorm.io/driver/mysql" @@ -35,10 +36,30 @@ func Config(path string) (chan bool, *http.Server) { configPath = path // Create a new Gin instance r := gin.Default() + // get server port + port := 8080 + host := "127.0.0.1" + // check gateway mode + if report.IsGatewayMode() { + // get free port + freePort, err := report.ModulePort() + if err != nil { + logger.Errorf("get module port error: %s", err.Error()) + panic(err) + } + port = freePort + // register module + err = report.RegisterModule(port) + if err != nil { + logger.Errorf("register module error: %s", err.Error()) + panic(err) + } + logger.Infof("module registered on port %d", port) + } // Create a new HTTP server server := &http.Server{ - Addr: ":8080", + Addr: fmt.Sprintf("%s:%d", host, port), Handler: r, } // Load templates diff --git a/initialize/migrate/database/02120_user_subscribe_note.down.sql b/initialize/migrate/database/02120_user_subscribe_note.down.sql new file mode 100644 index 0000000..60cc0e8 --- /dev/null +++ b/initialize/migrate/database/02120_user_subscribe_note.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE `user_subscribe` +DROP COLUMN `note`; diff --git a/initialize/migrate/database/02120_user_subscribe_note.up.sql b/initialize/migrate/database/02120_user_subscribe_note.up.sql new file mode 100644 index 0000000..b8b6983 --- /dev/null +++ b/initialize/migrate/database/02120_user_subscribe_note.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE `user_subscribe` +ADD COLUMN `note` VARCHAR(500) NOT NULL DEFAULT '' + COMMENT 'User note for subscription' + AFTER `status`; diff --git a/initialize/migrate/database/02121_user_rules.down.sql b/initialize/migrate/database/02121_user_rules.down.sql new file mode 100644 index 0000000..718f4a6 --- /dev/null +++ b/initialize/migrate/database/02121_user_rules.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE `user` +DROP COLUMN IF EXISTS `rules`; diff --git a/initialize/migrate/database/02121_user_rules.up.sql b/initialize/migrate/database/02121_user_rules.up.sql new file mode 100644 index 0000000..5e93aca --- /dev/null +++ b/initialize/migrate/database/02121_user_rules.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE `user` + ADD COLUMN `rules` TEXT NULL + COMMENT 'User rules for subscription' + AFTER `created_at`; diff --git a/initialize/migrate/database/02122_user_withdrawal.down.sql b/initialize/migrate/database/02122_user_withdrawal.down.sql new file mode 100644 index 0000000..4de8bc5 --- /dev/null +++ b/initialize/migrate/database/02122_user_withdrawal.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS `withdrawals`; + +DELETE FROM `system` +WHERE `category` = 'invite' + AND `key` = 'WithdrawalMethod'; diff --git a/initialize/migrate/database/02122_user_withdrawal.up.sql b/initialize/migrate/database/02122_user_withdrawal.up.sql new file mode 100644 index 0000000..4f39e1e --- /dev/null +++ b/initialize/migrate/database/02122_user_withdrawal.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS `withdrawals` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'Primary Key', + `user_id` BIGINT NOT NULL COMMENT 'User ID', + `amount` BIGINT NOT NULL COMMENT 'Withdrawal Amount', + `content` TEXT COMMENT 'Withdrawal Content', + `status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Withdrawal Status', + `reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Rejection Reason', + `created_at` DATETIME NOT NULL COMMENT 'Creation Time', + `updated_at` DATETIME NOT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT IGNORE INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`) +VALUES + ('invite', 'WithdrawalMethod', '', 'string', 'withdrawal method', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'); \ No newline at end of file diff --git a/internal/handler/admin/subscribe/resetAllSubscribeTokenHandler.go b/internal/handler/admin/subscribe/resetAllSubscribeTokenHandler.go new file mode 100644 index 0000000..408975a --- /dev/null +++ b/internal/handler/admin/subscribe/resetAllSubscribeTokenHandler.go @@ -0,0 +1,18 @@ +package subscribe + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/subscribe" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/result" +) + +// Reset all subscribe tokens +func ResetAllSubscribeTokenHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + + l := subscribe.NewResetAllSubscribeTokenLogic(c.Request.Context(), svcCtx) + resp, err := l.ResetAllSubscribeToken() + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/system/getModuleConfigHandler.go b/internal/handler/admin/system/getModuleConfigHandler.go new file mode 100644 index 0000000..72f87a3 --- /dev/null +++ b/internal/handler/admin/system/getModuleConfigHandler.go @@ -0,0 +1,18 @@ +package system + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/system" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/result" +) + +// GetModuleConfigHandler Get Module Config +func GetModuleConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + + l := system.NewGetModuleConfigLogic(c.Request.Context(), svcCtx) + resp, err := l.GetModuleConfig() + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/tool/queryIPLocationHandler.go b/internal/handler/admin/tool/queryIPLocationHandler.go new file mode 100644 index 0000000..0b95355 --- /dev/null +++ b/internal/handler/admin/tool/queryIPLocationHandler.go @@ -0,0 +1,26 @@ +package tool + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/tool" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// QueryIPLocationHandler Query IP Location +func QueryIPLocationHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.QueryIPLocationRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := tool.NewQueryIPLocationLogic(c.Request.Context(), svcCtx) + resp, err := l.QueryIPLocation(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/common/getClientHandler.go b/internal/handler/common/getClientHandler.go index e40b555..78d613d 100644 --- a/internal/handler/common/getClientHandler.go +++ b/internal/handler/common/getClientHandler.go @@ -10,7 +10,6 @@ import ( // Get Client func GetClientHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { return func(c *gin.Context) { - l := common.NewGetClientLogic(c.Request.Context(), svcCtx) resp, err := l.GetClient() result.HttpResult(c, resp, err) diff --git a/internal/handler/common/heartbeatHandler.go b/internal/handler/common/heartbeatHandler.go new file mode 100644 index 0000000..d72d771 --- /dev/null +++ b/internal/handler/common/heartbeatHandler.go @@ -0,0 +1,18 @@ +package common + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/common" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/result" +) + +// Heartbeat +func HeartbeatHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + + l := common.NewHeartbeatLogic(c.Request.Context(), svcCtx) + resp, err := l.Heartbeat() + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/public/user/commissionWithdrawHandler.go b/internal/handler/public/user/commissionWithdrawHandler.go new file mode 100644 index 0000000..f4f244c --- /dev/null +++ b/internal/handler/public/user/commissionWithdrawHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Commission Withdraw +func CommissionWithdrawHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.CommissionWithdrawRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewCommissionWithdrawLogic(c.Request.Context(), svcCtx) + resp, err := l.CommissionWithdraw(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/public/user/queryWithdrawalLogHandler.go b/internal/handler/public/user/queryWithdrawalLogHandler.go new file mode 100644 index 0000000..9f0bddc --- /dev/null +++ b/internal/handler/public/user/queryWithdrawalLogHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Query Withdrawal Log +func QueryWithdrawalLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.QueryWithdrawalLogListRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewQueryWithdrawalLogLogic(c.Request.Context(), svcCtx) + resp, err := l.QueryWithdrawalLog(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/public/user/updateUserRulesHandler.go b/internal/handler/public/user/updateUserRulesHandler.go new file mode 100644 index 0000000..e8b9a01 --- /dev/null +++ b/internal/handler/public/user/updateUserRulesHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Update User Rules +func UpdateUserRulesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.UpdateUserRulesRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewUpdateUserRulesLogic(c.Request.Context(), svcCtx) + err := l.UpdateUserRules(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/public/user/updateUserSubscribeNoteHandler.go b/internal/handler/public/user/updateUserSubscribeNoteHandler.go new file mode 100644 index 0000000..17b77bf --- /dev/null +++ b/internal/handler/public/user/updateUserSubscribeNoteHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Update User Subscribe Note +func UpdateUserSubscribeNoteHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.UpdateUserSubscribeNoteRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewUpdateUserSubscribeNoteLogic(c.Request.Context(), svcCtx) + err := l.UpdateUserSubscribeNote(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 723f993..f235295 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -386,6 +386,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Get subscribe list adminSubscribeGroupRouter.GET("/list", adminSubscribe.GetSubscribeListHandler(serverCtx)) + // Reset all subscribe tokens + adminSubscribeGroupRouter.POST("/reset_all_token", adminSubscribe.ResetAllSubscribeTokenHandler(serverCtx)) + // Subscribe sort adminSubscribeGroupRouter.POST("/sort", adminSubscribe.SubscribeSortHandler(serverCtx)) } @@ -409,6 +412,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Update invite config adminSystemGroupRouter.PUT("/invite_config", adminSystem.UpdateInviteConfigHandler(serverCtx)) + // Get Module Config + adminSystemGroupRouter.GET("/module", adminSystem.GetModuleConfigHandler(serverCtx)) + // Get node config adminSystemGroupRouter.GET("/node_config", adminSystem.GetNodeConfigHandler(serverCtx)) @@ -488,6 +494,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { adminToolGroupRouter.Use(middleware.AuthMiddleware(serverCtx)) { + // Query IP Location + adminToolGroupRouter.GET("/ip/location", adminTool.QueryIPLocationHandler(serverCtx)) + // Get System Log adminToolGroupRouter.GET("/log", adminTool.GetSystemLogHandler(serverCtx)) @@ -636,6 +645,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Get Client commonGroupRouter.GET("/client", common.GetClientHandler(serverCtx)) + // Heartbeat + commonGroupRouter.GET("/heartbeat", common.HeartbeatHandler(serverCtx)) + // Get verification code commonGroupRouter.POST("/send_code", common.SendEmailCodeHandler(serverCtx)) @@ -796,6 +808,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Query User Commission Log publicUserGroupRouter.GET("/commission_log", publicUser.QueryUserCommissionLogHandler(serverCtx)) + // Commission Withdraw + publicUserGroupRouter.POST("/commission_withdraw", publicUser.CommissionWithdrawHandler(serverCtx)) + // Delete Current User Account publicUserGroupRouter.DELETE("/current_user_account", publicUser.DeleteCurrentUserAccountHandler(serverCtx)) @@ -820,12 +835,18 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Update User Password publicUserGroupRouter.PUT("/password", publicUser.UpdateUserPasswordHandler(serverCtx)) + // Update User Rules + publicUserGroupRouter.PUT("/rules", publicUser.UpdateUserRulesHandler(serverCtx)) + // Query User Subscribe publicUserGroupRouter.GET("/subscribe", publicUser.QueryUserSubscribeHandler(serverCtx)) // Get Subscribe Log publicUserGroupRouter.GET("/subscribe_log", publicUser.GetSubscribeLogHandler(serverCtx)) + // Update User Subscribe Note + publicUserGroupRouter.PUT("/subscribe_note", publicUser.UpdateUserSubscribeNoteHandler(serverCtx)) + // Reset User Subscribe Token publicUserGroupRouter.PUT("/subscribe_token", publicUser.ResetUserSubscribeTokenHandler(serverCtx)) @@ -846,6 +867,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Verify Email publicUserGroupRouter.POST("/verify_email", publicUser.VerifyEmailHandler(serverCtx)) + + // Query Withdrawal Log + publicUserGroupRouter.GET("/withdrawal_log", publicUser.QueryWithdrawalLogHandler(serverCtx)) } publicUserWsGroupRouter := router.Group("/v1/public/user") diff --git a/internal/handler/subscribe.go b/internal/handler/subscribe.go index bf72a19..6c228ed 100644 --- a/internal/handler/subscribe.go +++ b/internal/handler/subscribe.go @@ -23,6 +23,22 @@ func SubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { ua := c.GetHeader("User-Agent") req.UA = c.Request.Header.Get("User-Agent") req.Flag = c.Query("flag") + if svcCtx.Config.Subscribe.PanDomain { + domain := c.Request.Host + domainArr := strings.Split(domain, ".") + short, err := tool.FixedUniqueString(req.Token, 8, "") + if err != nil { + logger.Errorf("[SubscribeHandler] Generate short token failed: %v", err) + c.String(http.StatusInternalServerError, "Internal Server") + c.Abort() + return + } + if short != domainArr[0] { + c.String(http.StatusForbidden, "Access denied") + c.Abort() + return + } + } if svcCtx.Config.Subscribe.UserAgentLimit { if ua == "" { diff --git a/internal/logic/admin/payment/getPaymentMethodListLogic.go b/internal/logic/admin/payment/getPaymentMethodListLogic.go index 77c7e40..ef987f3 100644 --- a/internal/logic/admin/payment/getPaymentMethodListLogic.go +++ b/internal/logic/admin/payment/getPaymentMethodListLogic.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" + "github.com/perfect-panel/server/internal/report" paymentPlatform "github.com/perfect-panel/server/pkg/payment" "github.com/perfect-panel/server/internal/model/payment" @@ -43,15 +44,31 @@ func (l *GetPaymentMethodListLogic) GetPaymentMethodList(req *types.GetPaymentMe Total: total, List: make([]types.PaymentMethodDetail, len(list)), } + + // gateway mod + + isGatewayMod := report.IsGatewayMode() + for i, v := range list { config := make(map[string]interface{}) _ = json.Unmarshal([]byte(v.Config), &config) notifyUrl := "" + if paymentPlatform.ParsePlatform(v.Platform) != paymentPlatform.Balance { + notifyUrl = v.Domain if v.Domain != "" { - notifyUrl = v.Domain + "/v1/notify/" + v.Platform + "/" + v.Token + // if is gateway mod, use gateway domain + if isGatewayMod { + notifyUrl += "/api/" + } + notifyUrl += "/v1/notify/" + v.Platform + "/" + v.Token } else { - notifyUrl = "https://" + l.svcCtx.Config.Host + "/v1/notify/" + v.Platform + "/" + v.Token + notifyUrl += "https://" + l.svcCtx.Config.Host + if isGatewayMod { + notifyUrl += "/api/v1/notify/" + v.Platform + "/" + v.Token + } else { + notifyUrl += "/v1/notify/" + v.Platform + "/" + v.Token + } } } resp.List[i] = types.PaymentMethodDetail{ diff --git a/internal/logic/admin/subscribe/resetAllSubscribeTokenLogic.go b/internal/logic/admin/subscribe/resetAllSubscribeTokenLogic.go new file mode 100644 index 0000000..e7307a2 --- /dev/null +++ b/internal/logic/admin/subscribe/resetAllSubscribeTokenLogic.go @@ -0,0 +1,61 @@ +package subscribe + +import ( + "context" + "strconv" + "time" + + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/pkg/uuidx" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" +) + +type ResetAllSubscribeTokenLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Reset all subscribe tokens +func NewResetAllSubscribeTokenLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ResetAllSubscribeTokenLogic { + return &ResetAllSubscribeTokenLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ResetAllSubscribeTokenLogic) ResetAllSubscribeToken() (resp *types.ResetAllSubscribeTokenResponse, err error) { + var list []*user.Subscribe + tx := l.svcCtx.DB.WithContext(l.ctx).Begin() + // select all active and Finished subscriptions + if err = tx.Model(&user.Subscribe{}).Where("`status` IN ?", []int64{1, 2}).Find(&list).Error; err != nil { + logger.Errorf("[ResetAllSubscribeToken] Failed to fetch subscribe list: %v", err.Error()) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to fetch subscribe list: %v", err.Error()) + } + + for _, sub := range list { + sub.Token = uuidx.SubscribeToken(strconv.FormatInt(time.Now().UnixMilli(), 10) + strconv.FormatInt(sub.Id, 10)) + sub.UUID = uuidx.NewUUID().String() + if err = tx.Model(&user.Subscribe{}).Where("id = ?", sub.Id).Save(sub).Error; err != nil { + tx.Rollback() + logger.Errorf("[ResetAllSubscribeToken] Failed to update subscribe token for ID %d: %v", sub.Id, err.Error()) + return &types.ResetAllSubscribeTokenResponse{ + Success: false, + }, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Failed to update subscribe token for ID %d: %v", sub.Id, err.Error()) + } + } + if err = tx.Commit().Error; err != nil { + logger.Errorf("[ResetAllSubscribeToken] Failed to commit transaction: %v", err.Error()) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Failed to commit transaction: %v", err.Error()) + } + + return &types.ResetAllSubscribeTokenResponse{ + Success: true, + }, nil +} diff --git a/internal/logic/admin/system/getModuleConfigLogic.go b/internal/logic/admin/system/getModuleConfigLogic.go new file mode 100644 index 0000000..2dc646f --- /dev/null +++ b/internal/logic/admin/system/getModuleConfigLogic.go @@ -0,0 +1,41 @@ +package system + +import ( + "context" + "os" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetModuleConfigLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Get Module Config +func NewGetModuleConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetModuleConfigLogic { + return &GetModuleConfigLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetModuleConfigLogic) GetModuleConfig() (resp *types.ModuleConfig, err error) { + value, exists := os.LookupEnv("SECRET_KEY") + if !exists { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), " SECRET_KEY not set in environment variables") + } + + return &types.ModuleConfig{ + Secret: value, + ServiceName: constant.ServiceName, + ServiceVersion: constant.Version, + }, nil +} diff --git a/internal/logic/admin/tool/queryIPLocationLogic.go b/internal/logic/admin/tool/queryIPLocationLogic.go new file mode 100644 index 0000000..6487b05 --- /dev/null +++ b/internal/logic/admin/tool/queryIPLocationLogic.go @@ -0,0 +1,57 @@ +package tool + +import ( + "context" + "net" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type QueryIPLocationLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// NewQueryIPLocationLogic Query IP Location +func NewQueryIPLocationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryIPLocationLogic { + return &QueryIPLocationLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *QueryIPLocationLogic) QueryIPLocation(req *types.QueryIPLocationRequest) (resp *types.QueryIPLocationResponse, err error) { + if l.svcCtx.GeoIP == nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), " GeoIP database not configured") + } + + ip := net.ParseIP(req.IP) + record, err := l.svcCtx.GeoIP.DB.City(ip) + if err != nil { + l.Errorf("Failed to query IP location: %v", err) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Failed to query IP location") + } + + var country, region, city string + if record.Country.Names != nil { + country = record.Country.Names["en"] + } + if len(record.Subdivisions) > 0 && record.Subdivisions[0].Names != nil { + region = record.Subdivisions[0].Names["en"] + } + if record.City.Names != nil { + city = record.City.Names["en"] + } + + return &types.QueryIPLocationResponse{ + Country: country, + Region: region, + City: city, + }, nil +} diff --git a/internal/logic/common/heartbeatLogic.go b/internal/logic/common/heartbeatLogic.go new file mode 100644 index 0000000..1bfb081 --- /dev/null +++ b/internal/logic/common/heartbeatLogic.go @@ -0,0 +1,33 @@ +package common + +import ( + "context" + "time" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" +) + +type HeartbeatLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// NewHeartbeatLogic Heartbeat +func NewHeartbeatLogic(ctx context.Context, svcCtx *svc.ServiceContext) *HeartbeatLogic { + return &HeartbeatLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *HeartbeatLogic) Heartbeat() (resp *types.HeartbeatResponse, err error) { + return &types.HeartbeatResponse{ + Status: true, + Message: "service is alive", + Timestamp: time.Now().Unix(), + }, nil +} diff --git a/internal/logic/public/portal/purchaseCheckoutLogic.go b/internal/logic/public/portal/purchaseCheckoutLogic.go index f016a49..ff7faf2 100644 --- a/internal/logic/public/portal/purchaseCheckoutLogic.go +++ b/internal/logic/public/portal/purchaseCheckoutLogic.go @@ -7,6 +7,7 @@ import ( "time" "github.com/perfect-panel/server/internal/model/log" + "github.com/perfect-panel/server/internal/report" "github.com/perfect-panel/server/pkg/constant" paymentPlatform "github.com/perfect-panel/server/pkg/payment" @@ -275,16 +276,29 @@ func (l *PurchaseCheckoutLogic) epayPayment(config *payment.Payment, info *order return "", err } + // gateway mod + + isGatewayMod := report.IsGatewayMode() + // Build notification URL for payment status callbacks notifyUrl := "" if config.Domain != "" { - notifyUrl = config.Domain + "/v1/notify/" + config.Platform + "/" + config.Token + notifyUrl = config.Domain + if isGatewayMod { + notifyUrl += "/api/" + } + notifyUrl = notifyUrl + "/v1/notify/" + config.Platform + "/" + config.Token } else { host, ok := l.ctx.Value(constant.CtxKeyRequestHost).(string) if !ok { host = l.svcCtx.Config.Host } - notifyUrl = "https://" + host + "/v1/notify/" + config.Platform + "/" + config.Token + + notifyUrl = "https://" + host + if isGatewayMod { + notifyUrl += "/api" + } + notifyUrl = notifyUrl + "/v1/notify/" + config.Platform + "/" + config.Token } // Create payment URL for user redirection @@ -317,18 +331,29 @@ func (l *PurchaseCheckoutLogic) CryptoSaaSPayment(config *payment.Payment, info return "", err } + // gateway mod + isGatewayMod := report.IsGatewayMode() + // Build notification URL for payment status callbacks notifyUrl := "" if config.Domain != "" { - notifyUrl = config.Domain + "/v1/notify/" + config.Platform + "/" + config.Token + notifyUrl = config.Domain + if isGatewayMod { + notifyUrl += "/api/" + } + notifyUrl = notifyUrl + "/v1/notify/" + config.Platform + "/" + config.Token } else { host, ok := l.ctx.Value(constant.CtxKeyRequestHost).(string) if !ok { host = l.svcCtx.Config.Host } - notifyUrl = "https://" + host + "/v1/notify/" + config.Platform + "/" + config.Token - } + notifyUrl = "https://" + host + if isGatewayMod { + notifyUrl += "/api" + } + notifyUrl = notifyUrl + "/v1/notify/" + config.Platform + "/" + config.Token + } // Create payment URL for user redirection url := client.CreatePayUrl(epay.Order{ Name: l.svcCtx.Config.Site.SiteName, diff --git a/internal/logic/public/user/commissionWithdrawLogic.go b/internal/logic/public/user/commissionWithdrawLogic.go new file mode 100644 index 0000000..d16dec0 --- /dev/null +++ b/internal/logic/public/user/commissionWithdrawLogic.go @@ -0,0 +1,108 @@ +package user + +import ( + "context" + "time" + + "github.com/perfect-panel/server/internal/model/log" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type CommissionWithdrawLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Commission Withdraw +func NewCommissionWithdrawLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CommissionWithdrawLogic { + return &CommissionWithdrawLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdrawRequest) (resp *types.WithdrawalLog, err error) { + u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) + if !ok { + logger.Error("current user is not found in context") + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") + } + + if u.Commission < req.Amount { + logger.Errorf("User %d has insufficient commission balance: %.2f, requested: %.2f", u.Id, float64(u.Commission)/100, float64(req.Amount)/100) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "User %d has insufficient commission balance", u.Id) + } + + tx := l.svcCtx.DB.WithContext(l.ctx).Begin() + + // update user commission balance + u.Commission -= req.Amount + if err = l.svcCtx.UserModel.Update(l.ctx, u, tx); err != nil { + tx.Rollback() + l.Errorf("Failed to update user %d commission balance: %v", u.Id, err) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Failed to update user %d commission balance: %v", u.Id, err) + } + + // create withdrawal log + logInfo := log.Commission{ + Type: log.CommissionTypeConvertBalance, + Amount: req.Amount, + Timestamp: time.Now().UnixMilli(), + } + b, err := logInfo.Marshal() + + if err != nil { + tx.Rollback() + l.Errorf("Failed to marshal commission log for user %d: %v", u.Id, err) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Failed to marshal commission log for user %d: %v", u.Id, err) + } + + err = tx.Model(log.SystemLog{}).Create(&log.SystemLog{ + Type: log.TypeCommission.Uint8(), + Date: time.Now().Format("2006-01-02"), + ObjectID: u.Id, + Content: string(b), + CreatedAt: time.Now(), + }).Error + + if err != nil { + tx.Rollback() + l.Errorf("Failed to create commission log for user %d: %v", u.Id, err) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create commission log for user %d: %v", u.Id, err) + } + + err = tx.Model(&user.Withdrawal{}).Create(&user.Withdrawal{ + UserId: u.Id, + Amount: req.Amount, + Content: req.Content, + Status: 0, + Reason: "", + }).Error + + if err != nil { + tx.Rollback() + l.Errorf("Failed to create withdrawal log for user %d: %v", u.Id, err) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal log for user %d: %v", u.Id, err) + } + if err = tx.Commit().Error; err != nil { + l.Errorf("Transaction commit failed for user %d withdrawal: %v", u.Id, err) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Transaction commit failed for user %d withdrawal: %v", u.Id, err) + } + + return &types.WithdrawalLog{ + UserId: u.Id, + Amount: req.Amount, + Content: req.Content, + Status: 0, + Reason: "", + CreatedAt: time.Now().UnixMilli(), + }, nil +} diff --git a/internal/logic/public/user/queryUserSubscribeLogic.go b/internal/logic/public/user/queryUserSubscribeLogic.go index 757f6fc..55e3770 100644 --- a/internal/logic/public/user/queryUserSubscribeLogic.go +++ b/internal/logic/public/user/queryUserSubscribeLogic.go @@ -60,6 +60,8 @@ func (l *QueryUserSubscribeLogic) QueryUserSubscribe() (resp *types.QueryUserSub } } + short, _ := tool.FixedUniqueString(item.Token, 8, "") + sub.Short = short sub.ResetTime = calculateNextResetTime(&sub) resp.List = append(resp.List, sub) } diff --git a/internal/logic/public/user/queryWithdrawalLogLogic.go b/internal/logic/public/user/queryWithdrawalLogLogic.go new file mode 100644 index 0000000..1b1a583 --- /dev/null +++ b/internal/logic/public/user/queryWithdrawalLogLogic.go @@ -0,0 +1,30 @@ +package user + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" +) + +type QueryWithdrawalLogLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// NewQueryWithdrawalLogLogic Query Withdrawal Log +func NewQueryWithdrawalLogLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryWithdrawalLogLogic { + return &QueryWithdrawalLogLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalLogListRequest) (resp *types.QueryWithdrawalLogListResponse, err error) { + // todo: add your logic here and delete this line + + return +} diff --git a/internal/logic/public/user/updateUserRulesLogic.go b/internal/logic/public/user/updateUserRulesLogic.go new file mode 100644 index 0000000..63ab169 --- /dev/null +++ b/internal/logic/public/user/updateUserRulesLogic.go @@ -0,0 +1,51 @@ +package user + +import ( + "context" + "encoding/json" + + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type UpdateUserRulesLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// NewUpdateUserRulesLogic Update User Rules +func NewUpdateUserRulesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateUserRulesLogic { + return &UpdateUserRulesLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpdateUserRulesLogic) UpdateUserRules(req *types.UpdateUserRulesRequest) error { + u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) + if !ok { + logger.Error("current user is not found in context") + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") + } + if len(req.Rules) > 0 { + bytes, err := json.Marshal(req.Rules) + if err != nil { + l.Logger.Errorf("UpdateUserRulesLogic json marshal rules error: %v", err) + return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "json marshal rules failed: %v", err.Error()) + } + u.Rules = string(bytes) + err = l.svcCtx.UserModel.Update(l.ctx, u) + if err != nil { + l.Logger.Errorf("UpdateUserRulesLogic UpdateUserRules error: %v", err) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update user rules failed: %v", err.Error()) + } + } + return nil +} diff --git a/internal/logic/public/user/updateUserSubscribeNoteLogic.go b/internal/logic/public/user/updateUserSubscribeNoteLogic.go new file mode 100644 index 0000000..3c43a8d --- /dev/null +++ b/internal/logic/public/user/updateUserSubscribeNoteLogic.go @@ -0,0 +1,73 @@ +package user + +import ( + "context" + + "github.com/perfect-panel/server/pkg/constant" + + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/tool" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type UpdateUserSubscribeNoteLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// NewUpdateUserSubscribeNoteLogic Update User Subscribe Note +func NewUpdateUserSubscribeNoteLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateUserSubscribeNoteLogic { + return &UpdateUserSubscribeNoteLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpdateUserSubscribeNoteLogic) UpdateUserSubscribeNote(req *types.UpdateUserSubscribeNoteRequest) error { + u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) + if !ok { + logger.Error("current user is not found in context") + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") + } + + userSub, err := l.svcCtx.UserModel.FindOneUserSubscribe(l.ctx, req.UserSubscribeId) + if err != nil { + l.Errorw("FindOneUserSubscribe failed:", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindOneUserSubscribe failed: %v", err.Error()) + } + + if userSub.UserId != u.Id { + l.Errorw("UserSubscribeId does not belong to the current user") + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "UserSubscribeId does not belong to the current user") + } + + userSub.Note = req.Note + var newSub user.Subscribe + tool.DeepCopy(&newSub, userSub) + + err = l.svcCtx.UserModel.UpdateSubscribe(l.ctx, &newSub) + if err != nil { + l.Errorw("UpdateSubscribe failed:", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "UpdateSubscribe failed: %v", err.Error()) + } + + // Clear user subscription cache + if err = l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, &newSub); err != nil { + l.Errorw("ClearSubscribeCache failed", logger.Field("error", err.Error()), logger.Field("userSubscribeId", userSub.Id)) + return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "ClearSubscribeCache failed: %v", err.Error()) + } + + // Clear subscription cache + if err = l.svcCtx.SubscribeModel.ClearCache(l.ctx, userSub.SubscribeId); err != nil { + l.Errorw("ClearSubscribeCache failed", logger.Field("error", err.Error()), logger.Field("subscribeId", userSub.SubscribeId)) + return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "ClearSubscribeCache failed: %v", err.Error()) + } + + return nil +} diff --git a/internal/model/log/log.go b/internal/model/log/log.go index af3eb98..1afd13d 100644 --- a/internal/model/log/log.go +++ b/internal/model/log/log.go @@ -33,23 +33,24 @@ const ( TypeTrafficStat Type = 42 // Daily traffic statistics log ) const ( - ResetSubscribeTypeAuto uint16 = 231 // Auto reset - ResetSubscribeTypeAdvance uint16 = 232 // Advance reset - ResetSubscribeTypePaid uint16 = 233 // Paid reset - ResetSubscribeTypeQuota uint16 = 234 // Quota reset - BalanceTypeRecharge uint16 = 321 // Recharge - BalanceTypeWithdraw uint16 = 322 // Withdraw - BalanceTypePayment uint16 = 323 // Payment - BalanceTypeRefund uint16 = 324 // Refund - BalanceTypeAdjust uint16 = 326 // Admin Adjust - BalanceTypeReward uint16 = 325 // Reward - CommissionTypePurchase uint16 = 331 // Purchase - CommissionTypeRenewal uint16 = 332 // Renewal - CommissionTypeRefund uint16 = 333 // Refund - commissionTypeWithdraw uint16 = 334 // withdraw - CommissionTypeAdjust uint16 = 335 // Admin Adjust - GiftTypeIncrease uint16 = 341 // Increase - GiftTypeReduce uint16 = 342 // Reduce + ResetSubscribeTypeAuto uint16 = 231 // Auto reset + ResetSubscribeTypeAdvance uint16 = 232 // Advance reset + ResetSubscribeTypePaid uint16 = 233 // Paid reset + ResetSubscribeTypeQuota uint16 = 234 // Quota reset + BalanceTypeRecharge uint16 = 321 // Recharge + BalanceTypeWithdraw uint16 = 322 // Withdraw + BalanceTypePayment uint16 = 323 // Payment + BalanceTypeRefund uint16 = 324 // Refund + BalanceTypeAdjust uint16 = 326 // Admin Adjust + BalanceTypeReward uint16 = 325 // Reward + CommissionTypePurchase uint16 = 331 // Purchase + CommissionTypeRenewal uint16 = 332 // Renewal + CommissionTypeRefund uint16 = 333 // Refund + CommissionTypeWithdraw uint16 = 334 // withdraw + CommissionTypeAdjust uint16 = 335 // Admin Adjust + CommissionTypeConvertBalance uint16 = 336 // Convert to Balance + GiftTypeIncrease uint16 = 341 // Increase + GiftTypeReduce uint16 = 342 // Reduce ) // Uint8 converts Type to uint8. diff --git a/internal/model/user/model.go b/internal/model/user/model.go index d2107a9..b9ffe04 100644 --- a/internal/model/user/model.go +++ b/internal/model/user/model.go @@ -36,6 +36,7 @@ type SubscribeDetails struct { Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"` UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"` Status uint8 `gorm:"type:tinyint(1);default:0;comment:Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired; 4: Cancelled"` + Note string `gorm:"type:varchar(500);default:'';comment:User note for subscription"` CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` UpdatedAt time.Time `gorm:"comment:Update Time"` } diff --git a/internal/model/user/user.go b/internal/model/user/user.go index 98bfbcb..923594e 100644 --- a/internal/model/user/user.go +++ b/internal/model/user/user.go @@ -25,6 +25,7 @@ type User struct { EnableTradeNotify *bool `gorm:"default:false;not null;comment:Enable Trade Notifications"` AuthMethods []AuthMethods `gorm:"foreignKey:UserId;references:Id"` UserDevices []Device `gorm:"foreignKey:UserId;references:Id"` + Rules string `gorm:"type:TEXT;comment:User Rules"` CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` UpdatedAt time.Time `gorm:"comment:Update Time"` } @@ -48,6 +49,7 @@ type Subscribe struct { Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"` UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"` Status uint8 `gorm:"type:tinyint(1);default:0;comment:Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired 4: Deducted"` + Note string `gorm:"type:varchar(500);default:'';comment:User note for subscription"` CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` UpdatedAt time.Time `gorm:"comment:Update Time"` } @@ -100,3 +102,18 @@ type DeviceOnlineRecord struct { func (DeviceOnlineRecord) TableName() string { return "user_device_online_record" } + +type Withdrawal struct { + Id int64 `gorm:"primaryKey"` + UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"` + Amount int64 `gorm:"not null;comment:Withdrawal Amount"` + Content string `gorm:"type:text;comment:Withdrawal Content"` + Status uint8 `gorm:"type:tinyint(1);default:0;comment:Withdrawal Status: 0: Pending 1: Approved 2: Rejected"` + Reason string `gorm:"type:varchar(500);default:'';comment:Rejection Reason"` + CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` +} + +func (*Withdrawal) TableName() string { + return "user_withdrawal" +} diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 0000000..1ba618a --- /dev/null +++ b/internal/report/report.go @@ -0,0 +1,15 @@ +package report + +const ( + RegisterAPI = "/basic/register" // 模块注册接口 +) + +// RegisterResponse 模块注册响应参数 +type RegisterResponse struct { + Code int `json:"code"` // 响应代码 + Message string `json:"message"` // 响应信息 + Data struct { + Success bool `json:"success"` // 注册是否成功 + Message string `json:"message"` // 返回信息 + } `json:"data"` // 响应数据 +} diff --git a/internal/report/tool.go b/internal/report/tool.go new file mode 100644 index 0000000..fe8a68c --- /dev/null +++ b/internal/report/tool.go @@ -0,0 +1,113 @@ +package report + +import ( + "fmt" + "net" + "os" + + "github.com/go-resty/resty/v2" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" + "github.com/pkg/errors" +) + +// FreePort returns a free TCP port by opening a listener on port 0. +func FreePort() (int, error) { + l, err := net.Listen("tcp", ":0") + if err != nil { + return 0, err + } + defer l.Close() + // Get the assigned port + addr := l.Addr().(*net.TCPAddr) + return addr.Port, nil +} + +// ModulePort returns the module port from the environment variable or a free port. +func ModulePort() (int, error) { + // 从环境变量获取端口号 + value, exists := os.LookupEnv("PPANEL_PORT") + if exists { + var port int + _, err := fmt.Sscanf(value, "%d", &port) + if err != nil { + return FreePort() + } + return port, nil + } + return FreePort() +} + +// GatewayPort returns the gateway port from the environment variable or a free port. +func GatewayPort() (int, error) { + // 从环境变量获取端口号 + value, exists := os.LookupEnv("GATEWAY_PORT") + if exists { + var port int + _, err := fmt.Sscanf(value, "%d", &port) + if err != nil { + logger.Errorf("Failed to parse GATEWAY_PORT: %v Value %s", err.Error(), value) + panic(err) + } + return port, nil + } + return 0, errors.New("could not determine gateway port") +} + +// RegisterModule registers a module with the gateway. +func RegisterModule(port int) error { + // 从环境变量中读取网关模块端口 + gatewayPort, err := GatewayPort() + if err != nil { + logger.Errorf("Failed to determine GATEWAY_PORT: %v", err) + return err + } + + // 从环境变量中获取通讯密钥 + value, exists := os.LookupEnv("SECRET_KEY") + if !exists { + panic("could not determine secret key") + } + + var response RegisterResponse + + client := resty.New().SetBaseURL(fmt.Sprintf("http://127.0.0.1:%d", gatewayPort)) + result, err := client.R().SetHeader("Content-Type", "application/json").SetBody(RegisterServiceRequest{ + Secret: value, + ProxyPath: "/api", + ServiceURL: fmt.Sprintf("http://127.0.0.1:%d", port), + Repository: constant.Repository, + HeartbeatURL: fmt.Sprintf("http://127.0.0.1:%d/v1/common/heartbeat", port), + ServiceName: constant.ServiceName, + ServiceVersion: constant.Version, + }).SetResult(&response).Post(RegisterAPI) + + if err != nil { + logger.Errorf("Failed to register service: %v", err) + return err + } + + if result.IsError() { + return errors.New("failed to register module: " + result.Status()) + } + + if !response.Data.Success { + logger.Infof("Result: %v", result.String()) + return errors.New("failed to register module: " + response.Message) + } + logger.Infof("Module registered successfully: %s", response.Message) + return nil +} + +// IsGatewayMode checks if the application is running in gateway mode. +// It returns true if GATEWAY_MODE is set to "true" and GATEWAY_PORT is valid. +func IsGatewayMode() bool { + value, exists := os.LookupEnv("GATEWAY_MODE") + if exists && value == "true" { + if _, err := GatewayPort(); err == nil { + return true + } + } + + return false +} diff --git a/internal/report/tool_test.go b/internal/report/tool_test.go new file mode 100644 index 0000000..cee9d83 --- /dev/null +++ b/internal/report/tool_test.go @@ -0,0 +1,21 @@ +package report + +import ( + "testing" +) + +func TestFreePort(t *testing.T) { + port, err := FreePort() + if err != nil { + t.Fatalf("FreePort() error: %v", err) + } + t.Logf("FreePort: %v", port) +} + +func TestModulePort(t *testing.T) { + port, err := ModulePort() + if err != nil { + t.Fatalf("ModulePort() error: %v", err) + } + t.Logf("ModulePort: %v", port) +} diff --git a/internal/report/types.go b/internal/report/types.go new file mode 100644 index 0000000..d9cd643 --- /dev/null +++ b/internal/report/types.go @@ -0,0 +1,11 @@ +package report + +type RegisterServiceRequest struct { + Secret string `json:"secret"` // 通讯密钥 + ProxyPath string `json:"proxy_path"` // 代理路径 + ServiceURL string `json:"service_url"` // 服务地址 + Repository string `json:"repository"` // 服务代码仓库 + ServiceName string `json:"service_name"` // 服务名称 + HeartbeatURL string `json:"heartbeat_url"` // 心跳检测地址 + ServiceVersion string `json:"service_version"` // 服务版本 +} diff --git a/internal/server.go b/internal/server.go index 64c2704..78d6422 100644 --- a/internal/server.go +++ b/internal/server.go @@ -6,8 +6,10 @@ import ( "errors" "fmt" "net/http" + "os" "time" + "github.com/perfect-panel/server/internal/report" "github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/proc" @@ -48,7 +50,7 @@ func initServer(svc *svc.ServiceContext) *gin.Engine { } r.Use(sessions.Sessions("ppanel", sessionStore)) // use cors middleware - r.Use(middleware.TraceMiddleware(svc), middleware.LoggerMiddleware(svc), middleware.CorsMiddleware, middleware.PanDomainMiddleware(svc), gin.Recovery()) + r.Use(middleware.TraceMiddleware(svc), middleware.LoggerMiddleware(svc), middleware.CorsMiddleware, gin.Recovery()) // register handlers handler.RegisterHandlers(r, svc) @@ -65,9 +67,32 @@ func (m *Service) Start() { if m.svc == nil { panic("config file path is nil") } + // init service r := initServer(m.svc) - serverAddr := fmt.Sprintf("%v:%d", m.svc.Config.Host, m.svc.Config.Port) + // get server port + port := m.svc.Config.Port + host := m.svc.Config.Host + // check gateway mode + if report.IsGatewayMode() { + // get free port + freePort, err := report.ModulePort() + if err != nil { + logger.Errorf("get module port error: %s", err.Error()) + panic(err) + } + port = freePort + host = "127.0.0.1" + // register module + err = report.RegisterModule(port) + if err != nil { + logger.Errorf("register module error: %s", err.Error()) + os.Exit(1) + } + logger.Infof("module registered on port %d", port) + } + + serverAddr := fmt.Sprintf("%v:%d", host, port) m.server = &http.Server{ Addr: serverAddr, Handler: r, diff --git a/internal/svc/mmdb.go b/internal/svc/mmdb.go new file mode 100644 index 0000000..331034f --- /dev/null +++ b/internal/svc/mmdb.go @@ -0,0 +1,74 @@ +package svc + +import ( + "io" + "net/http" + "os" + "path/filepath" + + "github.com/oschwald/geoip2-golang" + "github.com/perfect-panel/server/pkg/logger" +) + +const GeoIPDBURL = "https://raw.githubusercontent.com/adysec/IP_database/main/geolite/GeoLite2-City.mmdb" + +type IPLocation struct { + Path string + DB *geoip2.Reader +} + +func NewIPLocation(path string) (*IPLocation, error) { + + // 检查文件是否存在 + if _, err := os.Stat(path); os.IsNotExist(err) { + logger.Infof("[GeoIP] Database not found, downloading from %s", GeoIPDBURL) + // 文件不存在,下载数据库 + err := DownloadGeoIPDatabase(GeoIPDBURL, path) + if err != nil { + logger.Errorf("[GeoIP] Failed to download database: %v", err.Error()) + return nil, err + } + logger.Infof("[GeoIP] Database downloaded successfully") + } + + db, err := geoip2.Open(path) + if err != nil { + return nil, err + } + return &IPLocation{ + Path: path, + DB: db, + }, nil +} + +func (ipLoc *IPLocation) Close() error { + return ipLoc.DB.Close() +} + +func DownloadGeoIPDatabase(url, path string) error { + + // 创建路径, 确保目录存在 + err := os.MkdirAll(filepath.Dir(path), 0755) + if err != nil { + logger.Errorf("[GeoIP] Failed to create directory: %v", err.Error()) + return err + } + + // 创建文件 + out, err := os.Create(path) + if err != nil { + return err + } + defer out.Close() + + // 请求远程文件 + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + // 保存文件 + _, err = io.Copy(out, resp.Body) + return err +} diff --git a/internal/svc/serviceContext.go b/internal/svc/serviceContext.go index be05079..4f6bc3a 100644 --- a/internal/svc/serviceContext.go +++ b/internal/svc/serviceContext.go @@ -37,6 +37,7 @@ type ServiceContext struct { Config config.Config Queue *asynq.Client ExchangeRate float64 + GeoIP *IPLocation //NodeCache *cache.NodeCacheClient AuthModel auth.Model @@ -68,9 +69,17 @@ func NewServiceContext(c config.Config) *ServiceContext { db, err := orm.ConnectMysql(orm.Mysql{ Config: c.MySQL, }) + if err != nil { panic(err.Error()) } + + // IP location initialize + geoIP, err := NewIPLocation("./cache/GeoLite2-City.mmdb") + if err != nil { + panic(err.Error()) + } + rds := redis.NewClient(&redis.Options{ Addr: c.Redis.Host, Password: c.Redis.Pass, @@ -89,6 +98,7 @@ func NewServiceContext(c config.Config) *ServiceContext { Config: c, Queue: NewAsynqClient(c), ExchangeRate: 1.0, + GeoIP: geoIP, //NodeCache: cache.NewNodeCacheClient(rds), AuthLimiter: authLimiter, AdsModel: ads.NewModel(db, rds), diff --git a/internal/types/types.go b/internal/types/types.go index 5505276..3054d16 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -239,6 +239,11 @@ type CommissionLog struct { Timestamp int64 `json:"timestamp"` } +type CommissionWithdrawRequest struct { + Amount int64 `json:"amount"` + Content string `json:"content"` +} + type ConnectionRecords struct { CurrentContinuousDays int64 `json:"current_continuous_days"` HistoryContinuousDays int64 `json:"history_continuous_days"` @@ -1169,6 +1174,12 @@ type HasMigrateSeverNodeResponse struct { HasMigrate bool `json:"has_migrate"` } +type HeartbeatResponse struct { + Status bool `json:"status"` + Message string `json:"message,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` +} + type Hysteria2 struct { Port int `json:"port" validate:"required"` HopPorts string `json:"hop_ports" validate:"required"` @@ -1232,6 +1243,12 @@ type MobileAuthenticateConfig struct { Whitelist []string `json:"whitelist"` } +type ModuleConfig struct { + Secret string `json:"secret"` // 通讯密钥 + ServiceName string `json:"service_name"` // 服务名称 + ServiceVersion string `json:"service_version"` // 服务版本 +} + type Node struct { Id int64 `json:"id"` Name string `json:"name"` @@ -1551,7 +1568,7 @@ type PurchaseOrderResponse struct { type QueryAnnouncementRequest struct { Page int `form:"page"` - Size int `form:"size,default=15"` + Size int `form:"size"` Pinned *bool `form:"pinned"` Popup *bool `form:"popup"` } @@ -1570,6 +1587,16 @@ type QueryDocumentListResponse struct { List []Document `json:"list"` } +type QueryIPLocationRequest struct { + IP string `form:"ip" validate:"required"` +} + +type QueryIPLocationResponse struct { + Country string `json:"country"` + Region string `json:"region,omitempty"` + City string `json:"city"` +} + type QueryNodeTagResponse struct { Tags []string `json:"tags"` } @@ -1712,6 +1739,16 @@ type QueryUserSubscribeNodeListResponse struct { List []UserSubscribeInfo `json:"list"` } +type QueryWithdrawalLogListRequest struct { + Page int `form:"page"` + Size int `form:"size"` +} + +type QueryWithdrawalLogListResponse struct { + List []WithdrawalLog `json:"list"` + Total int64 `json:"total"` +} + type QuotaTask struct { Id int64 `json:"id"` Subscribers []int64 `json:"subscribers"` @@ -1771,6 +1808,10 @@ type RenewalOrderResponse struct { OrderNo string `json:"order_no"` } +type ResetAllSubscribeTokenResponse struct { + Success bool `json:"success"` +} + type ResetPasswordRequest struct { Identifier string `json:"identifier"` Email string `json:"email" validate:"required"` @@ -2464,6 +2505,15 @@ type UpdateUserPasswordRequest struct { Password string `json:"password" validate:"required"` } +type UpdateUserRulesRequest struct { + Rules []string `json:"rules" validate:"required"` +} + +type UpdateUserSubscribeNoteRequest struct { + UserSubscribeId int64 `json:"user_subscribe_id" validate:"required"` + Note string `json:"note" validate:"max=500"` +} + type UpdateUserSubscribeRequest struct { UserSubscribeId int64 `json:"user_subscribe_id"` SubscribeId int64 `json:"subscribe_id"` @@ -2497,6 +2547,7 @@ type User struct { EnableTradeNotify bool `json:"enable_trade_notify"` AuthMethods []UserAuthMethod `json:"auth_methods"` UserDevices []UserDevice `json:"user_devices"` + Rules []string `json:"rules"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` DeletedAt int64 `json:"deleted_at,omitempty"` @@ -2587,6 +2638,7 @@ type UserSubscribe struct { Upload int64 `json:"upload"` Token string `json:"token"` Status uint8 `json:"status"` + Short string `json:"short"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } @@ -2751,3 +2803,14 @@ type WeeklyStat struct { DayName string `json:"day_name"` Hours float64 `json:"hours"` } + +type WithdrawalLog struct { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + Amount int64 `json:"amount"` + Content string `json:"content"` + Status uint8 `json:"status"` + Reason string `json:"reason,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} diff --git a/pkg/constant/version.go b/pkg/constant/version.go index 5370107..b9e58ed 100644 --- a/pkg/constant/version.go +++ b/pkg/constant/version.go @@ -2,6 +2,8 @@ package constant // Version PPanel version var ( - Version = "unknown version" - BuildTime = "unknown time" + Version = "unknown version" + BuildTime = "unknown time" + Repository = "https://github.com/perfect-panel/server" + ServiceName = "ApiService" ) diff --git a/pkg/tool/string.go b/pkg/tool/string.go new file mode 100644 index 0000000..2567dc0 --- /dev/null +++ b/pkg/tool/string.go @@ -0,0 +1,38 @@ +package tool + +import ( + "crypto/sha256" + "encoding/binary" + "errors" + "math/rand" +) + +func FixedUniqueString(s string, length int, alphabet string) (string, error) { + if alphabet == "" { + alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + } + if length <= 0 { + return "", errors.New("length must be > 0") + } + if length > len(alphabet) { + return "", errors.New("length greater than available unique characters") + } + + // Generate deterministic seed from SHA256 + hash := sha256.Sum256([]byte(s)) + seed := int64(binary.LittleEndian.Uint64(hash[:8])) // 前 8 字节 + + r := rand.New(rand.NewSource(seed)) + + // Copy alphabet to mutable array + data := []rune(alphabet) + + // Deterministic shuffle (Fisher–Yates) + for i := len(data) - 1; i > 0; i-- { + j := r.Intn(i + 1) + data[i], data[j] = data[j], data[i] + } + + // Take first N characters + return string(data[:length]), nil +} diff --git a/pkg/tool/string_test.go b/pkg/tool/string_test.go new file mode 100644 index 0000000..0c44086 --- /dev/null +++ b/pkg/tool/string_test.go @@ -0,0 +1,27 @@ +package tool + +import ( + "testing" +) + +func TestFixedUniqueString(t *testing.T) { + a := "example" + b := "example1" + c := "example" + + strA1, err := FixedUniqueString(a, 8, "") + strB1, err := FixedUniqueString(b, 8, "") + strC1, err := FixedUniqueString(c, 8, "") + if err != nil { + t.Logf("Error: %v", err.Error()) + return + } + if strA1 != strC1 { + t.Errorf("Expected strA1 and strC1 to be equal, got %s and %s", strA1, strC1) + } + if strA1 == strB1 { + t.Errorf("Expected strA1 and strB1 to be different, got %s and %s", strA1, strB1) + } + t.Logf("strA1 and strB1 are not equal, strA1: %s, strB1: %s", strA1, strB1) + t.Logf("strA1 and strC1 are equal,strA1: %s, strC1: %s", strA1, strC1) +} diff --git a/pkg/updater/updater.go b/pkg/updater/updater.go new file mode 100644 index 0000000..ff87e36 --- /dev/null +++ b/pkg/updater/updater.go @@ -0,0 +1,382 @@ +package updater + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/perfect-panel/server/pkg/constant" +) + +const ( + githubAPIURL = "https://api.github.com/repos/OmnTeam/server/releases/latest" + githubRelURL = "https://github.com/OmnTeam/server/releases" +) + +// Release represents a GitHub release +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + Assets []Asset `json:"assets"` + HTMLURL string `json:"html_url"` +} + +// Asset represents a release asset +type Asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +// Updater handles auto-update functionality +type Updater struct { + CurrentVersion string + Owner string + Repo string + HTTPClient *http.Client +} + +// NewUpdater creates a new updater instance +func NewUpdater() *Updater { + return &Updater{ + CurrentVersion: constant.Version, + Owner: "OmnTeam", + Repo: "server", + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// CheckForUpdates checks if a new version is available +func (u *Updater) CheckForUpdates() (*Release, bool, error) { + req, err := http.NewRequest("GET", githubAPIURL, nil) + if err != nil { + return nil, false, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := u.HTTPClient.Do(req) + if err != nil { + return nil, false, fmt.Errorf("failed to fetch release info: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, false, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var release Release + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, false, fmt.Errorf("failed to decode response: %w", err) + } + + // Skip draft and prerelease versions + if release.Draft || release.Prerelease { + return nil, false, nil + } + + // Compare versions + hasUpdate := u.compareVersions(release.TagName, u.CurrentVersion) + return &release, hasUpdate, nil +} + +// compareVersions compares two version strings +// Returns true if newVersion is newer than currentVersion +func (u *Updater) compareVersions(newVersion, currentVersion string) bool { + // Remove 'v' prefix if present + newVersion = strings.TrimPrefix(newVersion, "v") + currentVersion = strings.TrimPrefix(currentVersion, "v") + + // Handle "unknown version" case + if currentVersion == "unknown version" || currentVersion == "" { + return true + } + + return newVersion != currentVersion +} + +// Download downloads the appropriate binary for the current platform +func (u *Updater) Download(release *Release) (string, error) { + assetName := u.getAssetName() + + var targetAsset *Asset + for _, asset := range release.Assets { + if asset.Name == assetName { + targetAsset = &asset + break + } + } + + if targetAsset == nil { + return "", fmt.Errorf("no suitable asset found for %s", assetName) + } + + // Create temp directory + tempDir, err := os.MkdirTemp("", "ppanel-update-*") + if err != nil { + return "", fmt.Errorf("failed to create temp directory: %w", err) + } + + // Download the file + resp, err := u.HTTPClient.Get(targetAsset.BrowserDownloadURL) + if err != nil { + os.RemoveAll(tempDir) + return "", fmt.Errorf("failed to download asset: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + os.RemoveAll(tempDir) + return "", fmt.Errorf("failed to download: status code %d", resp.StatusCode) + } + + // Read the entire file into memory + data, err := io.ReadAll(resp.Body) + if err != nil { + os.RemoveAll(tempDir) + return "", fmt.Errorf("failed to read download: %w", err) + } + + // Extract the binary + binaryPath, err := u.extractBinary(data, tempDir, assetName) + if err != nil { + os.RemoveAll(tempDir) + return "", fmt.Errorf("failed to extract binary: %w", err) + } + + return binaryPath, nil +} + +// getAssetName returns the expected asset name for the current platform +func (u *Updater) getAssetName() string { + goos := runtime.GOOS + goarch := runtime.GOARCH + + // Capitalize first letter of OS + osName := strings.Title(goos) + + // Map architecture names to match goreleaser output + archName := goarch + switch goarch { + case "amd64": + archName = "x86_64" + case "386": + archName = "i386" + } + + // Format: ppanel-server-{Version}-{Os}-{Arch}.{ext} + ext := "tar.gz" + if goos == "windows" { + ext = "zip" + } + + return fmt.Sprintf("ppanel-server-%s-%s-%s.%s", u.CurrentVersion, osName, archName, ext) +} + +// extractBinary extracts the binary from the downloaded archive +func (u *Updater) extractBinary(data []byte, destDir, assetName string) (string, error) { + if strings.HasSuffix(assetName, ".zip") { + return u.extractZip(data, destDir) + } + return u.extractTarGz(data, destDir) +} + +// extractZip extracts a zip archive +func (u *Updater) extractZip(data []byte, destDir string) (string, error) { + reader := bytes.NewReader(data) + zipReader, err := zip.NewReader(reader, int64(len(data))) + if err != nil { + return "", fmt.Errorf("failed to create zip reader: %w", err) + } + + var binaryPath string + for _, file := range zipReader.File { + // Look for the binary file + if strings.Contains(file.Name, "ppanel-server") && !strings.Contains(file.Name, "/") { + binaryPath = filepath.Join(destDir, filepath.Base(file.Name)) + + rc, err := file.Open() + if err != nil { + return "", fmt.Errorf("failed to open file in zip: %w", err) + } + defer rc.Close() + + outFile, err := os.OpenFile(binaryPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return "", fmt.Errorf("failed to create output file: %w", err) + } + defer outFile.Close() + + if _, err := io.Copy(outFile, rc); err != nil { + return "", fmt.Errorf("failed to write file: %w", err) + } + + return binaryPath, nil + } + } + + return "", fmt.Errorf("binary not found in archive") +} + +// extractTarGz extracts a tar.gz archive +func (u *Updater) extractTarGz(data []byte, destDir string) (string, error) { + reader := bytes.NewReader(data) + gzReader, err := gzip.NewReader(reader) + if err != nil { + return "", fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + + var binaryPath string + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return "", fmt.Errorf("failed to read tar: %w", err) + } + + // Look for the binary file + if strings.Contains(header.Name, "ppanel-server") && !strings.Contains(header.Name, "/") { + binaryPath = filepath.Join(destDir, filepath.Base(header.Name)) + + outFile, err := os.OpenFile(binaryPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return "", fmt.Errorf("failed to create output file: %w", err) + } + defer outFile.Close() + + if _, err := io.Copy(outFile, tarReader); err != nil { + return "", fmt.Errorf("failed to write file: %w", err) + } + + return binaryPath, nil + } + } + + return "", fmt.Errorf("binary not found in archive") +} + +// Apply applies the update by replacing the current binary +func (u *Updater) Apply(newBinaryPath string) error { + // Get current executable path + currentPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get current executable path: %w", err) + } + + // Resolve symlinks + currentPath, err = filepath.EvalSymlinks(currentPath) + if err != nil { + return fmt.Errorf("failed to resolve symlinks: %w", err) + } + + // Create backup + backupPath := currentPath + ".backup" + if err := u.copyFile(currentPath, backupPath); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + + // Replace the binary + if err := u.replaceFile(newBinaryPath, currentPath); err != nil { + // Restore backup on failure + u.copyFile(backupPath, currentPath) + return fmt.Errorf("failed to replace binary: %w", err) + } + + // Remove backup on success + os.Remove(backupPath) + + return nil +} + +// copyFile copies a file from src to dst +func (u *Updater) copyFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return err + } + defer dstFile.Close() + + if _, err := io.Copy(dstFile, srcFile); err != nil { + return err + } + + return dstFile.Sync() +} + +// replaceFile replaces dst with src +func (u *Updater) replaceFile(src, dst string) error { + // On Windows, we need to rename the old file first + if runtime.GOOS == "windows" { + oldPath := dst + ".old" + if err := os.Rename(dst, oldPath); err != nil { + return err + } + defer os.Remove(oldPath) + } + + // Copy the new file + if err := u.copyFile(src, dst); err != nil { + return err + } + + return nil +} + +// Update performs the complete update process +func (u *Updater) Update() error { + // Check for updates + release, hasUpdate, err := u.CheckForUpdates() + if err != nil { + return fmt.Errorf("failed to check for updates: %w", err) + } + + if !hasUpdate { + return fmt.Errorf("already running the latest version") + } + + fmt.Printf("New version available: %s\n", release.TagName) + fmt.Printf("Downloading update...\n") + + // Download the update + binaryPath, err := u.Download(release) + if err != nil { + return fmt.Errorf("failed to download update: %w", err) + } + defer os.RemoveAll(filepath.Dir(binaryPath)) + + fmt.Printf("Applying update...\n") + + // Apply the update + if err := u.Apply(binaryPath); err != nil { + return fmt.Errorf("failed to apply update: %w", err) + } + + fmt.Printf("Update completed successfully! Please restart the application.\n") + return nil +} diff --git a/pkg/updater/updater_test.go b/pkg/updater/updater_test.go new file mode 100644 index 0000000..bc703a3 --- /dev/null +++ b/pkg/updater/updater_test.go @@ -0,0 +1,74 @@ +package updater + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewUpdater(t *testing.T) { + u := NewUpdater() + assert.NotNil(t, u) + assert.Equal(t, "OmnTeam", u.Owner) + assert.Equal(t, "server", u.Repo) + assert.NotNil(t, u.HTTPClient) +} + +func TestCompareVersions(t *testing.T) { + u := NewUpdater() + + tests := []struct { + name string + newVersion string + currentVersion string + expected bool + }{ + { + name: "same version", + newVersion: "v1.0.0", + currentVersion: "v1.0.0", + expected: false, + }, + { + name: "different version", + newVersion: "v1.1.0", + currentVersion: "v1.0.0", + expected: true, + }, + { + name: "unknown current version", + newVersion: "v1.0.0", + currentVersion: "unknown version", + expected: true, + }, + { + name: "version without v prefix", + newVersion: "1.1.0", + currentVersion: "1.0.0", + expected: true, + }, + { + name: "empty current version", + newVersion: "v1.0.0", + currentVersion: "", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := u.compareVersions(tt.newVersion, tt.currentVersion) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetAssetName(t *testing.T) { + u := NewUpdater() + u.CurrentVersion = "v1.0.0" + + assetName := u.getAssetName() + assert.NotEmpty(t, assetName) + assert.Contains(t, assetName, "ppanel-server") + assert.Contains(t, assetName, "v1.0.0") +} diff --git a/pkg/xerr/errCode.go b/pkg/xerr/errCode.go index 3b1c601..24756ce 100644 --- a/pkg/xerr/errCode.go +++ b/pkg/xerr/errCode.go @@ -18,16 +18,17 @@ const ( // User error const ( - UserExist uint32 = 20001 - UserNotExist uint32 = 20002 - UserPasswordError uint32 = 20003 - UserDisabled uint32 = 20004 - InsufficientBalance uint32 = 20005 - StopRegister uint32 = 20006 - TelegramNotBound uint32 = 20007 - UserNotBindOauth uint32 = 20008 - InviteCodeError uint32 = 20009 - RegisterIPLimit uint32 = 20010 + UserExist uint32 = 20001 + UserNotExist uint32 = 20002 + UserPasswordError uint32 = 20003 + UserDisabled uint32 = 20004 + InsufficientBalance uint32 = 20005 + StopRegister uint32 = 20006 + TelegramNotBound uint32 = 20007 + UserNotBindOauth uint32 = 20008 + InviteCodeError uint32 = 20009 + UserCommissionNotEnough uint32 = 20010 + RegisterIPLimit uint32 = 20011 ) // Node error