Merge branch perfect-panel/master/server into develop

This commit is contained in:
EUForest 2025-12-11 23:53:32 +08:00
parent 5e46357104
commit 76ff9a658d
60 changed files with 1897 additions and 84 deletions

View File

@ -127,12 +127,12 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) {
HopPorts: protocol.HopPorts, HopPorts: protocol.HopPorts,
HopInterval: protocol.HopInterval, HopInterval: protocol.HopInterval,
ObfsPassword: protocol.ObfsPassword, ObfsPassword: protocol.ObfsPassword,
UpMbps: protocol.UpMbps,
DownMbps: protocol.DownMbps,
DisableSNI: protocol.DisableSNI, DisableSNI: protocol.DisableSNI,
ReduceRtt: protocol.ReduceRtt, ReduceRtt: protocol.ReduceRtt,
UDPRelayMode: protocol.UDPRelayMode, UDPRelayMode: protocol.UDPRelayMode,
CongestionController: protocol.CongestionController, CongestionController: protocol.CongestionController,
UpMbps: protocol.UpMbps,
DownMbps: protocol.DownMbps,
PaddingScheme: protocol.PaddingScheme, PaddingScheme: protocol.PaddingScheme,
Multiplex: protocol.Multiplex, Multiplex: protocol.Multiplex,
XhttpMode: protocol.XhttpMode, XhttpMode: protocol.XhttpMode,
@ -145,6 +145,10 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) {
EncryptionPrivateKey: protocol.EncryptionPrivateKey, EncryptionPrivateKey: protocol.EncryptionPrivateKey,
EncryptionClientPadding: protocol.EncryptionClientPadding, EncryptionClientPadding: protocol.EncryptionClientPadding,
EncryptionPassword: protocol.EncryptionPassword, EncryptionPassword: protocol.EncryptionPassword,
Ratio: protocol.Ratio,
CertMode: protocol.CertMode,
CertDNSProvider: protocol.CertDNSProvider,
CertDNSEnv: protocol.CertDNSEnv,
}) })
} }
} }

View File

