x
Build docker and publish / build (20.15.1) (push) Has been cancelled

This commit is contained in:
2026-02-09 19:01:20 -08:00
parent 28ada42ae5
commit f518b8b1eb
9 changed files with 139 additions and 243 deletions
@@ -2,15 +2,12 @@ package user
import (
"context"
"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/logger"
"github.com/perfect-panel/server/pkg/loki"
"github.com/perfect-panel/server/pkg/openinstall"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
@@ -30,8 +27,17 @@ func NewGetAgentDownloadsLogic(ctx context.Context, svcCtx *svc.ServiceContext)
}
}
// PlatformStats 各平台设备统计结果
type PlatformStats struct {
Android int64 `gorm:"column:android"`
IOS int64 `gorm:"column:ios"`
Mac int64 `gorm:"column:mac"`
Windows int64 `gorm:"column:windows"`
Total int64 `gorm:"column:total"`
}
// GetAgentDownloads 获取用户代理下载统计数据
// 结合 OpenInstall (iOS/Android) 和 Loki (Windows/Mac) 数据源
// 基于用户邀请码查询被邀请用户的设备UA来统计各平台安装量
func (l *GetAgentDownloadsLogic) GetAgentDownloads(req *types.GetAgentDownloadsRequest) (resp *types.GetAgentDownloadsResponse, err error) {
// 1. 从 context 获取用户信息
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
@@ -40,69 +46,49 @@ func (l *GetAgentDownloadsLogic) GetAgentDownloads(req *types.GetAgentDownloadsR
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 初始化响应数据
var iosCount, androidCount, windowsCount, macCount int64
var comparisonRate *string
// 2. 通过数据库查询各平台设备安装量
// 基于 user_device 表的 user_agent 字段判断平台
// UA格式: HiVPN/1.0.0 (平台; 设备; 版本) Flutter
var stats PlatformStats
err = l.svcCtx.DB.WithContext(l.ctx).
Table("user u").
Select(`
SUM(CASE WHEN d.user_agent LIKE '%(Android;%' THEN 1 ELSE 0 END) AS android,
SUM(CASE WHEN d.user_agent LIKE '%(iOS;%' THEN 1 ELSE 0 END) AS ios,
SUM(CASE WHEN d.user_agent LIKE '%(macOS;%' THEN 1 ELSE 0 END) AS mac,
SUM(CASE WHEN d.user_agent LIKE '%(Windows;%' THEN 1 ELSE 0 END) AS windows,
COUNT(*) AS total
`).
Joins("JOIN user_device d ON u.id = d.user_id").
Where("u.referer_id = ?", u.Id).
Scan(&stats).Error
// 2. 从 OpenInstall 获取 iOS/Android 数据
openInstallCfg := l.svcCtx.Config.OpenInstall
if openInstallCfg.Enable && openInstallCfg.ApiKey != "" {
client := openinstall.NewClient(openInstallCfg.ApiKey)
platformDownloads, err := client.GetPlatformDownloads(l.ctx, u.ReferCode)
if err != nil {
l.Errorw("Failed to fetch OpenInstall platform downloads", logger.Field("error", err), logger.Field("user_id", u.Id))
// 不返回错误,继续处理其他数据源
} else {
iosCount = platformDownloads.IOS
androidCount = platformDownloads.Android
// OpenInstall 的 Windows/Mac 数据可能为空,后面用 Loki 补充
// 计算环比
if platformDownloads.Comparison != nil {
percent := platformDownloads.Comparison.ChangePercent
var formatted string
if percent >= 0 {
formatted = fmt.Sprintf("+%.1f%%", percent)
} else {
formatted = fmt.Sprintf("%.1f%%", percent)
}
comparisonRate = &formatted
}
}
if err != nil {
l.Errorw("[GetAgentDownloads] query platform stats failed",
logger.Field("error", err.Error()),
logger.Field("user_id", u.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
"query platform stats failed: %v", err.Error())
}
// 3. 从 Loki 获取 Windows/Mac 数据(基于用户邀请码)
lokiCfg := l.svcCtx.Config.Loki
if lokiCfg.Enable && lokiCfg.URL != "" && u.ReferCode != "" {
lokiClient := loki.NewClient(lokiCfg.URL)
lokiStats, err := lokiClient.GetInviteCodeStats(l.ctx, u.ReferCode, 30)
if err != nil {
l.Errorw("Failed to fetch Loki stats", logger.Field("error", err), logger.Field("user_id", u.Id), logger.Field("refer_code", u.ReferCode))
// 不返回错误,继续使用已有数据
} else {
// 使用 Loki 的 Windows/Mac 数据
windowsCount = lokiStats.WindowsClicks
macCount = lokiStats.MacClicks
l.Infow("Fetched Loki stats successfully",
logger.Field("user_id", u.Id),
logger.Field("refer_code", u.ReferCode),
logger.Field("windows", windowsCount),
logger.Field("mac", macCount))
}
}
l.Infow("[GetAgentDownloads] platform stats fetched successfully",
logger.Field("user_id", u.Id),
logger.Field("refer_code", u.ReferCode),
logger.Field("android", stats.Android),
logger.Field("ios", stats.IOS),
logger.Field("mac", stats.Mac),
logger.Field("windows", stats.Windows),
logger.Field("total", stats.Total))
// 4. 计算总量
total := iosCount + androidCount + windowsCount + macCount
// 5. 构造响应
// 3. 构造响应
return &types.GetAgentDownloadsResponse{
Total: total,
Total: stats.Total,
Platforms: &types.PlatformDownloads{
IOS: iosCount,
Android: androidCount,
Windows: windowsCount,
Mac: macCount,
IOS: stats.IOS,
Android: stats.Android,
Windows: stats.Windows,
Mac: stats.Mac,
},
ComparisonRate: comparisonRate,
ComparisonRate: nil, // 不再计算环比
}, nil
}
@@ -2,7 +2,6 @@ package user
import (
"context"
"encoding/json"
"fmt"
"time"
@@ -10,8 +9,8 @@ import (
"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/loki"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
@@ -38,154 +37,75 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
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 create/fetch Kutt link",
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,
GrowthRate: "0%",
}, nil
}
// 6. Get detailed stats for growth rate calculation
stats, err := client.GetLinkStats(l.ctx, link.ID)
var growthRate string
if err != nil {
l.Errorw("Failed to fetch Kutt detailed stats, using basic count only",
logger.Field("error", err.Error()),
logger.Field("link_id", link.ID))
growthRate = "N/A"
} else {
// Calculate month-over-month growth rate
// lastYear.views is an array of 12 months, last element is current month, second-to-last is previous month
growthRate = calculateGrowthRate(stats.LastYear.Views)
}
// 7. Get paid user count
var views, lastMonthViews int64
var installs int64
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
// 2. 从 Loki 获取 viewsnginx 访问日志)
lokiCfg := l.svcCtx.Config.Loki
if lokiCfg.Enable && lokiCfg.URL != "" && u.ReferCode != "" {
lokiClient := loki.NewClient(lokiCfg.URL)
lokiStats, err := lokiClient.GetInviteCodeStats(l.ctx, u.ReferCode, 30)
if err != nil {
l.Errorw("[GetAgentRealtime] Failed to fetch Loki stats",
logger.Field("error", err.Error()),
logger.Field("user_id", u.Id),
logger.Field("refer_code", u.ReferCode))
// 不返回错误,继续使用已有数据
} else {
views = lokiStats.MacClicks + lokiStats.WindowsClicks
lastMonthViews = lokiStats.LastMonthMac + lokiStats.LastMonthWindows
l.Infow("[GetAgentRealtime] Fetched Loki stats successfully",
logger.Field("user_id", u.Id),
logger.Field("refer_code", u.ReferCode),
logger.Field("views", views),
logger.Field("last_month_views", lastMonthViews))
}
}
// 3. 从数据库获取安装量(被邀请注册用户数)
err = l.svcCtx.DB.WithContext(l.ctx).
Model(&user.User{}).
Where("referer_id = ?", u.Id).
Count(&installs).Error
if err != nil {
l.Errorw("Failed to count paid users",
l.Errorw("[GetAgentRealtime] Failed to count installs",
logger.Field("error", err.Error()),
logger.Field("user_id", u.Id))
installs = 0
}
// 4. 获取付费用户数
err = l.svcCtx.DB.WithContext(l.ctx).
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("[GetAgentRealtime] 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
}
// 8. Calculate paid user growth rate (month-over-month)
// 5. 计算环比增长率
growthRate := calculateGrowthRate([]int{int(lastMonthViews), int(views)})
// 6. 计算付费用户环比增长率
paidGrowthRate := l.calculatePaidGrowthRate(u.Id)
// Use current month clicks if available
var currentMonthClicks int64
if stats != nil && len(stats.LastYear.Views) > 0 {
currentMonthClicks = int64(stats.LastYear.Views[len(stats.LastYear.Views)-1])
} else {
// Fallback to total if stats not available (or 0)
// Requirement suggests monthly, so maybe 0 is better if stats fail?
// Existing logic: total. Let's stick to total if stats fail unless strict requirement.
// User said: "kutt interface data requires monthly clicks".
// If stats fail, it means we don't have monthly data.
// Let's fallback to 0 to be safe/strict about "Monthly", OR keep using VisitCount if that's the only thing we have.
// Given the user wants "current month", finding 0 is more accurate than Total All Time if we can't find month.
// However, typically fallback to Total is less "broken" looking.
// But let's follow the instruction: "Use current month".
// If stats err, currentMonthClicks remains 0.
}
return &types.GetAgentRealtimeResponse{
Total: currentMonthClicks,
Clicks: currentMonthClicks,
Views: currentMonthClicks,
Total: views,
Clicks: views,
Views: views,
Installs: installs,
PaidCount: paidCount,
GrowthRate: growthRate,
PaidGrowthRate: paidGrowthRate,
}, 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
}
// calculatePaidGrowthRate 计算付费用户的环比增长率
func (l *GetAgentRealtimeLogic) calculatePaidGrowthRate(userId int64) string {
db := l.svcCtx.DB
@@ -60,6 +60,14 @@ func (l *QueryUserSubscribeLogic) QueryUserSubscribe() (resp *types.QueryUserSub
}
}
// 查询订单金额,判断是否为赠送订单(amount=0)
if item.OrderId > 0 {
orderInfo, err := l.svcCtx.OrderModel.FindOne(l.ctx, item.OrderId)
if err == nil && orderInfo != nil {
sub.IsGift = orderInfo.Amount == 0
}
}
sub.ResetTime = calculateNextResetTime(&sub)
resp.List = append(resp.List, sub)
}
+1 -1
View File
@@ -249,7 +249,7 @@ func (m *customOrderModel) IsUserEligibleForNewOrder(ctx context.Context, userID
var count int64
err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error {
return conn.Model(&Order{}).
Where("user_id = ? AND status IN ?", userID, []int64{2, 5}).
Where("user_id = ? AND status IN ? AND amount > 0", userID, []int64{2, 5}).
Count(&count).Error
})
return count == 0, err
+2
View File
@@ -912,6 +912,7 @@ type GetAgentRealtimeResponse struct {
Total int64 `json:"total"` // 访问总人数
Clicks int64 `json:"clicks"` // 点击量
Views int64 `json:"views"` // 浏览量
Installs int64 `json:"installs"` // 安装量(被邀请注册用户数)
PaidCount int64 `json:"paid_count"` // 付费数量
GrowthRate string `json:"growth_rate"` // 访问量环比增长率
PaidGrowthRate string `json:"paid_growth_rate"` // 付费用户环比增长率
@@ -2788,6 +2789,7 @@ type UserSubscribe struct {
Upload int64 `json:"upload"`
Token string `json:"token"`
Status uint8 `json:"status"`
IsGift bool `json:"is_gift"` // 是否为赠送订单(amount=0
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}