This commit is contained in:
2026-02-03 04:40:23 -08:00
parent 5b238919f5
commit 709d657906
19 changed files with 1649 additions and 85 deletions
@@ -2,8 +2,7 @@ package user
import (
"context"
"time"
"fmt"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
@@ -30,47 +29,80 @@ func NewGetAgentDownloadsLogic(ctx context.Context, svcCtx *svc.ServiceContext)
}
func (l *GetAgentDownloadsLogic) GetAgentDownloads(req *types.GetAgentDownloadsRequest) (resp *types.GetAgentDownloadsResponse, err error) {
// 1. Get current user
_, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
// 1. 从 context 获取用户信息
u, 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
// 2. 检查 OpenInstall 是否启用
cfg := l.svcCtx.Config.OpenInstall
if !cfg.Enable {
l.Infow("[GetAgentDownloads] OpenInstall is disabled, returning zero stats")
return &types.GetAgentDownloadsResponse{
List: []types.AgentDownloadStats{},
Total: 0,
Platforms: &types.PlatformDownloads{
IOS: 0,
Android: 0,
Windows: 0,
Mac: 0,
},
}, nil
}
// 3. Call OpenInstall API
// Default to last 30 days
endDate := time.Now()
startDate := endDate.AddDate(0, 0, -30)
// 3. 检查 ApiKey 是否配置
if cfg.ApiKey == "" {
l.Errorw("[GetAgentDownloads] OpenInstall ApiKey not configured")
return &types.GetAgentDownloadsResponse{
Total: 0,
Platforms: &types.PlatformDownloads{
IOS: 0,
Android: 0,
Windows: 0,
Mac: 0,
},
}, nil
}
client := openinstall.NewClient(cfg.AppKey)
stats, err := client.GetPlatformStats(l.ctx, startDate, endDate)
// 4. 调用 OpenInstall API 获取各端下载量
client := openinstall.NewClient(cfg.ApiKey)
platformDownloads, err := client.GetPlatformDownloads(l.ctx)
if err != nil {
l.Errorw("Failed to fetch OpenInstall stats", logger.Field("error", err))
// Return empty list on error
l.Errorw("Failed to fetch OpenInstall platform downloads", logger.Field("error", err), logger.Field("user_id", u.Id))
// 返回空数据而不是错误,避免影响前端显示
return &types.GetAgentDownloadsResponse{
List: []types.AgentDownloadStats{},
Total: 0,
Platforms: &types.PlatformDownloads{
IOS: 0,
Android: 0,
Windows: 0,
Mac: 0,
},
}, 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,
})
// 5. 构造响应
var comparisonRate *string
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
}
return &types.GetAgentDownloadsResponse{
List: list,
Total: platformDownloads.Total,
Platforms: &types.PlatformDownloads{
IOS: platformDownloads.IOS,
Android: platformDownloads.Android,
Windows: platformDownloads.Windows,
Mac: platformDownloads.Mac,
},
ComparisonRate: comparisonRate,
}, nil
}
@@ -133,10 +133,27 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
// 8. Calculate paid user growth rate (month-over-month)
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: int64(link.VisitCount),
Clicks: int64(link.VisitCount),
Views: int64(link.VisitCount),
Total: currentMonthClicks,
Clicks: currentMonthClicks,
Views: currentMonthClicks,
PaidCount: paidCount,
GrowthRate: growthRate,
PaidGrowthRate: paidGrowthRate,
@@ -2,6 +2,9 @@ package user
import (
"context"
"fmt"
"hash/fnv"
"strconv"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
@@ -40,7 +43,7 @@ func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (
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).
Where("u.referer_id = ? AND o.status = ?", userId, 5).
Count(&totalSales).Error
if err != nil {
l.Errorw("[GetInviteSales] count sales failed",
@@ -64,18 +67,21 @@ func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (
// 4. Get sales data
type OrderWithUser struct {
Amount int64 `gorm:"column:amount"`
CreatedAt int64 `gorm:"column:created_at"`
UserId int64 `gorm:"column:user_id"`
Amount int64 `gorm:"column:amount"`
UpdatedAt int64 `gorm:"column:updated_at"`
UserId int64 `gorm:"column:user_id"`
ProductName string `gorm:"column:product_name"`
Quantity int64 `gorm:"column:quantity"`
}
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").
Select("o.amount, CAST(UNIX_TIMESTAMP(o.updated_at) * 1000 AS SIGNED) as updated_at, u.id as user_id, s.name as product_name, o.quantity").
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").
Joins("LEFT JOIN subscribe s ON o.subscribe_id = s.id").
Where("u.referer_id = ? AND o.status = ?", userId, 5). // status 5: Finished
Order("o.updated_at DESC").
Limit(req.Size).
Offset(offset).
Scan(&orderData).Error
@@ -87,34 +93,29 @@ func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (
"query sales failed: %v", err.Error())
}
// 5. Get user emails
// 5. Get sales list
const HashSalt = "ppanel_invite_sales_v1" // Fixed Key
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
// Calculate unique numeric hash (FNV-64a)
h := fnv.New64a()
h.Write([]byte(HashSalt))
h.Write([]byte(strconv.FormatInt(order.UserId, 10)))
// Truncate to 10 digits using modulo 10^10
hashVal := h.Sum64() % 10000000000
userHashStr := fmt.Sprintf("%010d", hashVal)
// 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
// Format product name as "{{ quantity }}天VPN服务"
productName := fmt.Sprintf("%d天VPN服务", order.Quantity)
if order.Quantity <= 0 {
productName = "1天VPN服务"
}
list = append(list, types.InvitedUserSale{
Amount: float64(order.Amount) / 100.0, // Convert cents to dollars
CreatedAt: order.CreatedAt,
UserEmail: email,
UserId: order.UserId,
Amount: float64(order.Amount) / 100.0, // Convert cents to dollars
UpdatedAt: order.UpdatedAt,
UserHash: userHashStr,
ProductName: productName,
})
}