Skip to content

Commit fd959fd

Browse files
committed
Merge remote-tracking branch 'upstream/main'
2 parents 4c89b6e + 4588258 commit fd959fd

63 files changed

Lines changed: 4742 additions & 370 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/cmd/server/wire_gen.go

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/internal/config/config.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -934,9 +934,10 @@ type DashboardAggregationConfig struct {
934934

935935
// DashboardAggregationRetentionConfig 预聚合保留窗口
936936
type DashboardAggregationRetentionConfig struct {
937-
UsageLogsDays int `mapstructure:"usage_logs_days"`
938-
HourlyDays int `mapstructure:"hourly_days"`
939-
DailyDays int `mapstructure:"daily_days"`
937+
UsageLogsDays int `mapstructure:"usage_logs_days"`
938+
UsageBillingDedupDays int `mapstructure:"usage_billing_dedup_days"`
939+
HourlyDays int `mapstructure:"hourly_days"`
940+
DailyDays int `mapstructure:"daily_days"`
940941
}
941942

942943
// UsageCleanupConfig 使用记录清理任务配置
@@ -1301,6 +1302,7 @@ func setDefaults() {
13011302
viper.SetDefault("dashboard_aggregation.backfill_enabled", false)
13021303
viper.SetDefault("dashboard_aggregation.backfill_max_days", 31)
13031304
viper.SetDefault("dashboard_aggregation.retention.usage_logs_days", 90)
1305+
viper.SetDefault("dashboard_aggregation.retention.usage_billing_dedup_days", 365)
13041306
viper.SetDefault("dashboard_aggregation.retention.hourly_days", 180)
13051307
viper.SetDefault("dashboard_aggregation.retention.daily_days", 730)
13061308
viper.SetDefault("dashboard_aggregation.recompute_days", 2)
@@ -1758,6 +1760,12 @@ func (c *Config) Validate() error {
17581760
if c.DashboardAgg.Retention.UsageLogsDays <= 0 {
17591761
return fmt.Errorf("dashboard_aggregation.retention.usage_logs_days must be positive")
17601762
}
1763+
if c.DashboardAgg.Retention.UsageBillingDedupDays <= 0 {
1764+
return fmt.Errorf("dashboard_aggregation.retention.usage_billing_dedup_days must be positive")
1765+
}
1766+
if c.DashboardAgg.Retention.UsageBillingDedupDays < c.DashboardAgg.Retention.UsageLogsDays {
1767+
return fmt.Errorf("dashboard_aggregation.retention.usage_billing_dedup_days must be greater than or equal to usage_logs_days")
1768+
}
17611769
if c.DashboardAgg.Retention.HourlyDays <= 0 {
17621770
return fmt.Errorf("dashboard_aggregation.retention.hourly_days must be positive")
17631771
}
@@ -1780,6 +1788,14 @@ func (c *Config) Validate() error {
17801788
if c.DashboardAgg.Retention.UsageLogsDays < 0 {
17811789
return fmt.Errorf("dashboard_aggregation.retention.usage_logs_days must be non-negative")
17821790
}
1791+
if c.DashboardAgg.Retention.UsageBillingDedupDays < 0 {
1792+
return fmt.Errorf("dashboard_aggregation.retention.usage_billing_dedup_days must be non-negative")
1793+
}
1794+
if c.DashboardAgg.Retention.UsageBillingDedupDays > 0 &&
1795+
c.DashboardAgg.Retention.UsageLogsDays > 0 &&
1796+
c.DashboardAgg.Retention.UsageBillingDedupDays < c.DashboardAgg.Retention.UsageLogsDays {
1797+
return fmt.Errorf("dashboard_aggregation.retention.usage_billing_dedup_days must be greater than or equal to usage_logs_days")
1798+
}
17831799
if c.DashboardAgg.Retention.HourlyDays < 0 {
17841800
return fmt.Errorf("dashboard_aggregation.retention.hourly_days must be non-negative")
17851801
}

backend/internal/config/config_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,9 @@ func TestLoadDefaultDashboardAggregationConfig(t *testing.T) {
441441
if cfg.DashboardAgg.Retention.UsageLogsDays != 90 {
442442
t.Fatalf("DashboardAgg.Retention.UsageLogsDays = %d, want 90", cfg.DashboardAgg.Retention.UsageLogsDays)
443443
}
444+
if cfg.DashboardAgg.Retention.UsageBillingDedupDays != 365 {
445+
t.Fatalf("DashboardAgg.Retention.UsageBillingDedupDays = %d, want 365", cfg.DashboardAgg.Retention.UsageBillingDedupDays)
446+
}
444447
if cfg.DashboardAgg.Retention.HourlyDays != 180 {
445448
t.Fatalf("DashboardAgg.Retention.HourlyDays = %d, want 180", cfg.DashboardAgg.Retention.HourlyDays)
446449
}
@@ -1016,6 +1019,23 @@ func TestValidateConfigErrors(t *testing.T) {
10161019
mutate: func(c *Config) { c.DashboardAgg.Enabled = true; c.DashboardAgg.Retention.UsageLogsDays = 0 },
10171020
wantErr: "dashboard_aggregation.retention.usage_logs_days",
10181021
},
1022+
{
1023+
name: "dashboard aggregation dedup retention",
1024+
mutate: func(c *Config) {
1025+
c.DashboardAgg.Enabled = true
1026+
c.DashboardAgg.Retention.UsageBillingDedupDays = 0
1027+
},
1028+
wantErr: "dashboard_aggregation.retention.usage_billing_dedup_days",
1029+
},
1030+
{
1031+
name: "dashboard aggregation dedup retention smaller than usage logs",
1032+
mutate: func(c *Config) {
1033+
c.DashboardAgg.Enabled = true
1034+
c.DashboardAgg.Retention.UsageLogsDays = 30
1035+
c.DashboardAgg.Retention.UsageBillingDedupDays = 29
1036+
},
1037+
wantErr: "dashboard_aggregation.retention.usage_billing_dedup_days",
1038+
},
10191039
{
10201040
name: "dashboard aggregation disabled interval",
10211041
mutate: func(c *Config) { c.DashboardAgg.Enabled = false; c.DashboardAgg.IntervalSeconds = -1 },

backend/internal/handler/admin/dashboard_handler.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,9 +466,60 @@ type BatchUsersUsageRequest struct {
466466
UserIDs []int64 `json:"user_ids" binding:"required"`
467467
}
468468

469+
var dashboardUsersRankingCache = newSnapshotCache(5 * time.Minute)
469470
var dashboardBatchUsersUsageCache = newSnapshotCache(30 * time.Second)
470471
var dashboardBatchAPIKeysUsageCache = newSnapshotCache(30 * time.Second)
471472

473+
func parseRankingLimit(raw string) int {
474+
limit, err := strconv.Atoi(strings.TrimSpace(raw))
475+
if err != nil || limit <= 0 {
476+
return 12
477+
}
478+
if limit > 50 {
479+
return 50
480+
}
481+
return limit
482+
}
483+
484+
// GetUserSpendingRanking handles getting user spending ranking data.
485+
// GET /api/v1/admin/dashboard/users-ranking
486+
func (h *DashboardHandler) GetUserSpendingRanking(c *gin.Context) {
487+
startTime, endTime := parseTimeRange(c)
488+
limit := parseRankingLimit(c.DefaultQuery("limit", "12"))
489+
490+
keyRaw, _ := json.Marshal(struct {
491+
Start string `json:"start"`
492+
End string `json:"end"`
493+
Limit int `json:"limit"`
494+
}{
495+
Start: startTime.UTC().Format(time.RFC3339),
496+
End: endTime.UTC().Format(time.RFC3339),
497+
Limit: limit,
498+
})
499+
cacheKey := string(keyRaw)
500+
if cached, ok := dashboardUsersRankingCache.Get(cacheKey); ok {
501+
c.Header("X-Snapshot-Cache", "hit")
502+
response.Success(c, cached.Payload)
503+
return
504+
}
505+
506+
ranking, err := h.dashboardService.GetUserSpendingRanking(c.Request.Context(), startTime, endTime, limit)
507+
if err != nil {
508+
response.Error(c, 500, "Failed to get user spending ranking")
509+
return
510+
}
511+
512+
payload := gin.H{
513+
"ranking": ranking.Ranking,
514+
"total_actual_cost": ranking.TotalActualCost,
515+
"start_date": startTime.Format("2006-01-02"),
516+
"end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
517+
}
518+
dashboardUsersRankingCache.Set(cacheKey, payload)
519+
c.Header("X-Snapshot-Cache", "miss")
520+
response.Success(c, payload)
521+
}
522+
472523
// GetBatchUsersUsage handles getting usage stats for multiple users
473524
// POST /api/v1/admin/dashboard/users-usage
474525
func (h *DashboardHandler) GetBatchUsersUsage(c *gin.Context) {

backend/internal/handler/admin/dashboard_handler_request_type_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ type dashboardUsageRepoCapture struct {
1919
trendStream *bool
2020
modelRequestType *int16
2121
modelStream *bool
22+
rankingLimit int
23+
ranking []usagestats.UserSpendingRankingItem
24+
rankingTotal float64
2225
}
2326

2427
func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters(
@@ -49,13 +52,26 @@ func (s *dashboardUsageRepoCapture) GetModelStatsWithFilters(
4952
return []usagestats.ModelStat{}, nil
5053
}
5154

55+
func (s *dashboardUsageRepoCapture) GetUserSpendingRanking(
56+
ctx context.Context,
57+
startTime, endTime time.Time,
58+
limit int,
59+
) (*usagestats.UserSpendingRankingResponse, error) {
60+
s.rankingLimit = limit
61+
return &usagestats.UserSpendingRankingResponse{
62+
Ranking: s.ranking,
63+
TotalActualCost: s.rankingTotal,
64+
}, nil
65+
}
66+
5267
func newDashboardRequestTypeTestRouter(repo *dashboardUsageRepoCapture) *gin.Engine {
5368
gin.SetMode(gin.TestMode)
5469
dashboardSvc := service.NewDashboardService(repo, nil, nil, nil)
5570
handler := NewDashboardHandler(dashboardSvc, nil)
5671
router := gin.New()
5772
router.GET("/admin/dashboard/trend", handler.GetUsageTrend)
5873
router.GET("/admin/dashboard/models", handler.GetModelStats)
74+
router.GET("/admin/dashboard/users-ranking", handler.GetUserSpendingRanking)
5975
return router
6076
}
6177

@@ -130,3 +146,30 @@ func TestDashboardModelStatsInvalidStream(t *testing.T) {
130146

131147
require.Equal(t, http.StatusBadRequest, rec.Code)
132148
}
149+
150+
func TestDashboardUsersRankingLimitAndCache(t *testing.T) {
151+
dashboardUsersRankingCache = newSnapshotCache(5 * time.Minute)
152+
repo := &dashboardUsageRepoCapture{
153+
ranking: []usagestats.UserSpendingRankingItem{
154+
{UserID: 7, Email: "rank@example.com", ActualCost: 10.5, Requests: 3, Tokens: 300},
155+
},
156+
rankingTotal: 88.8,
157+
}
158+
router := newDashboardRequestTypeTestRouter(repo)
159+
160+
req := httptest.NewRequest(http.MethodGet, "/admin/dashboard/users-ranking?limit=100&start_date=2025-01-01&end_date=2025-01-02", nil)
161+
rec := httptest.NewRecorder()
162+
router.ServeHTTP(rec, req)
163+
164+
require.Equal(t, http.StatusOK, rec.Code)
165+
require.Equal(t, 50, repo.rankingLimit)
166+
require.Contains(t, rec.Body.String(), "\"total_actual_cost\":88.8")
167+
require.Equal(t, "miss", rec.Header().Get("X-Snapshot-Cache"))
168+
169+
req2 := httptest.NewRequest(http.MethodGet, "/admin/dashboard/users-ranking?limit=100&start_date=2025-01-01&end_date=2025-01-02", nil)
170+
rec2 := httptest.NewRecorder()
171+
router.ServeHTTP(rec2, req2)
172+
173+
require.Equal(t, http.StatusOK, rec2.Code)
174+
require.Equal(t, "hit", rec2.Header().Get("X-Snapshot-Cache"))
175+
}

backend/internal/handler/admin/redeem_handler.go

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,15 @@ type GenerateRedeemCodesRequest struct {
4141
}
4242

4343
// CreateAndRedeemCodeRequest represents creating a fixed code and redeeming it for a target user.
44+
// Type 为 omitempty 而非 required 是为了向后兼容旧版调用方(不传 type 时默认 balance)。
4445
type CreateAndRedeemCodeRequest struct {
45-
Code string `json:"code" binding:"required,min=3,max=128"`
46-
Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"`
47-
Value float64 `json:"value" binding:"required,gt=0"`
48-
UserID int64 `json:"user_id" binding:"required,gt=0"`
49-
Notes string `json:"notes"`
46+
Code string `json:"code" binding:"required,min=3,max=128"`
47+
Type string `json:"type" binding:"omitempty,oneof=balance concurrency subscription invitation"` // 不传时默认 balance(向后兼容)
48+
Value float64 `json:"value" binding:"required,gt=0"`
49+
UserID int64 `json:"user_id" binding:"required,gt=0"`
50+
GroupID *int64 `json:"group_id"` // subscription 类型必填
51+
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // subscription 类型必填,>0
52+
Notes string `json:"notes"`
5053
}
5154

5255
// List handles listing all redeem codes with pagination
@@ -136,6 +139,22 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
136139
return
137140
}
138141
req.Code = strings.TrimSpace(req.Code)
142+
// 向后兼容:旧版调用方(如 Sub2ApiPay)不传 type 字段,默认当作 balance 充值处理。
143+
// 请勿删除此默认值逻辑,否则会导致旧版调用方 400 报错。
144+
if req.Type == "" {
145+
req.Type = "balance"
146+
}
147+
148+
if req.Type == "subscription" {
149+
if req.GroupID == nil {
150+
response.BadRequest(c, "group_id is required for subscription type")
151+
return
152+
}
153+
if req.ValidityDays <= 0 {
154+
response.BadRequest(c, "validity_days must be greater than 0 for subscription type")
155+
return
156+
}
157+
}
139158

140159
executeAdminIdempotentJSON(c, "admin.redeem_codes.create_and_redeem", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
141160
existing, err := h.redeemService.GetByCode(ctx, req.Code)
@@ -147,11 +166,13 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
147166
}
148167

149168
createErr := h.redeemService.CreateCode(ctx, &service.RedeemCode{
150-
Code: req.Code,
151-
Type: req.Type,
152-
Value: req.Value,
153-
Status: service.StatusUnused,
154-
Notes: req.Notes,
169+
Code: req.Code,
170+
Type: req.Type,
171+
Value: req.Value,
172+
Status: service.StatusUnused,
173+
Notes: req.Notes,
174+
GroupID: req.GroupID,
175+
ValidityDays: req.ValidityDays,
155176
})
156177
if createErr != nil {
157178
// Unique code race: if code now exists, use idempotent semantics by used_by.

0 commit comments

Comments
 (0)