diff --git a/apis/admin/user.api b/apis/admin/user.api index e408e04..eab2983 100644 --- a/apis/admin/user.api +++ b/apis/admin/user.api @@ -43,7 +43,7 @@ type ( GiftAmount int64 `json:"gift_amount"` Telegram int64 `json:"telegram"` ReferCode string `json:"refer_code"` - RefererId *int64 `json:"referer_id"` + RefererId *int64 `json:"referer_id"` Enable bool `json:"enable"` IsAdmin bool `json:"is_admin"` MemberStatus string `json:"member_status"` @@ -295,3 +295,4 @@ service ppanel { @handler GetUserLoginLogs get /login/logs (GetUserLoginLogsRequest) returns (GetUserLoginLogsResponse) } + diff --git a/apis/public/user.api b/apis/public/user.api index bf7f7f4..276af2e 100644 --- a/apis/public/user.api +++ b/apis/public/user.api @@ -118,6 +118,51 @@ type ( DeviceStatus bool `json:"device_status"` EmailStatus bool `json:"email_status"` } + // GetAgentRealtimeRequest - 获取代理链接实时数据 + GetAgentRealtimeRequest {} + // GetAgentRealtimeResponse - 代理链接实时数据响应 + GetAgentRealtimeResponse { + Total int64 `json:"total"` // 访问总人数 + Clicks int64 `json:"clicks"` // 点击量 + Views int64 `json:"views"` // 浏览量 + PaidCount int64 `json:"paid_count"` // 付费数量 + } + // GetUserInviteStatsRequest - 获取用户邀请统计 + GetUserInviteStatsRequest {} + // GetUserInviteStatsResponse - 用户邀请统计响应 + GetUserInviteStatsResponse { + FriendlyCount int64 `json:"friendly_count"` // 有效邀请数(有订单的用户) + HistoryCount int64 `json:"history_count"` // 历史邀请总数 + } + // GetInviteSalesRequest - 获取最近销售数据 + GetInviteSalesRequest { + Page int `form:"page" validate:"required"` + Size int `form:"size" validate:"required"` + } + // GetInviteSalesResponse - 最近销售数据响应 + GetInviteSalesResponse { + Total int64 `json:"total"` // 销售记录总数 + List []InvitedUserSale `json:"list"` // 销售数据列表(分页) + } + // InvitedUserSale - 被邀请用户的销售记录 + InvitedUserSale { + Amount int64 `json:"amount"` + CreatedAt int64 `json:"created_at"` + UserEmail string `json:"user_email"` + UserId int64 `json:"user_id"` + } + // GetAgentDownloadsRequest - 获取各端下载量 + GetAgentDownloadsRequest {} + // GetAgentDownloadsResponse - 各端下载量响应 + GetAgentDownloadsResponse { + List []AgentDownloadStats `json:"list"` + } + // AgentDownloadStats - 各端下载量统计 + AgentDownloadStats { + Platform string `json:"platform"` + Clicks int64 `json:"clicks"` + Visits int64 `json:"visits"` + } ) @server ( @@ -225,5 +270,21 @@ service ppanel { @doc "Unbind Device" @handler UnbindDevice put /unbind_device (UnbindDeviceRequest) + + @doc "Get agent realtime data" + @handler GetAgentRealtime + get /agent/realtime (GetAgentRealtimeRequest) returns (GetAgentRealtimeResponse) + + @doc "Get user invite statistics" + @handler GetUserInviteStats + get /invite/stats (GetUserInviteStatsRequest) returns (GetUserInviteStatsResponse) + + @doc "Get invite sales data" + @handler GetInviteSales + get /invite/sales (GetInviteSalesRequest) returns (GetInviteSalesResponse) + + @doc "Get agent downloads data" + @handler GetAgentDownloads + get /agent/downloads (GetAgentDownloadsRequest) returns (GetAgentDownloadsResponse) } diff --git a/internal/config/config.go b/internal/config/config.go index 2974545..0ad9fb6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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:""` diff --git a/internal/handler/public/user/getAgentDownloadsHandler.go b/internal/handler/public/user/getAgentDownloadsHandler.go new file mode 100644 index 0000000..c35e379 --- /dev/null +++ b/internal/handler/public/user/getAgentDownloadsHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// 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) + } +} diff --git a/internal/handler/public/user/getAgentRealtimeHandler.go b/internal/handler/public/user/getAgentRealtimeHandler.go new file mode 100644 index 0000000..3568e3d --- /dev/null +++ b/internal/handler/public/user/getAgentRealtimeHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// 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) + } +} diff --git a/internal/handler/public/user/getInviteSalesHandler.go b/internal/handler/public/user/getInviteSalesHandler.go new file mode 100644 index 0000000..ce88ff4 --- /dev/null +++ b/internal/handler/public/user/getInviteSalesHandler.go @@ -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) + } +} diff --git a/internal/handler/public/user/getUserInviteStatsHandler.go b/internal/handler/public/user/getUserInviteStatsHandler.go new file mode 100644 index 0000000..25e0a6a --- /dev/null +++ b/internal/handler/public/user/getUserInviteStatsHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// 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) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index e4d4633..4aafae9 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -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") diff --git a/internal/logic/public/user/getAgentDownloadsLogic.go b/internal/logic/public/user/getAgentDownloadsLogic.go new file mode 100644 index 0000000..c5d343d --- /dev/null +++ b/internal/logic/public/user/getAgentDownloadsLogic.go @@ -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 +} diff --git a/internal/logic/public/user/getAgentRealtimeLogic.go b/internal/logic/public/user/getAgentRealtimeLogic.go new file mode 100644 index 0000000..e916d97 --- /dev/null +++ b/internal/logic/public/user/getAgentRealtimeLogic.go @@ -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 +} diff --git a/internal/logic/public/user/getInviteSalesLogic.go b/internal/logic/public/user/getInviteSalesLogic.go new file mode 100644 index 0000000..ee80bc5 --- /dev/null +++ b/internal/logic/public/user/getInviteSalesLogic.go @@ -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 +} diff --git a/internal/logic/public/user/getUserInviteStatsLogic.go b/internal/logic/public/user/getUserInviteStatsLogic.go new file mode 100644 index 0000000..0c20ca2 --- /dev/null +++ b/internal/logic/public/user/getUserInviteStatsLogic.go @@ -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 +} diff --git a/internal/svc/serviceContext.go b/internal/svc/serviceContext.go index 2c50c07..5532e9e 100644 --- a/internal/svc/serviceContext.go +++ b/internal/svc/serviceContext.go @@ -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{ diff --git a/internal/types/types.go b/internal/types/types.go index 3f16fec..a022390 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -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"` diff --git a/pkg/openinstall/openinstall.go b/pkg/openinstall/openinstall.go new file mode 100644 index 0000000..3e0b273 --- /dev/null +++ b/pkg/openinstall/openinstall.go @@ -0,0 +1,45 @@ +package openinstall + +import ( + "context" + "net/http" + "time" +) + +// Client for OpenInstall API +type Client struct { + appKey string + httpClient *http.Client +} + +// NewClient creates a new OpenInstall client +func NewClient(appKey string) *Client { + return &Client{ + appKey: appKey, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// PlatformStats represents statistics for a specific platform +type PlatformStats struct { + Platform string `json:"platform"` + Clicks int64 `json:"clicks"` // OpenInstall "click" + Visits int64 `json:"visits"` // OpenInstall "visit" +} + +// Mock implementation for now as we don't have real API docs/credentials +// In a real implementation, this would call OpenInstall's API +func (c *Client) GetPlatformStats(ctx context.Context, startDate, endDate time.Time) ([]PlatformStats, error) { + // TODO: distinct implementation when API details are confirmed + + // Mock response + return []PlatformStats{ + {Platform: "iOS", Clicks: 0, Visits: 0}, + {Platform: "Android", Clicks: 0, Visits: 0}, + {Platform: "Windows", Clicks: 0, Visits: 0}, + {Platform: "macOS", Clicks: 0, Visits: 0}, + {Platform: "Linux", Clicks: 0, Visits: 0}, + }, nil +} diff --git a/test_data_mock_invites.sql b/test_data_mock_invites.sql new file mode 100644 index 0000000..e96d1a7 --- /dev/null +++ b/test_data_mock_invites.sql @@ -0,0 +1,167 @@ +-- =================================================================== +-- 模拟邀请数据 SQL - 用于测试 Agent/Invite APIs +-- =================================================================== +-- 说明: +-- 1. 假设当前登录用户 ID = 524(邀请者) +-- 2. 创建 5 个被邀请用户(user_id: 10001-10005) +-- 3. 其中 3 个用户有付费订单(user_id: 10001, 10002, 10003) +-- 4. 2 个用户没有订单(user_id: 10004, 10005) +-- =================================================================== + +-- 1. 插入被邀请用户(referer_id = 524,即当前登录用户) +-- 注意:如果这些 ID 已存在,请先手动修改为其他未使用的 ID + +-- 用户 10001:有2个已支付订单 +INSERT INTO `user` ( + `id`, `referer_id`, `refer_code`, `password`, `is_admin`, + `balance`, `commission`, `created_at`, `updated_at` +) VALUES ( + 10001, 524, 'MOCK_CODE_01', '$2a$10$mock.hash.for.testing.only.password.bcrypt.hash', 0, + 0, 0, NOW(), NOW() +) ON DUPLICATE KEY UPDATE `referer_id` = 524; + +-- 为用户 10001 添加 email 认证方式 +INSERT INTO `user_auth_methods` (`user_id`, `auth_type`, `auth_identifier`, `verified`, `created_at`, `updated_at`) +VALUES (10001, 'email', 'mock_user_001@test.com', 1, NOW(), NOW()) +ON DUPLICATE KEY UPDATE `auth_identifier` = 'mock_user_001@test.com'; + +-- 用户 10002:有1个已支付订单 +INSERT INTO `user` ( + `id`, `referer_id`, `refer_code`, `password`, `is_admin`, + `balance`, `commission`, `created_at`, `updated_at` +) VALUES ( + 10002, 524, 'MOCK_CODE_02', '$2a$10$mock.hash.for.testing.only.password.bcrypt.hash', 0, + 0, 0, NOW(), NOW() +) ON DUPLICATE KEY UPDATE `referer_id` = 524; + +INSERT INTO `user_auth_methods` (`user_id`, `auth_type`, `auth_identifier`, `verified`, `created_at`, `updated_at`) +VALUES (10002, 'email', 'mock_user_002@test.com', 1, NOW(), NOW()) +ON DUPLICATE KEY UPDATE `auth_identifier` = 'mock_user_002@test.com'; + +-- 用户 10003:有1个已完成订单 +INSERT INTO `user` ( + `id`, `referer_id`, `refer_code`, `password`, `is_admin`, + `balance`, `commission`, `created_at`, `updated_at` +) VALUES ( + 10003, 524, 'MOCK_CODE_03', '$2a$10$mock.hash.for.testing.only.password.bcrypt.hash', 0, + 0, 0, NOW(), NOW() +) ON DUPLICATE KEY UPDATE `referer_id` = 524; + +INSERT INTO `user_auth_methods` (`user_id`, `auth_type`, `auth_identifier`, `verified`, `created_at`, `updated_at`) +VALUES (10003, 'email', 'mock_user_003@test.com', 1, NOW(), NOW()) +ON DUPLICATE KEY UPDATE `auth_identifier` = 'mock_user_003@test.com'; + +-- 用户 10004:没有订单(历史邀请但未付费) +INSERT INTO `user` ( + `id`, `referer_id`, `refer_code`, `password`, `is_admin`, + `balance`, `commission`, `created_at`, `updated_at` +) VALUES ( + 10004, 524, 'MOCK_CODE_04', '$2a$10$mock.hash.for.testing.only.password.bcrypt.hash', 0, + 0, 0, NOW(), NOW() +) ON DUPLICATE KEY UPDATE `referer_id` = 524; + +INSERT INTO `user_auth_methods` (`user_id`, `auth_type`, `auth_identifier`, `verified`, `created_at`, `updated_at`) +VALUES (10004, 'email', 'mock_user_004@test.com', 1, NOW(), NOW()) +ON DUPLICATE KEY UPDATE `auth_identifier` = 'mock_user_004@test.com'; + +-- 用户 10005:没有订单(历史邀请但未付费) +INSERT INTO `user` ( + `id`, `referer_id`, `refer_code`, `password`, `is_admin`, + `balance`, `commission`, `created_at`, `updated_at` +) VALUES ( + 10005, 524, 'MOCK_CODE_05', '$2a$10$mock.hash.for.testing.only.password.bcrypt.hash', 0, + 0, 0, NOW(), NOW() +) ON DUPLICATE KEY UPDATE `referer_id` = 524; + +INSERT INTO `user_auth_methods` (`user_id`, `auth_type`, `auth_identifier`, `verified`, `created_at`, `updated_at`) +VALUES (10005, 'email', 'mock_user_005@test.com', 1, NOW(), NOW()) +ON DUPLICATE KEY UPDATE `auth_identifier` = 'mock_user_005@test.com'; + + + +-- =================================================================== +-- 2. 插入订单数据 +-- status: 2 = Paid(已支付), 5 = Finished(已完成) +-- =================================================================== + +-- 订单 1:用户 10001,已支付,金额 $9.99 +INSERT INTO `order` ( + `user_id`, `order_no`, `type`, `status`, `amount`, `quantity`, + `payment_id`, `created_at`, `updated_at` +) VALUES ( + 10001, 'MOCK_ORDER_001', 1, 2, 999, 1, + 1, DATE_SUB(NOW(), INTERVAL 10 DAY), DATE_SUB(NOW(), INTERVAL 10 DAY) +); + +-- 订单 2:用户 10001,已支付,金额 $19.99 +INSERT INTO `order` ( + `user_id`, `order_no`, `type`, `status`, `amount`, `quantity`, + `payment_id`, `created_at`, `updated_at` +) VALUES ( + 10001, 'MOCK_ORDER_002', 1, 2, 1999, 1, + 1, DATE_SUB(NOW(), INTERVAL 5 DAY), DATE_SUB(NOW(), INTERVAL 5 DAY) +); + +-- 订单 3:用户 10002,已支付,金额 $29.99 +INSERT INTO `order` ( + `user_id`, `order_no`, `type`, `status`, `amount`, `quantity`, + `payment_id`, `created_at`, `updated_at` +) VALUES ( + 10002, 'MOCK_ORDER_003', 1, 2, 2999, 1, + 1, DATE_SUB(NOW(), INTERVAL 3 DAY), DATE_SUB(NOW(), INTERVAL 3 DAY) +); + +-- 订单 4:用户 10003,已完成,金额 $49.99 +INSERT INTO `order` ( + `user_id`, `order_no`, `type`, `status`, `amount`, `quantity`, + `payment_id`, `created_at`, `updated_at` +) VALUES ( + 10003, 'MOCK_ORDER_004', 1, 5, 4999, 1, + 1, DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY) +); + +-- 订单 5:用户 10001,待支付(status=1,不会被统计) +INSERT INTO `order` ( + `user_id`, `order_no`, `type`, `status`, `amount`, `quantity`, + `payment_id`, `created_at`, `updated_at` +) VALUES ( + 10001, 'MOCK_ORDER_005', 1, 1, 999, 1, + 1, NOW(), NOW() +); + + +-- =================================================================== +-- 3. 验证查询 +-- =================================================================== + +-- 查询当前用户(524)的邀请统计 +-- 应返回: +-- - friendlyCount: 3 (user_id 10001, 10002, 10003 有已支付/完成订单) +-- - historyCount: 5 (user_id 10001-10005 都是被邀请用户) +SELECT + (SELECT COUNT(*) FROM user WHERE referer_id = 524 + AND EXISTS (SELECT 1 FROM `order` o WHERE o.user_id = user.id AND o.status IN (2, 5)) + ) as friendlyCount, + (SELECT COUNT(*) FROM user WHERE referer_id = 524) as historyCount; + +-- 查询销售记录(分页查询,前10条) +-- 应返回 4 条记录(MOCK_ORDER_001 到 MOCK_ORDER_004) +SELECT + o.amount, + UNIX_TIMESTAMP(o.created_at) * 1000 as created_at, + u.id as user_id, + COALESCE(am.auth_identifier, 'no_email') as user_email +FROM `order` o +JOIN user u ON o.user_id = u.id +LEFT JOIN user_auth_methods am ON am.user_id = u.id AND am.auth_type = 'email' +WHERE u.referer_id = 524 AND o.status IN (2, 5) +ORDER BY o.created_at DESC +LIMIT 10 OFFSET 0; + + + +-- =================================================================== +-- 清理命令(如需删除测试数据,请执行以下语句) +-- =================================================================== +-- DELETE FROM `order` WHERE order_no LIKE 'MOCK_ORDER_%'; +-- DELETE FROM `user` WHERE id BETWEEN 10001 AND 10005;