This commit is contained in:
2026-02-08 18:49:14 -08:00
parent 709d657906
commit 28ada42ae5
14 changed files with 726 additions and 77 deletions
@@ -9,6 +9,7 @@ import (
"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"
@@ -20,6 +21,7 @@ type GetAgentDownloadsLogic struct {
svcCtx *svc.ServiceContext
}
// NewGetAgentDownloadsLogic 创建 GetAgentDownloadsLogic 实例
func NewGetAgentDownloadsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAgentDownloadsLogic {
return &GetAgentDownloadsLogic{
Logger: logger.WithContext(ctx),
@@ -28,6 +30,8 @@ func NewGetAgentDownloadsLogic(ctx context.Context, svcCtx *svc.ServiceContext)
}
}
// GetAgentDownloads 获取用户代理下载统计数据
// 结合 OpenInstall (iOS/Android) 和 Loki (Windows/Mac) 数据源
func (l *GetAgentDownloadsLogic) GetAgentDownloads(req *types.GetAgentDownloadsRequest) (resp *types.GetAgentDownloadsResponse, err error) {
// 1. 从 context 获取用户信息
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
@@ -36,72 +40,68 @@ func (l *GetAgentDownloadsLogic) GetAgentDownloads(req *types.GetAgentDownloadsR
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 2. 检查 OpenInstall 是否启用
cfg := l.svcCtx.Config.OpenInstall
if !cfg.Enable {
l.Infow("[GetAgentDownloads] OpenInstall is disabled, returning zero stats")
return &types.GetAgentDownloadsResponse{
Total: 0,
Platforms: &types.PlatformDownloads{
IOS: 0,
Android: 0,
Windows: 0,
Mac: 0,
},
}, nil
// 初始化响应数据
var iosCount, androidCount, windowsCount, macCount int64
var comparisonRate *string
// 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
}
}
}
// 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
// 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))
}
}
// 4. 调用 OpenInstall API 获取各端下载
client := openinstall.NewClient(cfg.ApiKey)
platformDownloads, err := client.GetPlatformDownloads(l.ctx)
if err != nil {
l.Errorw("Failed to fetch OpenInstall platform downloads", logger.Field("error", err), logger.Field("user_id", u.Id))
// 返回空数据而不是错误,避免影响前端显示
return &types.GetAgentDownloadsResponse{
Total: 0,
Platforms: &types.PlatformDownloads{
IOS: 0,
Android: 0,
Windows: 0,
Mac: 0,
},
}, nil
}
// 4. 计算总
total := iosCount + androidCount + windowsCount + macCount
// 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{
Total: platformDownloads.Total,
Total: total,
Platforms: &types.PlatformDownloads{
IOS: platformDownloads.IOS,
Android: platformDownloads.Android,
Windows: platformDownloads.Windows,
Mac: platformDownloads.Mac,
IOS: iosCount,
Android: androidCount,
Windows: windowsCount,
Mac: macCount,
},
ComparisonRate: comparisonRate,
}, nil
@@ -40,11 +40,19 @@ func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (
// 2. Count total sales
var totalSales int64
err = l.svcCtx.DB.WithContext(l.ctx).
db := 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 = ?", userId, 5).
Count(&totalSales).Error
Where("u.referer_id = ? AND o.status = ?", userId, 5)
if req.StartTime > 0 {
db = db.Where("o.updated_at >= FROM_UNIXTIME(?)", req.StartTime)
}
if req.EndTime > 0 {
db = db.Where("o.updated_at <= FROM_UNIXTIME(?)", req.EndTime)
}
err = db.Count(&totalSales).Error
if err != nil {
l.Errorw("[GetInviteSales] count sales failed",
logger.Field("error", err.Error()),
@@ -75,13 +83,21 @@ func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (
}
var orderData []OrderWithUser
err = l.svcCtx.DB.WithContext(l.ctx).
query := l.svcCtx.DB.WithContext(l.ctx).
Table("`order` o").
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").
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").
Where("u.referer_id = ? AND o.status = ?", userId, 5) // status 5: Finished
if req.StartTime > 0 {
query = query.Where("o.updated_at >= FROM_UNIXTIME(?)", req.StartTime)
}
if req.EndTime > 0 {
query = query.Where("o.updated_at <= FROM_UNIXTIME(?)", req.EndTime)
}
err = query.Order("o.updated_at DESC").
Limit(req.Size).
Offset(offset).
Scan(&orderData).Error
@@ -0,0 +1,168 @@
package user
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/subscribe"
"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/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// setupTestSvcCtx 初始化测试上下文
func setupTestSvcCtx(t *testing.T) (*svc.ServiceContext, *gorm.DB) {
// 1. Setup Miniredis
mr, err := miniredis.Run()
assert.NoError(t, err)
rdb := redis.NewClient(&redis.Options{
Addr: mr.Addr(),
})
// 2. Setup GORM with SQLite
dbName := fmt.Sprintf("test_sales_%d.db", time.Now().UnixNano())
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
assert.NoError(t, err)
t.Cleanup(func() {
os.Remove(dbName)
mr.Close()
})
// Migrate tables
_ = db.Migrator().CreateTable(&user.User{})
_ = db.Migrator().CreateTable(&subscribe.Subscribe{}) // Plan definition
_ = db.Migrator().CreateTable(&order.Order{})
// 3. Create ServiceContext
svcCtx := &svc.ServiceContext{
Redis: rdb,
DB: db,
Config: config.Config{},
UserModel: user.NewModel(db, rdb),
}
return svcCtx, db
}
func TestGetInviteSales_TimeFilter(t *testing.T) {
svcCtx, db := setupTestSvcCtx(t)
// 1. Prepare Data
// Referrer User (Current User)
referrer := &user.User{
Id: 100,
// Email removed (not in struct)
ReferCode: "REF100",
}
db.Create(referrer)
// Invited User
invitedUser := &user.User{
Id: 200,
// Email removed
RefererId: referrer.Id, // Linked to referrer
}
db.Create(invitedUser)
// Subscribe (Plan)
sub := &subscribe.Subscribe{
Id: 1,
Name: "Standard Plan",
}
db.Create(sub)
// Orders
// Order 1: Inside Range (2023-10-15)
timeIn := time.Date(2023, 10, 15, 12, 0, 0, 0, time.UTC)
db.Create(&order.Order{
UserId: invitedUser.Id,
OrderNo: "ORD001",
Status: 5, // Finished
Amount: 1000,
Quantity: 30,
SubscribeId: sub.Id,
UpdatedAt: timeIn,
})
// Order 2: Before Range (2023-09-15)
timeBefore := time.Date(2023, 9, 15, 12, 0, 0, 0, time.UTC)
db.Create(&order.Order{
UserId: invitedUser.Id,
OrderNo: "ORD002",
Status: 5, // Finished
Amount: 1000,
Quantity: 30,
SubscribeId: sub.Id,
UpdatedAt: timeBefore,
})
// Order 3: After Range (2023-11-15)
timeAfter := time.Date(2023, 11, 15, 12, 0, 0, 0, time.UTC)
db.Create(&order.Order{
UserId: invitedUser.Id,
OrderNo: "ORD003",
Status: 5, // Finished
Amount: 1000,
Quantity: 30,
SubscribeId: sub.Id,
UpdatedAt: timeAfter,
})
// Order 4: Wrong Status (2023-10-16) - Should be ignored
db.Create(&order.Order{
UserId: invitedUser.Id,
OrderNo: "ORD004",
Status: 1, // Pending
Amount: 1000,
Quantity: 30,
SubscribeId: sub.Id,
UpdatedAt: timeIn.Add(24 * time.Hour),
})
// 2. Execute Logic
// Context with current user
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, referrer)
l := NewGetInviteSalesLogic(ctx, svcCtx)
// Filter for October 2023
startTime := time.Date(2023, 10, 1, 0, 0, 0, 0, time.UTC).Unix()
endTime := time.Date(2023, 10, 31, 23, 59, 59, 0, time.UTC).Unix()
req := &types.GetInviteSalesRequest{
Page: 1,
Size: 10,
StartTime: startTime, // 2023-10-01
EndTime: endTime, // 2023-10-31
}
resp, err := l.GetInviteSales(req)
assert.NoError(t, err)
// 3. Verify Results
// Should match exactly 1 order (ORD001)
assert.Equal(t, int64(1), resp.Total, "Should return exactly 1 order matching time range and status")
if assert.NotEmpty(t, resp.List) {
assert.Equal(t, 1, len(resp.List))
// Log result for debug
t.Logf("Found Sale: Amount=%.2f, Time=%d", resp.List[0].Amount, resp.List[0].UpdatedAt)
// Verify timestamp is roughly correct (millisecond precision in logic)
expectedMs := timeIn.Unix() * 1000
assert.Equal(t, expectedMs, resp.List[0].UpdatedAt)
} else {
t.Error("Returned list is empty")
}
}