@ -102,6 +102,9 @@ type (
BatchDeleteSubscribeRequest { BatchDeleteSubscribeRequest {
Ids []int64 `json:"ids" validate:"required"` Ids []int64 `json:"ids" validate:"required"`
} }
ResetAllSubscribeTokenResponse {
Success bool `json:"success"`
}
) )
@server ( @server (
@ -157,5 +160,9 @@ service ppanel {
@doc "Subscribe sort" @doc "Subscribe sort"
@handler SubscribeSort @handler SubscribeSort
post /sort (SubscribeSortRequest) post /sort (SubscribeSortRequest)
@doc "Reset all subscribe tokens"
@handler ResetAllSubscribeToken
post /reset_all_token returns (ResetAllSubscribeTokenResponse)
} }

View File

@ -22,6 +22,11 @@ type (
CurrentTime string `json:"current_time"` CurrentTime string `json:"current_time"`
Ratio float32 `json:"ratio"` Ratio float32 `json:"ratio"`
} }
ModuleConfig {
Secret string `json:"secret"` // 通讯密钥
ServiceName string `json:"service_name"` // 服务名称
ServiceVersion string `json:"service_version"` // 服务版本
}
) )
@server ( @server (
@ -125,5 +130,9 @@ service ppanel {
@doc "PreView Node Multiplier" @doc "PreView Node Multiplier"
@handler PreViewNodeMultiplier @handler PreViewNodeMultiplier
get /node_multiplier/preview returns (PreViewNodeMultiplierResponse) get /node_multiplier/preview returns (PreViewNodeMultiplierResponse)
@doc "Get Module Config"
@handler GetModuleConfig
get /module returns (ModuleConfig)
} }

View File

@ -17,6 +17,14 @@ type (
VersionResponse { VersionResponse {
Version string `json:"version"` 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 ( @server (
@ -36,5 +44,9 @@ service ppanel {
@doc "Get Version" @doc "Get Version"
@handler GetVersion @handler GetVersion
get /version returns (VersionResponse) get /version returns (VersionResponse)
@doc "Query IP Location"
@handler QueryIPLocation
get /ip/location (QueryIPLocationRequest) returns (QueryIPLocationResponse)
} }

View File

@ -87,6 +87,11 @@ type (
Total int64 `json:"total"` Total int64 `json:"total"`
List []SubscribeClient `json:"list"` List []SubscribeClient `json:"list"`
} }
HeartbeatResponse {
Status bool `json:"status"`
Message string `json:"message,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
}
) )
@server ( @server (
@ -130,5 +135,9 @@ service ppanel {
@doc "Get Client" @doc "Get Client"
@handler GetClient @handler GetClient
get /client returns (GetSubscribeClientResponse) get /client returns (GetSubscribeClientResponse)
@doc "Heartbeat"
@handler Heartbeat
get /heartbeat returns (HeartbeatResponse)
} }

View File

@ -14,11 +14,9 @@ type (
QuerySubscribeListRequest { QuerySubscribeListRequest {
Language string `form:"language"` Language string `form:"language"`
} }
QueryUserSubscribeNodeListResponse { QueryUserSubscribeNodeListResponse {
List []UserSubscribeInfo `json:"list"` List []UserSubscribeInfo `json:"list"`
} }
UserSubscribeInfo { UserSubscribeInfo {
Id int64 `json:"id"` Id int64 `json:"id"`
UserId int64 `json:"user_id"` UserId int64 `json:"user_id"`

View File

@ -123,6 +123,36 @@ type (
LongestSingleConnection int64 `json:"longest_single_connection"` 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 ( @server (
@ -234,6 +264,22 @@ service ppanel {
@doc "Delete Current User Account" @doc "Delete Current User Account"
@handler DeleteCurrentUserAccount @handler DeleteCurrentUserAccount
delete /current_user_account 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)
} }

View File

@ -28,6 +28,7 @@ type (
EnableTradeNotify bool `json:"enable_trade_notify"` EnableTradeNotify bool `json:"enable_trade_notify"`
AuthMethods []UserAuthMethod `json:"auth_methods"` AuthMethods []UserAuthMethod `json:"auth_methods"`
UserDevices []UserDevice `json:"user_devices"` UserDevices []UserDevice `json:"user_devices"`
Rules []string `json:"rules"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
DeletedAt int64 `json:"deleted_at,omitempty"` DeletedAt int64 `json:"deleted_at,omitempty"`
@ -135,14 +136,12 @@ type (
EnableDomainSuffix bool `json:"enable_domain_suffix"` EnableDomainSuffix bool `json:"enable_domain_suffix"`
DomainSuffixList string `json:"domain_suffix_list"` DomainSuffixList string `json:"domain_suffix_list"`
} }
DeviceAuthticateConfig { DeviceAuthticateConfig {
Enable bool `json:"enable"` Enable bool `json:"enable"`
ShowAds bool `json:"show_ads"` ShowAds bool `json:"show_ads"`
EnableSecurity bool `json:"enable_security"` EnableSecurity bool `json:"enable_security"`
OnlyRealDevice bool `json:"only_real_device"` OnlyRealDevice bool `json:"only_real_device"`
} }
RegisterConfig { RegisterConfig {
StopRegister bool `json:"stop_register"` StopRegister bool `json:"stop_register"`
EnableTrial bool `json:"enable_trial"` EnableTrial bool `json:"enable_trial"`
@ -472,6 +471,7 @@ type (
Upload int64 `json:"upload"` Upload int64 `json:"upload"`
Token string `json:"token"` Token string `json:"token"`
Status uint8 `json:"status"` Status uint8 `json:"status"`
Short string `json:"short"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
@ -656,7 +656,7 @@ type (
// public announcement // public announcement
QueryAnnouncementRequest { QueryAnnouncementRequest {
Page int `form:"page"` Page int `form:"page"`
Size int `form:"size,default=15"` Size int `form:"size"`
Pinned *bool `form:"pinned"` Pinned *bool `form:"pinned"`
Popup *bool `form:"popup"` Popup *bool `form:"popup"`
} }
@ -673,7 +673,6 @@ type (
List []SubscribeGroup `json:"list"` List []SubscribeGroup `json:"list"`
Total int64 `json:"total"` Total int64 `json:"total"`
} }
GetUserSubscribeTrafficLogsRequest { GetUserSubscribeTrafficLogsRequest {
Page int `form:"page"` Page int `form:"page"`
Size int `form:"size"` Size int `form:"size"`

BIN
cache/GeoLite2-City.mmdb vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 MiB

73
cmd/update.go Normal file
View File

@ -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.")
}

2
go.mod
View File

@ -60,6 +60,7 @@ require (
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
github.com/oschwald/geoip2-golang v1.13.0
github.com/spaolacci/murmur3 v1.1.0 github.com/spaolacci/murmur3 v1.1.0
google.golang.org/grpc v1.64.1 google.golang.org/grpc v1.64.1
google.golang.org/protobuf v1.36.3 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/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/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect
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

4
go.sum
View File

@ -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/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 h1:zjqfqHjUpPmB3c1GlCvvgsM1G4LkvqQbBDueDOCg/jA=
github.com/openzipkin/zipkin-go v0.4.2/go.mod h1:ZeVkFjuuBiSy13y8vpSDCjMi9GoI3hPpCJSBx/EYFhY= 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 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=

View File

@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"os" "os"
"github.com/perfect-panel/server/internal/report"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
@ -35,10 +36,30 @@ func Config(path string) (chan bool, *http.Server) {
configPath = path configPath = path
// Create a new Gin instance // Create a new Gin instance
r := gin.Default() 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 // Create a new HTTP server
server := &http.Server{ server := &http.Server{
Addr: ":8080", Addr: fmt.Sprintf("%s:%d", host, port),
Handler: r, Handler: r,
} }
// Load templates // Load templates

View File

@ -0,0 +1,2 @@
ALTER TABLE `user_subscribe`
DROP COLUMN `note`;

View File

@ -0,0 +1,4 @@
ALTER TABLE `user_subscribe`
ADD COLUMN `note` VARCHAR(500) NOT NULL DEFAULT ''
COMMENT 'User note for subscription'
AFTER `status`;

View File

@ -0,0 +1,2 @@
ALTER TABLE `user`
DROP COLUMN IF EXISTS `rules`;

View File

@ -0,0 +1,4 @@
ALTER TABLE `user`
ADD COLUMN `rules` TEXT NULL
COMMENT 'User rules for subscription'
AFTER `created_at`;

View File

@ -0,0 +1,5 @@
DROP TABLE IF EXISTS `withdrawals`;
DELETE FROM `system`
WHERE `category` = 'invite'
AND `key` = 'WithdrawalMethod';

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,6 @@ import (
// Get Client // Get Client
func GetClientHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { func GetClientHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) { return func(c *gin.Context) {
l := common.NewGetClientLogic(c.Request.Context(), svcCtx) l := common.NewGetClientLogic(c.Request.Context(), svcCtx)
resp, err := l.GetClient() resp, err := l.GetClient()
result.HttpResult(c, resp, err) result.HttpResult(c, resp, err)

View File

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

View File

@ -0,0 +1,26 @@
package user
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/public/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// 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)
}
}

View File

@ -0,0 +1,26 @@
package user
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/public/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// 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)
}
}

View File

@ -0,0 +1,26 @@
package user
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/public/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// 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)
}
}

View File

@ -0,0 +1,26 @@
package user
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/public/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// 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)
}
}

View File

@ -386,6 +386,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Get subscribe list // Get subscribe list
adminSubscribeGroupRouter.GET("/list", adminSubscribe.GetSubscribeListHandler(serverCtx)) adminSubscribeGroupRouter.GET("/list", adminSubscribe.GetSubscribeListHandler(serverCtx))
// Reset all subscribe tokens
adminSubscribeGroupRouter.POST("/reset_all_token", adminSubscribe.ResetAllSubscribeTokenHandler(serverCtx))
// Subscribe sort // Subscribe sort
adminSubscribeGroupRouter.POST("/sort", adminSubscribe.SubscribeSortHandler(serverCtx)) adminSubscribeGroupRouter.POST("/sort", adminSubscribe.SubscribeSortHandler(serverCtx))
} }
@ -409,6 +412,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Update invite config // Update invite config
adminSystemGroupRouter.PUT("/invite_config", adminSystem.UpdateInviteConfigHandler(serverCtx)) adminSystemGroupRouter.PUT("/invite_config", adminSystem.UpdateInviteConfigHandler(serverCtx))
// Get Module Config
adminSystemGroupRouter.GET("/module", adminSystem.GetModuleConfigHandler(serverCtx))
// Get node config // Get node config
adminSystemGroupRouter.GET("/node_config", adminSystem.GetNodeConfigHandler(serverCtx)) adminSystemGroupRouter.GET("/node_config", adminSystem.GetNodeConfigHandler(serverCtx))
@ -488,6 +494,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
adminToolGroupRouter.Use(middleware.AuthMiddleware(serverCtx)) adminToolGroupRouter.Use(middleware.AuthMiddleware(serverCtx))
{ {
// Query IP Location
adminToolGroupRouter.GET("/ip/location", adminTool.QueryIPLocationHandler(serverCtx))
// Get System Log // Get System Log
adminToolGroupRouter.GET("/log", adminTool.GetSystemLogHandler(serverCtx)) adminToolGroupRouter.GET("/log", adminTool.GetSystemLogHandler(serverCtx))
@ -636,6 +645,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Get Client // Get Client
commonGroupRouter.GET("/client", common.GetClientHandler(serverCtx)) commonGroupRouter.GET("/client", common.GetClientHandler(serverCtx))
// Heartbeat
commonGroupRouter.GET("/heartbeat", common.HeartbeatHandler(serverCtx))
// Get verification code // Get verification code
commonGroupRouter.POST("/send_code", common.SendEmailCodeHandler(serverCtx)) commonGroupRouter.POST("/send_code", common.SendEmailCodeHandler(serverCtx))
@ -796,6 +808,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Query User Commission Log // Query User Commission Log
publicUserGroupRouter.GET("/commission_log", publicUser.QueryUserCommissionLogHandler(serverCtx)) publicUserGroupRouter.GET("/commission_log", publicUser.QueryUserCommissionLogHandler(serverCtx))
// Commission Withdraw
publicUserGroupRouter.POST("/commission_withdraw", publicUser.CommissionWithdrawHandler(serverCtx))
// Delete Current User Account // Delete Current User Account
publicUserGroupRouter.DELETE("/current_user_account", publicUser.DeleteCurrentUserAccountHandler(serverCtx)) publicUserGroupRouter.DELETE("/current_user_account", publicUser.DeleteCurrentUserAccountHandler(serverCtx))
@ -820,12 +835,18 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Update User Password // Update User Password
publicUserGroupRouter.PUT("/password", publicUser.UpdateUserPasswordHandler(serverCtx)) publicUserGroupRouter.PUT("/password", publicUser.UpdateUserPasswordHandler(serverCtx))
// Update User Rules
publicUserGroupRouter.PUT("/rules", publicUser.UpdateUserRulesHandler(serverCtx))
// Query User Subscribe // Query User Subscribe
publicUserGroupRouter.GET("/subscribe", publicUser.QueryUserSubscribeHandler(serverCtx)) publicUserGroupRouter.GET("/subscribe", publicUser.QueryUserSubscribeHandler(serverCtx))
// Get Subscribe Log // Get Subscribe Log
publicUserGroupRouter.GET("/subscribe_log", publicUser.GetSubscribeLogHandler(serverCtx)) publicUserGroupRouter.GET("/subscribe_log", publicUser.GetSubscribeLogHandler(serverCtx))
// Update User Subscribe Note
publicUserGroupRouter.PUT("/subscribe_note", publicUser.UpdateUserSubscribeNoteHandler(serverCtx))
// Reset User Subscribe Token // Reset User Subscribe Token
publicUserGroupRouter.PUT("/subscribe_token", publicUser.ResetUserSubscribeTokenHandler(serverCtx)) publicUserGroupRouter.PUT("/subscribe_token", publicUser.ResetUserSubscribeTokenHandler(serverCtx))
@ -846,6 +867,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Verify Email // Verify Email
publicUserGroupRouter.POST("/verify_email", publicUser.VerifyEmailHandler(serverCtx)) publicUserGroupRouter.POST("/verify_email", publicUser.VerifyEmailHandler(serverCtx))
// Query Withdrawal Log
publicUserGroupRouter.GET("/withdrawal_log", publicUser.QueryWithdrawalLogHandler(serverCtx))
} }
publicUserWsGroupRouter := router.Group("/v1/public/user") publicUserWsGroupRouter := router.Group("/v1/public/user")

View File

@ -23,6 +23,22 @@ func SubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
ua := c.GetHeader("User-Agent") ua := c.GetHeader("User-Agent")
req.UA = c.Request.Header.Get("User-Agent") req.UA = c.Request.Header.Get("User-Agent")
req.Flag = c.Query("flag") 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 svcCtx.Config.Subscribe.UserAgentLimit {
if ua == "" { if ua == "" {

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"github.com/perfect-panel/server/internal/report"
paymentPlatform "github.com/perfect-panel/server/pkg/payment" paymentPlatform "github.com/perfect-panel/server/pkg/payment"
"github.com/perfect-panel/server/internal/model/payment" "github.com/perfect-panel/server/internal/model/payment"
@ -43,15 +44,31 @@ func (l *GetPaymentMethodListLogic) GetPaymentMethodList(req *types.GetPaymentMe
Total: total, Total: total,
List: make([]types.PaymentMethodDetail, len(list)), List: make([]types.PaymentMethodDetail, len(list)),
} }
// gateway mod
isGatewayMod := report.IsGatewayMode()
for i, v := range list { for i, v := range list {
config := make(map[string]interface{}) config := make(map[string]interface{})
_ = json.Unmarshal([]byte(v.Config), &config) _ = json.Unmarshal([]byte(v.Config), &config)
notifyUrl := "" notifyUrl := ""
if paymentPlatform.ParsePlatform(v.Platform) != paymentPlatform.Balance { if paymentPlatform.ParsePlatform(v.Platform) != paymentPlatform.Balance {
notifyUrl = v.Domain
if 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 { } 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{ resp.List[i] = types.PaymentMethodDetail{

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/report"
"github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/constant"
paymentPlatform "github.com/perfect-panel/server/pkg/payment" paymentPlatform "github.com/perfect-panel/server/pkg/payment"
@ -275,16 +276,29 @@ func (l *PurchaseCheckoutLogic) epayPayment(config *payment.Payment, info *order
return "", err return "", err
} }
// gateway mod
isGatewayMod := report.IsGatewayMode()
// Build notification URL for payment status callbacks // Build notification URL for payment status callbacks
notifyUrl := "" notifyUrl := ""
if config.Domain != "" { 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 { } else {
host, ok := l.ctx.Value(constant.CtxKeyRequestHost).(string) host, ok := l.ctx.Value(constant.CtxKeyRequestHost).(string)
if !ok { if !ok {
host = l.svcCtx.Config.Host 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 // Create payment URL for user redirection
@ -317,18 +331,29 @@ func (l *PurchaseCheckoutLogic) CryptoSaaSPayment(config *payment.Payment, info
return "", err return "", err
} }
// gateway mod
isGatewayMod := report.IsGatewayMode()
// Build notification URL for payment status callbacks // Build notification URL for payment status callbacks
notifyUrl := "" notifyUrl := ""
if config.Domain != "" { 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 { } else {
host, ok := l.ctx.Value(constant.CtxKeyRequestHost).(string) host, ok := l.ctx.Value(constant.CtxKeyRequestHost).(string)
if !ok { if !ok {
host = l.svcCtx.Config.Host 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 // Create payment URL for user redirection
url := client.CreatePayUrl(epay.Order{ url := client.CreatePayUrl(epay.Order{
Name: l.svcCtx.Config.Site.SiteName, Name: l.svcCtx.Config.Site.SiteName,

View File

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

View File

@ -60,6 +60,8 @@ func (l *QueryUserSubscribeLogic) QueryUserSubscribe() (resp *types.QueryUserSub
} }
} }
short, _ := tool.FixedUniqueString(item.Token, 8, "")
sub.Short = short
sub.ResetTime = calculateNextResetTime(&sub) sub.ResetTime = calculateNextResetTime(&sub)
resp.List = append(resp.List, sub) resp.List = append(resp.List, sub)
} }

View File

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

View File

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

View File

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

View File

@ -46,8 +46,9 @@ const (
CommissionTypePurchase uint16 = 331 // Purchase CommissionTypePurchase uint16 = 331 // Purchase
CommissionTypeRenewal uint16 = 332 // Renewal CommissionTypeRenewal uint16 = 332 // Renewal
CommissionTypeRefund uint16 = 333 // Refund CommissionTypeRefund uint16 = 333 // Refund
commissionTypeWithdraw uint16 = 334 // withdraw CommissionTypeWithdraw uint16 = 334 // withdraw
CommissionTypeAdjust uint16 = 335 // Admin Adjust CommissionTypeAdjust uint16 = 335 // Admin Adjust
CommissionTypeConvertBalance uint16 = 336 // Convert to Balance
GiftTypeIncrease uint16 = 341 // Increase GiftTypeIncrease uint16 = 341 // Increase
GiftTypeReduce uint16 = 342 // Reduce GiftTypeReduce uint16 = 342 // Reduce
) )

View File

@ -36,6 +36,7 @@ type SubscribeDetails struct {
Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"` Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"`
UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"` UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"`
Status uint8 `gorm:"type:tinyint(1);default:0;comment:Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired; 4: Cancelled"` 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"` CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"` UpdatedAt time.Time `gorm:"comment:Update Time"`
} }

View File

@ -25,6 +25,7 @@ type User struct {
EnableTradeNotify *bool `gorm:"default:false;not null;comment:Enable Trade Notifications"` EnableTradeNotify *bool `gorm:"default:false;not null;comment:Enable Trade Notifications"`
AuthMethods []AuthMethods `gorm:"foreignKey:UserId;references:Id"` AuthMethods []AuthMethods `gorm:"foreignKey:UserId;references:Id"`
UserDevices []Device `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"` CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
UpdatedAt time.Time `gorm:"comment:Update 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"` Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"`
UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"` UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"`
Status uint8 `gorm:"type:tinyint(1);default:0;comment:Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired 4: Deducted"` 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"` CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"` UpdatedAt time.Time `gorm:"comment:Update Time"`
} }
@ -100,3 +102,18 @@ type DeviceOnlineRecord struct {
func (DeviceOnlineRecord) TableName() string { func (DeviceOnlineRecord) TableName() string {
return "user_device_online_record" 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"
}

15
internal/report/report.go Normal file
View File

@ -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"` // 响应数据
}

113
internal/report/tool.go Normal file
View File

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

View File

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

11
internal/report/types.go Normal file
View File

@ -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"` // 服务版本
}

View File

@ -6,8 +6,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"os"
"time" "time"
"github.com/perfect-panel/server/internal/report"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/proc" "github.com/perfect-panel/server/pkg/proc"
@ -48,7 +50,7 @@ func initServer(svc *svc.ServiceContext) *gin.Engine {
} }
r.Use(sessions.Sessions("ppanel", sessionStore)) r.Use(sessions.Sessions("ppanel", sessionStore))
// use cors middleware // 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 // register handlers
handler.RegisterHandlers(r, svc) handler.RegisterHandlers(r, svc)
@ -65,9 +67,32 @@ func (m *Service) Start() {
if m.svc == nil { if m.svc == nil {
panic("config file path is nil") panic("config file path is nil")
} }
// init service // init service
r := initServer(m.svc) 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{ m.server = &http.Server{
Addr: serverAddr, Addr: serverAddr,
Handler: r, Handler: r,

74
internal/svc/mmdb.go Normal file
View File

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

View File

@ -37,6 +37,7 @@ type ServiceContext struct {
Config config.Config Config config.Config
Queue *asynq.Client Queue *asynq.Client
ExchangeRate float64 ExchangeRate float64
GeoIP *IPLocation
//NodeCache *cache.NodeCacheClient //NodeCache *cache.NodeCacheClient
AuthModel auth.Model AuthModel auth.Model
@ -68,9 +69,17 @@ func NewServiceContext(c config.Config) *ServiceContext {
db, err := orm.ConnectMysql(orm.Mysql{ db, err := orm.ConnectMysql(orm.Mysql{
Config: c.MySQL, Config: c.MySQL,
}) })
if err != nil { if err != nil {
panic(err.Error()) panic(err.Error())
} }
// IP location initialize
geoIP, err := NewIPLocation("./cache/GeoLite2-City.mmdb")
if err != nil {
panic(err.Error())
}
rds := redis.NewClient(&redis.Options{ rds := redis.NewClient(&redis.Options{
Addr: c.Redis.Host, Addr: c.Redis.Host,
Password: c.Redis.Pass, Password: c.Redis.Pass,
@ -89,6 +98,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
Config: c, Config: c,
Queue: NewAsynqClient(c), Queue: NewAsynqClient(c),
ExchangeRate: 1.0, ExchangeRate: 1.0,
GeoIP: geoIP,
//NodeCache: cache.NewNodeCacheClient(rds), //NodeCache: cache.NewNodeCacheClient(rds),
AuthLimiter: authLimiter, AuthLimiter: authLimiter,
AdsModel: ads.NewModel(db, rds), AdsModel: ads.NewModel(db, rds),

View File

@ -239,6 +239,11 @@ type CommissionLog struct {
Timestamp int64 `json:"timestamp"` Timestamp int64 `json:"timestamp"`
} }
type CommissionWithdrawRequest struct {
Amount int64 `json:"amount"`
Content string `json:"content"`
}
type ConnectionRecords struct { type ConnectionRecords struct {
CurrentContinuousDays int64 `json:"current_continuous_days"` CurrentContinuousDays int64 `json:"current_continuous_days"`
HistoryContinuousDays int64 `json:"history_continuous_days"` HistoryContinuousDays int64 `json:"history_continuous_days"`
@ -1169,6 +1174,12 @@ type HasMigrateSeverNodeResponse struct {
HasMigrate bool `json:"has_migrate"` 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 { type Hysteria2 struct {
Port int `json:"port" validate:"required"` Port int `json:"port" validate:"required"`
HopPorts string `json:"hop_ports" validate:"required"` HopPorts string `json:"hop_ports" validate:"required"`
@ -1232,6 +1243,12 @@ type MobileAuthenticateConfig struct {
Whitelist []string `json:"whitelist"` Whitelist []string `json:"whitelist"`
} }
type ModuleConfig struct {
Secret string `json:"secret"` // 通讯密钥
ServiceName string `json:"service_name"` // 服务名称
ServiceVersion string `json:"service_version"` // 服务版本
}
type Node struct { type Node struct {
Id int64 `json:"id"` Id int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -1551,7 +1568,7 @@ type PurchaseOrderResponse struct {
type QueryAnnouncementRequest struct { type QueryAnnouncementRequest struct {
Page int `form:"page"` Page int `form:"page"`
Size int `form:"size,default=15"` Size int `form:"size"`
Pinned *bool `form:"pinned"` Pinned *bool `form:"pinned"`
Popup *bool `form:"popup"` Popup *bool `form:"popup"`
} }
@ -1570,6 +1587,16 @@ type QueryDocumentListResponse struct {
List []Document `json:"list"` 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 { type QueryNodeTagResponse struct {
Tags []string `json:"tags"` Tags []string `json:"tags"`
} }
@ -1712,6 +1739,16 @@ type QueryUserSubscribeNodeListResponse struct {
List []UserSubscribeInfo `json:"list"` 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 { type QuotaTask struct {
Id int64 `json:"id"` Id int64 `json:"id"`
Subscribers []int64 `json:"subscribers"` Subscribers []int64 `json:"subscribers"`
@ -1771,6 +1808,10 @@ type RenewalOrderResponse struct {
OrderNo string `json:"order_no"` OrderNo string `json:"order_no"`
} }
type ResetAllSubscribeTokenResponse struct {
Success bool `json:"success"`
}
type ResetPasswordRequest struct { type ResetPasswordRequest struct {
Identifier string `json:"identifier"` Identifier string `json:"identifier"`
Email string `json:"email" validate:"required"` Email string `json:"email" validate:"required"`
@ -2464,6 +2505,15 @@ type UpdateUserPasswordRequest struct {
Password string `json:"password" validate:"required"` 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 { type UpdateUserSubscribeRequest struct {
UserSubscribeId int64 `json:"user_subscribe_id"` UserSubscribeId int64 `json:"user_subscribe_id"`
SubscribeId int64 `json:"subscribe_id"` SubscribeId int64 `json:"subscribe_id"`
@ -2497,6 +2547,7 @@ type User struct {
EnableTradeNotify bool `json:"enable_trade_notify"` EnableTradeNotify bool `json:"enable_trade_notify"`
AuthMethods []UserAuthMethod `json:"auth_methods"` AuthMethods []UserAuthMethod `json:"auth_methods"`
UserDevices []UserDevice `json:"user_devices"` UserDevices []UserDevice `json:"user_devices"`
Rules []string `json:"rules"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
DeletedAt int64 `json:"deleted_at,omitempty"` DeletedAt int64 `json:"deleted_at,omitempty"`
@ -2587,6 +2638,7 @@ type UserSubscribe struct {
Upload int64 `json:"upload"` Upload int64 `json:"upload"`
Token string `json:"token"` Token string `json:"token"`
Status uint8 `json:"status"` Status uint8 `json:"status"`
Short string `json:"short"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
@ -2751,3 +2803,14 @@ type WeeklyStat struct {
DayName string `json:"day_name"` DayName string `json:"day_name"`
Hours float64 `json:"hours"` 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"`
}

View File

@ -4,4 +4,6 @@ package constant
var ( var (
Version = "unknown version" Version = "unknown version"
BuildTime = "unknown time" BuildTime = "unknown time"
Repository = "https://github.com/perfect-panel/server"
ServiceName = "ApiService"
) )

38
pkg/tool/string.go Normal file
View File

@ -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 (FisherYates)
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
}

27
pkg/tool/string_test.go Normal file
View File

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

382
pkg/updater/updater.go Normal file
View File

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

View File

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

View File

@ -27,7 +27,8 @@ const (
TelegramNotBound uint32 = 20007 TelegramNotBound uint32 = 20007
UserNotBindOauth uint32 = 20008 UserNotBindOauth uint32 = 20008
InviteCodeError uint32 = 20009 InviteCodeError uint32 = 20009
RegisterIPLimit uint32 = 20010 UserCommissionNotEnough uint32 = 20010
RegisterIPLimit uint32 = 20011
) )
// Node error // Node error