This commit is contained in:
shanshanzhong 2026-01-31 08:30:58 -08:00
parent 16c261bd36
commit af5231747a
16 changed files with 895 additions and 24 deletions

View File

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

View File

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

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:""`

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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{

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"`

View File

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

167
test_data_mock_invites.sql Normal file
View File

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