package repository import ( "context" "database/sql" "errors" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" "github.com/Wei-Shaw/sub2api/internal/service" ) // getPerformanceStats 获取 RPM 和 TPM(近5分钟平均值,可选按用户过滤) func (r *usageLogRepository) getPerformanceStats(ctx context.Context, userID int64) (rpm, tpm int64, err error) { fiveMinutesAgo := time.Now().Add(-5 * time.Minute) query := ` SELECT COUNT(*) as request_count, COALESCE(SUM(input_tokens + output_tokens), 0) as token_count FROM usage_logs WHERE created_at >= $1` args := []any{fiveMinutesAgo} if userID > 0 { query += " AND user_id = $2" args = append(args, userID) } var requestCount int64 var tokenCount int64 if err := scanSingleRow(ctx, r.sql, query, args, &requestCount, &tokenCount); err != nil { return 0, 0, err } return requestCount / 5, tokenCount / 5, nil } // UserStats 用户使用统计 type UserStats struct { TotalRequests int64 `json:"total_requests"` TotalTokens int64 `json:"total_tokens"` TotalCost float64 `json:"total_cost"` InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` CacheReadTokens int64 `json:"cache_read_tokens"` } func (r *usageLogRepository) GetUserStats(ctx context.Context, userID int64, startTime, endTime time.Time) (*UserStats, error) { query := ` SELECT COUNT(*) as total_requests, COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens, COALESCE(SUM(actual_cost), 0) as total_cost, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens, COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens FROM usage_logs WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 ` stats := &UserStats{} if err := scanSingleRow( ctx, r.sql, query, []any{userID, startTime, endTime}, &stats.TotalRequests, &stats.TotalTokens, &stats.TotalCost, &stats.InputTokens, &stats.OutputTokens, &stats.CacheReadTokens, ); err != nil { return nil, err } return stats, nil } // DashboardStats 仪表盘统计 type DashboardStats = usagestats.DashboardStats func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) { stats := &DashboardStats{} now := timezone.Now() todayStart := timezone.Today() if err := r.fillDashboardEntityStats(ctx, stats, todayStart, now); err != nil { return nil, err } if err := r.fillDashboardUsageStatsAggregated(ctx, stats, todayStart, now); err != nil { return nil, err } rpm, tpm, err := r.getPerformanceStats(ctx, 0) if err != nil { return nil, err } stats.Rpm = rpm stats.Tpm = tpm return stats, nil } func (r *usageLogRepository) GetDashboardStatsWithRange(ctx context.Context, start, end time.Time) (*DashboardStats, error) { startUTC := start.UTC() endUTC := end.UTC() if !endUTC.After(startUTC) { return nil, errors.New("统计时间范围无效") } stats := &DashboardStats{} now := timezone.Now() todayStart := timezone.Today() if err := r.fillDashboardEntityStats(ctx, stats, todayStart, now); err != nil { return nil, err } if err := r.fillDashboardUsageStatsFromUsageLogs(ctx, stats, startUTC, endUTC, todayStart, now); err != nil { return nil, err } rpm, tpm, err := r.getPerformanceStats(ctx, 0) if err != nil { return nil, err } stats.Rpm = rpm stats.Tpm = tpm return stats, nil } func (r *usageLogRepository) fillDashboardEntityStats(ctx context.Context, stats *DashboardStats, todayUTC, now time.Time) error { userStatsQuery := ` SELECT COUNT(*) as total_users, COUNT(CASE WHEN created_at >= $1 THEN 1 END) as today_new_users FROM users WHERE deleted_at IS NULL ` if err := scanSingleRow( ctx, r.sql, userStatsQuery, []any{todayUTC}, &stats.TotalUsers, &stats.TodayNewUsers, ); err != nil { return err } apiKeyStatsQuery := ` SELECT COUNT(*) as total_api_keys, COUNT(CASE WHEN status = $1 THEN 1 END) as active_api_keys FROM api_keys WHERE deleted_at IS NULL ` if err := scanSingleRow( ctx, r.sql, apiKeyStatsQuery, []any{service.StatusActive}, &stats.TotalAPIKeys, &stats.ActiveAPIKeys, ); err != nil { return err } accountStatsQuery := ` SELECT COUNT(*) as total_accounts, COUNT(CASE WHEN status = $1 AND schedulable = true THEN 1 END) as normal_accounts, COUNT(CASE WHEN status = $2 THEN 1 END) as error_accounts, COUNT(CASE WHEN rate_limited_at IS NOT NULL AND rate_limit_reset_at > $3 THEN 1 END) as ratelimit_accounts, COUNT(CASE WHEN overload_until IS NOT NULL AND overload_until > $4 THEN 1 END) as overload_accounts FROM accounts WHERE deleted_at IS NULL ` if err := scanSingleRow( ctx, r.sql, accountStatsQuery, []any{service.StatusActive, service.StatusError, now, now}, &stats.TotalAccounts, &stats.NormalAccounts, &stats.ErrorAccounts, &stats.RateLimitAccounts, &stats.OverloadAccounts, ); err != nil { return err } return nil } func (r *usageLogRepository) fillDashboardUsageStatsAggregated(ctx context.Context, stats *DashboardStats, todayUTC, now time.Time) error { totalStatsQuery := ` SELECT COALESCE(SUM(total_requests), 0) as total_requests, COALESCE(SUM(input_tokens), 0) as total_input_tokens, COALESCE(SUM(output_tokens), 0) as total_output_tokens, COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM(actual_cost), 0) as total_actual_cost, COALESCE(SUM(account_cost), 0) as total_account_cost, COALESCE(SUM(total_duration_ms), 0) as total_duration_ms FROM usage_dashboard_daily ` var totalDurationMs int64 if err := scanSingleRow( ctx, r.sql, totalStatsQuery, nil, &stats.TotalRequests, &stats.TotalInputTokens, &stats.TotalOutputTokens, &stats.TotalCacheCreationTokens, &stats.TotalCacheReadTokens, &stats.TotalCost, &stats.TotalActualCost, &stats.TotalAccountCost, &totalDurationMs, ); err != nil { return err } stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens if stats.TotalRequests > 0 { stats.AverageDurationMs = float64(totalDurationMs) / float64(stats.TotalRequests) } todayStatsQuery := ` SELECT total_requests as today_requests, input_tokens as today_input_tokens, output_tokens as today_output_tokens, cache_creation_tokens as today_cache_creation_tokens, cache_read_tokens as today_cache_read_tokens, total_cost as today_cost, actual_cost as today_actual_cost, account_cost as today_account_cost, active_users as active_users FROM usage_dashboard_daily WHERE bucket_date = $1::date ` if err := scanSingleRow( ctx, r.sql, todayStatsQuery, []any{todayUTC}, &stats.TodayRequests, &stats.TodayInputTokens, &stats.TodayOutputTokens, &stats.TodayCacheCreationTokens, &stats.TodayCacheReadTokens, &stats.TodayCost, &stats.TodayActualCost, &stats.TodayAccountCost, &stats.ActiveUsers, ); err != nil { if err != sql.ErrNoRows { return err } } stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens hourlyActiveQuery := ` SELECT active_users FROM usage_dashboard_hourly WHERE bucket_start = $1 ` hourStart := now.In(timezone.Location()).Truncate(time.Hour) if err := scanSingleRow(ctx, r.sql, hourlyActiveQuery, []any{hourStart}, &stats.HourlyActiveUsers); err != nil { if err != sql.ErrNoRows { return err } } return nil } func (r *usageLogRepository) fillDashboardUsageStatsFromUsageLogs(ctx context.Context, stats *DashboardStats, startUTC, endUTC, todayUTC, now time.Time) error { todayEnd := todayUTC.Add(24 * time.Hour) combinedStatsQuery := ` WITH scoped AS ( SELECT created_at, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, total_cost, actual_cost, COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1) AS account_cost, COALESCE(duration_ms, 0) AS duration_ms FROM usage_logs WHERE created_at >= LEAST($1::timestamptz, $3::timestamptz) AND created_at < GREATEST($2::timestamptz, $4::timestamptz) ) SELECT COUNT(*) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz) AS total_requests, COALESCE(SUM(input_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_input_tokens, COALESCE(SUM(output_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_output_tokens, COALESCE(SUM(cache_creation_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_cache_creation_tokens, COALESCE(SUM(cache_read_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_cache_read_tokens, COALESCE(SUM(total_cost) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_cost, COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_actual_cost, COALESCE(SUM(account_cost) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_account_cost, COALESCE(SUM(duration_ms) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_duration_ms, COUNT(*) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz) AS today_requests, COALESCE(SUM(input_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_input_tokens, COALESCE(SUM(output_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_output_tokens, COALESCE(SUM(cache_creation_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_cache_creation_tokens, COALESCE(SUM(cache_read_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_cache_read_tokens, COALESCE(SUM(total_cost) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_cost, COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_actual_cost, COALESCE(SUM(account_cost) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_account_cost FROM scoped ` var totalDurationMs int64 if err := scanSingleRow( ctx, r.sql, combinedStatsQuery, []any{startUTC, endUTC, todayUTC, todayEnd}, &stats.TotalRequests, &stats.TotalInputTokens, &stats.TotalOutputTokens, &stats.TotalCacheCreationTokens, &stats.TotalCacheReadTokens, &stats.TotalCost, &stats.TotalActualCost, &stats.TotalAccountCost, &totalDurationMs, &stats.TodayRequests, &stats.TodayInputTokens, &stats.TodayOutputTokens, &stats.TodayCacheCreationTokens, &stats.TodayCacheReadTokens, &stats.TodayCost, &stats.TodayActualCost, &stats.TodayAccountCost, ); err != nil { return err } stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens if stats.TotalRequests > 0 { stats.AverageDurationMs = float64(totalDurationMs) / float64(stats.TotalRequests) } stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens hourStart := now.UTC().Truncate(time.Hour) hourEnd := hourStart.Add(time.Hour) activeUsersQuery := ` WITH scoped AS ( SELECT user_id, created_at FROM usage_logs WHERE created_at >= LEAST($1::timestamptz, $3::timestamptz) AND created_at < GREATEST($2::timestamptz, $4::timestamptz) ) SELECT COUNT(DISTINCT CASE WHEN created_at >= $1::timestamptz AND created_at < $2::timestamptz THEN user_id END) AS active_users, COUNT(DISTINCT CASE WHEN created_at >= $3::timestamptz AND created_at < $4::timestamptz THEN user_id END) AS hourly_active_users FROM scoped ` if err := scanSingleRow(ctx, r.sql, activeUsersQuery, []any{todayUTC, todayEnd, hourStart, hourEnd}, &stats.ActiveUsers, &stats.HourlyActiveUsers); err != nil { return err } return nil } // UserDashboardStats 用户仪表盘统计 type UserDashboardStats = usagestats.UserDashboardStats // PlatformDashboardStats 单平台用量明细 type PlatformDashboardStats = usagestats.PlatformDashboardStats // GetUserDashboardStats 获取用户专属的仪表盘统计 func (r *usageLogRepository) GetUserDashboardStats(ctx context.Context, userID int64) (*UserDashboardStats, error) { stats := &UserDashboardStats{} today := timezone.Today() // API Key 统计 if err := scanSingleRow( ctx, r.sql, "SELECT COUNT(*) FROM api_keys WHERE user_id = $1 AND deleted_at IS NULL", []any{userID}, &stats.TotalAPIKeys, ); err != nil { return nil, err } if err := scanSingleRow( ctx, r.sql, "SELECT COUNT(*) FROM api_keys WHERE user_id = $1 AND status = $2 AND deleted_at IS NULL", []any{userID, service.StatusActive}, &stats.ActiveAPIKeys, ); err != nil { return nil, err } // 累计 Token 统计 totalStatsQuery := ` SELECT COUNT(*) as total_requests, COALESCE(SUM(input_tokens), 0) as total_input_tokens, COALESCE(SUM(output_tokens), 0) as total_output_tokens, COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM(actual_cost), 0) as total_actual_cost, COALESCE(AVG(duration_ms), 0) as avg_duration_ms FROM usage_logs WHERE user_id = $1 ` if err := scanSingleRow( ctx, r.sql, totalStatsQuery, []any{userID}, &stats.TotalRequests, &stats.TotalInputTokens, &stats.TotalOutputTokens, &stats.TotalCacheCreationTokens, &stats.TotalCacheReadTokens, &stats.TotalCost, &stats.TotalActualCost, &stats.AverageDurationMs, ); err != nil { return nil, err } stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens // 今日 Token 统计 todayStatsQuery := ` SELECT COUNT(*) as today_requests, COALESCE(SUM(input_tokens), 0) as today_input_tokens, COALESCE(SUM(output_tokens), 0) as today_output_tokens, COALESCE(SUM(cache_creation_tokens), 0) as today_cache_creation_tokens, COALESCE(SUM(cache_read_tokens), 0) as today_cache_read_tokens, COALESCE(SUM(total_cost), 0) as today_cost, COALESCE(SUM(actual_cost), 0) as today_actual_cost FROM usage_logs WHERE user_id = $1 AND created_at >= $2 ` if err := scanSingleRow( ctx, r.sql, todayStatsQuery, []any{userID, today}, &stats.TodayRequests, &stats.TodayInputTokens, &stats.TodayOutputTokens, &stats.TodayCacheCreationTokens, &stats.TodayCacheReadTokens, &stats.TodayCost, &stats.TodayActualCost, ); err != nil { return nil, err } stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens // 性能指标:RPM 和 TPM(最近1分钟,仅统计该用户的请求) rpm, tpm, err := r.getPerformanceStats(ctx, userID) if err != nil { return nil, err } stats.Rpm = rpm stats.Tpm = tpm // 按"有效平台"维度拆分(group.platform 优先,否则 account.platform)。 // 与 ops 路径口径一致;HAVING 过滤掉无法确定平台的行(避免出现空字符串平台)。 // 与上面 totalStatsQuery/todayStatsQuery 的总值可能略微差异,原因有二: // 1) 无平台归属的极少数行(group/account 都没 platform)会被 HAVING 排除; // 2) usageLogSuccessFilterUL 会把 actual_cost = 0 的失败 placeholder 行排除, // 而 totalStatsQuery/todayStatsQuery 没有这层过滤、会把这些行的 request 计数算进去。 platformQuery := ` SELECT ` + usageLogEffectivePlatformExpr + ` as platform, COUNT(*) as total_requests, COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens, COALESCE(SUM(ul.actual_cost), 0) as total_actual_cost, COUNT(*) FILTER (WHERE ul.created_at >= $2) as today_requests, COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens) FILTER (WHERE ul.created_at >= $2), 0) as today_tokens, COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $2), 0) as today_actual_cost FROM usage_logs ul LEFT JOIN groups g ON g.id = ul.group_id LEFT JOIN accounts a ON a.id = ul.account_id WHERE ul.user_id = $1 AND ` + usageLogSuccessFilterUL + ` GROUP BY ` + usageLogEffectivePlatformExpr + ` HAVING ` + usageLogEffectivePlatformExpr + ` IS NOT NULL AND ` + usageLogEffectivePlatformExpr + ` <> '' ORDER BY total_actual_cost DESC ` rows, err := r.sql.QueryContext(ctx, platformQuery, userID, today) if err != nil { return nil, err } for rows.Next() { var p PlatformDashboardStats if err := rows.Scan( &p.Platform, &p.TotalRequests, &p.TotalTokens, &p.TotalActualCost, &p.TodayRequests, &p.TodayTokens, &p.TodayActualCost, ); err != nil { _ = rows.Close() return nil, err } stats.ByPlatform = append(stats.ByPlatform, p) } if err := rows.Close(); err != nil { return nil, err } if err := rows.Err(); err != nil { return nil, err } return stats, nil } // getPerformanceStatsByAPIKey 获取指定 API Key 的 RPM 和 TPM(近5分钟平均值) func (r *usageLogRepository) getPerformanceStatsByAPIKey(ctx context.Context, apiKeyID int64) (rpm, tpm int64, err error) { fiveMinutesAgo := time.Now().Add(-5 * time.Minute) query := ` SELECT COUNT(*) as request_count, COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as token_count FROM usage_logs WHERE created_at >= $1 AND api_key_id = $2` args := []any{fiveMinutesAgo, apiKeyID} var requestCount int64 var tokenCount int64 if err := scanSingleRow(ctx, r.sql, query, args, &requestCount, &tokenCount); err != nil { return 0, 0, err } return requestCount / 5, tokenCount / 5, nil } // GetAPIKeyDashboardStats 获取指定 API Key 的仪表盘统计(按 api_key_id 过滤) func (r *usageLogRepository) GetAPIKeyDashboardStats(ctx context.Context, apiKeyID int64) (*UserDashboardStats, error) { stats := &UserDashboardStats{} today := timezone.Today() // API Key 维度不需要统计 key 数量,设为 1 stats.TotalAPIKeys = 1 stats.ActiveAPIKeys = 1 // 累计 Token 统计 totalStatsQuery := ` SELECT COUNT(*) as total_requests, COALESCE(SUM(input_tokens), 0) as total_input_tokens, COALESCE(SUM(output_tokens), 0) as total_output_tokens, COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM(actual_cost), 0) as total_actual_cost, COALESCE(AVG(duration_ms), 0) as avg_duration_ms FROM usage_logs WHERE api_key_id = $1 ` if err := scanSingleRow( ctx, r.sql, totalStatsQuery, []any{apiKeyID}, &stats.TotalRequests, &stats.TotalInputTokens, &stats.TotalOutputTokens, &stats.TotalCacheCreationTokens, &stats.TotalCacheReadTokens, &stats.TotalCost, &stats.TotalActualCost, &stats.AverageDurationMs, ); err != nil { return nil, err } stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens // 今日 Token 统计 todayStatsQuery := ` SELECT COUNT(*) as today_requests, COALESCE(SUM(input_tokens), 0) as today_input_tokens, COALESCE(SUM(output_tokens), 0) as today_output_tokens, COALESCE(SUM(cache_creation_tokens), 0) as today_cache_creation_tokens, COALESCE(SUM(cache_read_tokens), 0) as today_cache_read_tokens, COALESCE(SUM(total_cost), 0) as today_cost, COALESCE(SUM(actual_cost), 0) as today_actual_cost FROM usage_logs WHERE api_key_id = $1 AND created_at >= $2 ` if err := scanSingleRow( ctx, r.sql, todayStatsQuery, []any{apiKeyID, today}, &stats.TodayRequests, &stats.TodayInputTokens, &stats.TodayOutputTokens, &stats.TodayCacheCreationTokens, &stats.TodayCacheReadTokens, &stats.TodayCost, &stats.TodayActualCost, ); err != nil { return nil, err } stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens // 性能指标:RPM 和 TPM(最近5分钟,按 API Key 过滤) rpm, tpm, err := r.getPerformanceStatsByAPIKey(ctx, apiKeyID) if err != nil { return nil, err } stats.Rpm = rpm stats.Tpm = tpm return stats, nil }