This commit is contained in:
2026-01-31 08:30:58 -08:00
parent 16c261bd36
commit af5231747a
16 changed files with 895 additions and 24 deletions
+30 -23
View File
@@ -9,29 +9,30 @@ import (
)
type Config struct {
Model string `yaml:"Model" default:"prod"`
Host string `yaml:"Host" default:"0.0.0.0"`
Port int `yaml:"Port" default:"8080"`
Debug bool `yaml:"Debug" default:"false"`
TLS TLS `yaml:"TLS"`
JwtAuth JwtAuth `yaml:"JwtAuth"`
Logger logger.LogConf `yaml:"Logger"`
MySQL orm.Config `yaml:"MySQL"`
Redis RedisConfig `yaml:"Redis"`
Site SiteConfig `yaml:"Site"`
Node NodeConfig `yaml:"Node"`
Mobile MobileConfig `yaml:"Mobile"`
Email EmailConfig `yaml:"Email"`
Device DeviceConfig `yaml:"device"`
Verify Verify `yaml:"Verify"`
VerifyCode VerifyCode `yaml:"VerifyCode"`
Register RegisterConfig `yaml:"Register"`
Subscribe SubscribeConfig `yaml:"Subscribe"`
Invite InviteConfig `yaml:"Invite"`
Kutt KuttConfig `yaml:"Kutt"`
Telegram Telegram `yaml:"Telegram"`
Log Log `yaml:"Log"`
Trace trace.Config `yaml:"Trace"`
Model string `yaml:"Model" default:"prod"`
Host string `yaml:"Host" default:"0.0.0.0"`
Port int `yaml:"Port" default:"8080"`
Debug bool `yaml:"Debug" default:"false"`
TLS TLS `yaml:"TLS"`
JwtAuth JwtAuth `yaml:"JwtAuth"`
Logger logger.LogConf `yaml:"Logger"`
MySQL orm.Config `yaml:"MySQL"`
Redis RedisConfig `yaml:"Redis"`
Site SiteConfig `yaml:"Site"`
Node NodeConfig `yaml:"Node"`
Mobile MobileConfig `yaml:"Mobile"`
Email EmailConfig `yaml:"Email"`
Device DeviceConfig `yaml:"device"`
Verify Verify `yaml:"Verify"`
VerifyCode VerifyCode `yaml:"VerifyCode"`
Register RegisterConfig `yaml:"Register"`
Subscribe SubscribeConfig `yaml:"Subscribe"`
Invite InviteConfig `yaml:"Invite"`
Kutt KuttConfig `yaml:"Kutt"`
OpenInstall OpenInstallConfig `yaml:"OpenInstall"`
Telegram Telegram `yaml:"Telegram"`
Log Log `yaml:"Log"`
Trace trace.Config `yaml:"Trace"`
Administrator struct {
Email string `yaml:"Email" default:"admin@ppanel.dev"`
Password string `yaml:"Password" default:"password"`
@@ -219,6 +220,12 @@ type KuttConfig struct {
Domain string `yaml:"Domain" default:""` // 短链接域名 (例如: getsapp.net)
}
// OpenInstallConfig OpenInstall 配置
type OpenInstallConfig struct {
Enable bool `yaml:"Enable" default:"false"` // 是否启用 OpenInstall
AppKey string `yaml:"AppKey" default:""` // OpenInstall AppKey
}
type Telegram struct {
Enable bool `yaml:"Enable" default:"false"`
BotID int64 `yaml:"BotID" default:""`
@@ -0,0 +1,26 @@
package user
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/public/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Get agent downloads data
func GetAgentDownloadsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.GetAgentDownloadsRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := user.NewGetAgentDownloadsLogic(c.Request.Context(), svcCtx)
resp, err := l.GetAgentDownloads(&req)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,26 @@
package user
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/public/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Get agent realtime data
func GetAgentRealtimeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.GetAgentRealtimeRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := user.NewGetAgentRealtimeLogic(c.Request.Context(), svcCtx)
resp, err := l.GetAgentRealtime(&req)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,30 @@
package user
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/public/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Get invite sales data
func GetInviteSalesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.GetInviteSalesRequest
if err := c.ShouldBind(&req); err != nil {
result.ParamErrorResult(c, err)
return
}
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := user.NewGetInviteSalesLogic(c.Request.Context(), svcCtx)
resp, err := l.GetInviteSales(&req)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,26 @@
package user
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/public/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Get user invite statistics
func GetUserInviteStatsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.GetUserInviteStatsRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := user.NewGetUserInviteStatsLogic(c.Request.Context(), svcCtx)
resp, err := l.GetUserInviteStats(&req)
result.HttpResult(c, resp, err)
}
}
+12
View File
@@ -895,6 +895,18 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Verify Email
publicUserGroupRouter.POST("/verify_email", publicUser.VerifyEmailHandler(serverCtx))
// Get agent realtime data
publicUserGroupRouter.GET("/agent/realtime", publicUser.GetAgentRealtimeHandler(serverCtx))
// Get agent downloads data
publicUserGroupRouter.GET("/agent/downloads", publicUser.GetAgentDownloadsHandler(serverCtx))
// Get user invite statistics
publicUserGroupRouter.GET("/invite/stats", publicUser.GetUserInviteStatsHandler(serverCtx))
// Get invite sales data
publicUserGroupRouter.GET("/invite/sales", publicUser.GetInviteSalesHandler(serverCtx))
}
serverGroupRouter := router.Group("/v1/server")
@@ -0,0 +1,76 @@
package user
import (
"context"
"time"
"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/openinstall"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
type GetAgentDownloadsLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetAgentDownloadsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAgentDownloadsLogic {
return &GetAgentDownloadsLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetAgentDownloadsLogic) GetAgentDownloads(req *types.GetAgentDownloadsRequest) (resp *types.GetAgentDownloadsResponse, err error) {
// 1. Get current user
_, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
l.Errorw("[GetAgentDownloads] user not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 2. Check configuration
cfg := l.svcCtx.Config.OpenInstall
if !cfg.Enable {
return &types.GetAgentDownloadsResponse{
List: []types.AgentDownloadStats{},
}, nil
}
// 3. Call OpenInstall API
// Default to last 30 days
endDate := time.Now()
startDate := endDate.AddDate(0, 0, -30)
client := openinstall.NewClient(cfg.AppKey)
stats, err := client.GetPlatformStats(l.ctx, startDate, endDate)
if err != nil {
l.Errorw("Failed to fetch OpenInstall stats", logger.Field("error", err))
// Return empty list on error
return &types.GetAgentDownloadsResponse{
List: []types.AgentDownloadStats{},
}, nil
}
// 4. Map response
var list []types.AgentDownloadStats
for _, s := range stats {
list = append(list, types.AgentDownloadStats{
Platform: s.Platform,
Clicks: s.Clicks,
Visits: s.Visits,
})
}
return &types.GetAgentDownloadsResponse{
List: list,
}, nil
}
@@ -0,0 +1,150 @@
package user
import (
"context"
"encoding/json"
"fmt"
"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/kutt"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
type GetAgentRealtimeLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetAgentRealtimeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAgentRealtimeLogic {
return &GetAgentRealtimeLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequest) (resp *types.GetAgentRealtimeResponse, err error) {
// 1. Get current user
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
l.Errorw("[GetAgentRealtime] user not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 2. Check if Kutt is enabled
cfg := l.svcCtx.Config.Kutt
if !cfg.Enable || cfg.ApiKey == "" || cfg.ApiURL == "" {
l.Infow("Kutt service not enabled or configured",
logger.Field("enable", cfg.Enable),
logger.Field("has_key", cfg.ApiKey != ""),
logger.Field("has_url", cfg.ApiURL != ""))
return &types.GetAgentRealtimeResponse{
Total: 0,
}, nil
}
// 3. Get share URL and domain
shareUrl := l.getShareUrl()
domain := l.getDomain()
if shareUrl == "" {
l.Errorw("ShareUrl not configured for Kutt integration")
return &types.GetAgentRealtimeResponse{
Total: 0,
}, nil
}
// 4. Construct target URL
// Format: https://gethifast.net/?ic=INVITECODE
inviteCode := u.ReferCode
if inviteCode == "" {
l.Errorw("User has no refer code", logger.Field("user_id", u.Id))
return &types.GetAgentRealtimeResponse{
Total: 0,
}, nil
}
target := fmt.Sprintf("%s?ic=%s", shareUrl, inviteCode)
// 5. Call Kutt API to get link stats
// We use Reuse=true to get the existing link details including stats
client := kutt.NewClient(cfg.ApiURL, cfg.ApiKey)
kuttReq := &kutt.CreateLinkRequest{
Target: target,
Description: fmt.Sprintf("Invite link for code: %s", inviteCode),
Reuse: true,
Domain: domain,
}
link, err := client.CreateShortLink(l.ctx, kuttReq)
if err != nil {
l.Errorw("Failed to fetch Kutt stats",
logger.Field("error", err.Error()),
logger.Field("user_id", u.Id),
logger.Field("target", target))
// Return 0 on error, don't block the UI
return &types.GetAgentRealtimeResponse{
Total: 0,
}, nil
}
// 6. Get paid user count
var paidCount int64
db := l.svcCtx.DB
// Count users invited by me who have paid orders
// Sub-query to get user IDs invited by current user
// Count distinct users who have paid orders (Status 2=Paid, 5=Finished)
err = db.Table("order").
Joins("LEFT JOIN user ON user.id = order.user_id").
Where("user.referer_id = ? AND order.status IN ?", u.Id, []int{2, 5}).
Distinct("order.user_id").
Count(&paidCount).Error
if err != nil {
l.Errorw("Failed to count paid users",
logger.Field("error", err.Error()),
logger.Field("user_id", u.Id))
// Don't fail the whole request, just return 0 for paid count
paidCount = 0
}
return &types.GetAgentRealtimeResponse{
Total: int64(link.VisitCount),
Clicks: int64(link.VisitCount),
Views: int64(link.VisitCount),
PaidCount: paidCount,
}, nil
}
func (l *GetAgentRealtimeLogic) getShareUrl() string {
siteConfig := l.svcCtx.Config.Site
if siteConfig.CustomData != "" {
var data customData
if err := json.Unmarshal([]byte(siteConfig.CustomData), &data); err == nil {
if data.ShareUrl != "" {
return data.ShareUrl
}
}
}
return l.svcCtx.Config.Kutt.TargetURL
}
func (l *GetAgentRealtimeLogic) getDomain() string {
siteConfig := l.svcCtx.Config.Site
if siteConfig.CustomData != "" {
var data customData
if err := json.Unmarshal([]byte(siteConfig.CustomData), &data); err == nil {
if data.Domain != "" {
return data.Domain
}
}
}
return l.svcCtx.Config.Kutt.Domain
}
@@ -0,0 +1,125 @@
package user
import (
"context"
"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 GetInviteSalesLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetInviteSalesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetInviteSalesLogic {
return &GetInviteSalesLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (resp *types.GetInviteSalesResponse, err error) {
// 1. Get current user
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
l.Errorw("[GetInviteSales] user not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
userId := u.Id
// 2. Count total sales
var totalSales int64
err = l.svcCtx.DB.WithContext(l.ctx).
Table("`order` o").
Joins("JOIN user u ON o.user_id = u.id").
Where("u.referer_id = ? AND o.status IN (?, ?)", userId, 2, 5).
Count(&totalSales).Error
if err != nil {
l.Errorw("[GetInviteSales] count sales failed",
logger.Field("error", err.Error()),
logger.Field("user_id", userId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
"count sales failed: %v", err.Error())
}
// 3. Pagination
if req.Page < 1 {
req.Page = 1
}
if req.Size < 1 {
req.Size = 10
}
if req.Size > 100 {
req.Size = 100
}
offset := (req.Page - 1) * req.Size
// 4. Get sales data
type OrderWithUser struct {
Amount int64 `gorm:"column:amount"`
CreatedAt int64 `gorm:"column:created_at"`
UserId int64 `gorm:"column:user_id"`
}
var orderData []OrderWithUser
err = l.svcCtx.DB.WithContext(l.ctx).
Table("`order` o").
Select("o.amount, CAST(UNIX_TIMESTAMP(o.created_at) * 1000 AS SIGNED) as created_at, u.id as user_id").
Joins("JOIN user u ON o.user_id = u.id").
Where("u.referer_id = ? AND o.status IN (?, ?)", userId, 2, 5). // status 2: Paid, 5: Finished
Order("o.created_at DESC").
Limit(req.Size).
Offset(offset).
Scan(&orderData).Error
if err != nil {
l.Errorw("[GetInviteSales] query sales failed",
logger.Field("error", err.Error()),
logger.Field("user_id", userId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
"query sales failed: %v", err.Error())
}
// 5. Get user emails
var list []types.InvitedUserSale
for _, order := range orderData {
var email string
// Try email auth first
err = l.svcCtx.DB.WithContext(l.ctx).
Table("user_auth_methods").
Select("auth_identifier").
Where("user_id = ? AND auth_type = ?", order.UserId, "email").
Limit(1).
Scan(&email).Error
// Fallback to any auth method
if err != nil || email == "" {
err = l.svcCtx.DB.WithContext(l.ctx).
Table("user_auth_methods").
Select("auth_identifier").
Where("user_id = ?", order.UserId).
Order("created_at ASC").
Limit(1).
Scan(&email).Error
}
list = append(list, types.InvitedUserSale{
Amount: order.Amount,
CreatedAt: order.CreatedAt,
UserEmail: email,
UserId: order.UserId,
})
}
return &types.GetInviteSalesResponse{
Total: totalSales,
List: list,
}, nil
}
@@ -0,0 +1,72 @@
package user
import (
"context"
"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 GetUserInviteStatsLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Get user invite statistics
func NewGetUserInviteStatsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUserInviteStatsLogic {
return &GetUserInviteStatsLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetUserInviteStatsLogic) GetUserInviteStats(req *types.GetUserInviteStatsRequest) (resp *types.GetUserInviteStatsResponse, err error) {
// 1. 从 context 中获取当前登录用户
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
l.Errorw("[GetUserInviteStats] user not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
userId := u.Id
// 2. 获取有效邀请数 (FriendlyCount): 被邀请用户中至少有1个已支付订单 (status=2或5)
var friendlyCount int64
err = l.svcCtx.DB.WithContext(l.ctx).
Table("user").
Where("referer_id = ? AND EXISTS (SELECT 1 FROM `order` o WHERE o.user_id = user.id AND o.status IN (?, ?))", userId, 2, 5).
Count(&friendlyCount).Error
if err != nil {
l.Errorw("[GetUserInviteStats] count friendly users failed",
logger.Field("error", err.Error()),
logger.Field("user_id", userId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
"count friendly users failed: %v", err.Error())
}
// 3. 获取历史邀请总数 (HistoryCount)
var historyCount int64
err = l.svcCtx.DB.WithContext(l.ctx).
Table("user").
Where("referer_id = ?", userId).
Count(&historyCount).Error
if err != nil {
l.Errorw("[GetUserInviteStats] count history users failed",
logger.Field("error", err.Error()),
logger.Field("user_id", userId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
"count history users failed: %v", err.Error())
}
return &types.GetUserInviteStatsResponse{
FriendlyCount: friendlyCount,
HistoryCount: historyCount,
}, nil
}
+2
View File
@@ -74,10 +74,12 @@ type ServiceContext struct {
func NewServiceContext(c config.Config) *ServiceContext {
// gorm initialize
fmt.Printf(" [Debug] MySQL Config -> Addr: %s, User: %s, DB: %s\n", c.MySQL.Addr, c.MySQL.Username, c.MySQL.Dbname)
db, err := orm.ConnectMysql(orm.Mysql{
Config: c.MySQL,
})
if err != nil {
fmt.Printf(" [Debug] Connection Error: %v\n", err)
panic(err.Error())
}
rds := redis.NewClient(&redis.Options{
+45
View File
@@ -906,6 +906,51 @@ type GetGlobalConfigResponse struct {
WebAd bool `json:"web_ad"`
}
type GetAgentRealtimeRequest struct{}
type GetAgentRealtimeResponse struct {
Total int64 `json:"total"` // 访问总人数
Clicks int64 `json:"clicks"` // 点击量
Views int64 `json:"views"` // 浏览量
PaidCount int64 `json:"paid_count"` // 付费数量
}
type GetAgentDownloadsRequest struct{}
type GetAgentDownloadsResponse struct {
List []AgentDownloadStats `json:"list"`
}
type AgentDownloadStats struct {
Platform string `json:"platform"`
Clicks int64 `json:"clicks"`
Visits int64 `json:"visits"`
}
type GetUserInviteStatsRequest struct{}
type GetUserInviteStatsResponse struct {
FriendlyCount int64 `json:"friendly_count"` // 有效邀请数(有订单的用户)
HistoryCount int64 `json:"history_count"` // 历史邀请总数
}
type GetInviteSalesRequest struct {
Page int `form:"page" validate:"required"`
Size int `form:"size" validate:"required"`
}
type GetInviteSalesResponse struct {
Total int64 `json:"total"` // 销售记录总数
List []InvitedUserSale `json:"list"` // 销售数据列表(分页)
}
type InvitedUserSale struct {
Amount int64 `json:"amount"`
CreatedAt int64 `json:"created_at"`
UserEmail string `json:"user_email"`
UserId int64 `json:"user_id"`
}
type GetLoginLogRequest struct {
Page int `form:"page"`
Size int `form:"size"`