| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757 |
- package admin
- import (
- "encoding/json"
- "errors"
- "strconv"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/response"
- "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
- "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- )
- // DashboardHandler handles admin dashboard statistics
- type DashboardHandler struct {
- dashboardService *service.DashboardService
- aggregationService *service.DashboardAggregationService
- startTime time.Time // Server start time for uptime calculation
- }
- // NewDashboardHandler creates a new admin dashboard handler
- func NewDashboardHandler(dashboardService *service.DashboardService, aggregationService *service.DashboardAggregationService) *DashboardHandler {
- return &DashboardHandler{
- dashboardService: dashboardService,
- aggregationService: aggregationService,
- startTime: time.Now(),
- }
- }
- // parseTimeRange parses start_date, end_date query parameters
- // Uses user's timezone if provided, otherwise falls back to server timezone
- func parseTimeRange(c *gin.Context) (time.Time, time.Time) {
- userTZ := c.Query("timezone") // Get user's timezone from request
- now := timezone.NowInUserLocation(userTZ)
- startDate := c.Query("start_date")
- endDate := c.Query("end_date")
- var startTime, endTime time.Time
- if startDate != "" {
- if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil {
- startTime = t
- } else {
- startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
- }
- } else {
- startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
- }
- if endDate != "" {
- if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil {
- endTime = t.Add(24 * time.Hour) // Include the end date
- } else {
- endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
- }
- } else {
- endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
- }
- return startTime, endTime
- }
- func parseOptionalBoolDashboardFilter(c *gin.Context, name string) (*bool, error) {
- raw := strings.TrimSpace(c.Query(name))
- if raw == "" {
- return nil, nil
- }
- value, err := strconv.ParseBool(raw)
- if err != nil {
- return nil, err
- }
- return &value, nil
- }
- // GetStats handles getting dashboard statistics
- // GET /api/v1/admin/dashboard/stats
- func (h *DashboardHandler) GetStats(c *gin.Context) {
- stats, err := h.dashboardService.GetDashboardStats(c.Request.Context())
- if err != nil {
- response.Error(c, 500, "Failed to get dashboard statistics")
- return
- }
- // Calculate uptime in seconds
- uptime := int64(time.Since(h.startTime).Seconds())
- response.Success(c, gin.H{
- // 用户统计
- "total_users": stats.TotalUsers,
- "today_new_users": stats.TodayNewUsers,
- "active_users": stats.ActiveUsers,
- // API Key 统计
- "total_api_keys": stats.TotalAPIKeys,
- "active_api_keys": stats.ActiveAPIKeys,
- // 账户统计
- "total_accounts": stats.TotalAccounts,
- "normal_accounts": stats.NormalAccounts,
- "error_accounts": stats.ErrorAccounts,
- "ratelimit_accounts": stats.RateLimitAccounts,
- "overload_accounts": stats.OverloadAccounts,
- // 累计 Token 使用统计
- "total_requests": stats.TotalRequests,
- "total_input_tokens": stats.TotalInputTokens,
- "total_output_tokens": stats.TotalOutputTokens,
- "total_cache_creation_tokens": stats.TotalCacheCreationTokens,
- "total_cache_read_tokens": stats.TotalCacheReadTokens,
- "total_tokens": stats.TotalTokens,
- "total_cost": stats.TotalCost, // 标准计费
- "total_actual_cost": stats.TotalActualCost, // 实际扣除
- // 今日 Token 使用统计
- "today_requests": stats.TodayRequests,
- "today_input_tokens": stats.TodayInputTokens,
- "today_output_tokens": stats.TodayOutputTokens,
- "today_cache_creation_tokens": stats.TodayCacheCreationTokens,
- "today_cache_read_tokens": stats.TodayCacheReadTokens,
- "today_tokens": stats.TodayTokens,
- "today_cost": stats.TodayCost, // 今日标准计费
- "today_actual_cost": stats.TodayActualCost, // 今日实际扣除
- // 系统运行统计
- "average_duration_ms": stats.AverageDurationMs,
- "uptime": uptime,
- // 性能指标
- "rpm": stats.Rpm,
- "tpm": stats.Tpm,
- // 预聚合新鲜度
- "hourly_active_users": stats.HourlyActiveUsers,
- "stats_updated_at": stats.StatsUpdatedAt,
- "stats_stale": stats.StatsStale,
- })
- }
- type DashboardAggregationBackfillRequest struct {
- Start string `json:"start"`
- End string `json:"end"`
- }
- // BackfillAggregation handles triggering aggregation backfill
- // POST /api/v1/admin/dashboard/aggregation/backfill
- func (h *DashboardHandler) BackfillAggregation(c *gin.Context) {
- if h.aggregationService == nil {
- response.InternalError(c, "Aggregation service not available")
- return
- }
- var req DashboardAggregationBackfillRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request body")
- return
- }
- start, err := time.Parse(time.RFC3339, req.Start)
- if err != nil {
- response.BadRequest(c, "Invalid start time")
- return
- }
- end, err := time.Parse(time.RFC3339, req.End)
- if err != nil {
- response.BadRequest(c, "Invalid end time")
- return
- }
- if err := h.aggregationService.TriggerBackfill(start, end); err != nil {
- if errors.Is(err, service.ErrDashboardBackfillDisabled) {
- response.Forbidden(c, "Backfill is disabled")
- return
- }
- if errors.Is(err, service.ErrDashboardBackfillTooLarge) {
- response.BadRequest(c, "Backfill range too large")
- return
- }
- response.InternalError(c, "Failed to trigger backfill")
- return
- }
- response.Success(c, gin.H{
- "status": "accepted",
- })
- }
- // GetRealtimeMetrics handles getting real-time system metrics
- // GET /api/v1/admin/dashboard/realtime
- func (h *DashboardHandler) GetRealtimeMetrics(c *gin.Context) {
- // Return mock data for now
- response.Success(c, gin.H{
- "active_requests": 0,
- "requests_per_minute": 0,
- "average_response_time": 0,
- "error_rate": 0.0,
- })
- }
- // GetUsageTrend handles getting usage trend data
- // GET /api/v1/admin/dashboard/trend
- // Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), user_id, api_key_id, model, account_id, group_id, request_type, stream, billing_type
- func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
- startTime, endTime := parseTimeRange(c)
- granularity := c.DefaultQuery("granularity", "day")
- // Parse optional filter params
- var userID, apiKeyID, accountID, groupID int64
- var model string
- var requestType *int16
- var stream *bool
- var billingType *int8
- var upstreamModelMismatch *bool
- if userIDStr := c.Query("user_id"); userIDStr != "" {
- if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
- userID = id
- }
- }
- if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
- if id, err := strconv.ParseInt(apiKeyIDStr, 10, 64); err == nil {
- apiKeyID = id
- }
- }
- if accountIDStr := c.Query("account_id"); accountIDStr != "" {
- if id, err := strconv.ParseInt(accountIDStr, 10, 64); err == nil {
- accountID = id
- }
- }
- if groupIDStr := c.Query("group_id"); groupIDStr != "" {
- if id, err := strconv.ParseInt(groupIDStr, 10, 64); err == nil {
- groupID = id
- }
- }
- if modelStr := c.Query("model"); modelStr != "" {
- model = modelStr
- }
- if requestTypeStr := strings.TrimSpace(c.Query("request_type")); requestTypeStr != "" {
- parsed, err := service.ParseUsageRequestType(requestTypeStr)
- if err != nil {
- response.BadRequest(c, err.Error())
- return
- }
- value := int16(parsed)
- requestType = &value
- } else if streamStr := c.Query("stream"); streamStr != "" {
- if streamVal, err := strconv.ParseBool(streamStr); err == nil {
- stream = &streamVal
- } else {
- response.BadRequest(c, "Invalid stream value, use true or false")
- return
- }
- }
- if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
- if v, err := strconv.ParseInt(billingTypeStr, 10, 8); err == nil {
- bt := int8(v)
- billingType = &bt
- } else {
- response.BadRequest(c, "Invalid billing_type")
- return
- }
- }
- nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
- if err != nil {
- response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
- return
- }
- upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
- if err != nil {
- response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
- return
- }
- trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
- if err != nil {
- response.Error(c, 500, "Failed to get usage trend")
- return
- }
- c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
- response.Success(c, gin.H{
- "trend": trend,
- "start_date": startTime.Format("2006-01-02"),
- "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
- "granularity": granularity,
- })
- }
- // GetModelStats handles getting model usage statistics
- // GET /api/v1/admin/dashboard/models
- // Query params: start_date, end_date (YYYY-MM-DD), user_id, api_key_id, account_id, group_id, request_type, stream, billing_type
- func (h *DashboardHandler) GetModelStats(c *gin.Context) {
- startTime, endTime := parseTimeRange(c)
- // Parse optional filter params
- var userID, apiKeyID, accountID, groupID int64
- modelSource := usagestats.ModelSourceRequested
- var requestType *int16
- var stream *bool
- var billingType *int8
- var upstreamModelMismatch *bool
- if userIDStr := c.Query("user_id"); userIDStr != "" {
- if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
- userID = id
- }
- }
- if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
- if id, err := strconv.ParseInt(apiKeyIDStr, 10, 64); err == nil {
- apiKeyID = id
- }
- }
- if accountIDStr := c.Query("account_id"); accountIDStr != "" {
- if id, err := strconv.ParseInt(accountIDStr, 10, 64); err == nil {
- accountID = id
- }
- }
- if groupIDStr := c.Query("group_id"); groupIDStr != "" {
- if id, err := strconv.ParseInt(groupIDStr, 10, 64); err == nil {
- groupID = id
- }
- }
- if rawModelSource := strings.TrimSpace(c.Query("model_source")); rawModelSource != "" {
- if !usagestats.IsValidModelSource(rawModelSource) {
- response.BadRequest(c, "Invalid model_source, use requested/upstream/mapping")
- return
- }
- modelSource = rawModelSource
- }
- if requestTypeStr := strings.TrimSpace(c.Query("request_type")); requestTypeStr != "" {
- parsed, err := service.ParseUsageRequestType(requestTypeStr)
- if err != nil {
- response.BadRequest(c, err.Error())
- return
- }
- value := int16(parsed)
- requestType = &value
- } else if streamStr := c.Query("stream"); streamStr != "" {
- if streamVal, err := strconv.ParseBool(streamStr); err == nil {
- stream = &streamVal
- } else {
- response.BadRequest(c, "Invalid stream value, use true or false")
- return
- }
- }
- if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
- if v, err := strconv.ParseInt(billingTypeStr, 10, 8); err == nil {
- bt := int8(v)
- billingType = &bt
- } else {
- response.BadRequest(c, "Invalid billing_type")
- return
- }
- }
- nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
- if err != nil {
- response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
- return
- }
- upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
- if err != nil {
- response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
- return
- }
- stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
- if err != nil {
- response.Error(c, 500, "Failed to get model statistics")
- return
- }
- c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
- response.Success(c, gin.H{
- "models": stats,
- "start_date": startTime.Format("2006-01-02"),
- "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
- })
- }
- // GetGroupStats handles getting group usage statistics
- // GET /api/v1/admin/dashboard/groups
- // Query params: start_date, end_date (YYYY-MM-DD), user_id, api_key_id, account_id, group_id, request_type, stream, billing_type
- func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
- startTime, endTime := parseTimeRange(c)
- var userID, apiKeyID, accountID, groupID int64
- var requestType *int16
- var stream *bool
- var billingType *int8
- var upstreamModelMismatch *bool
- if userIDStr := c.Query("user_id"); userIDStr != "" {
- if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
- userID = id
- }
- }
- if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
- if id, err := strconv.ParseInt(apiKeyIDStr, 10, 64); err == nil {
- apiKeyID = id
- }
- }
- if accountIDStr := c.Query("account_id"); accountIDStr != "" {
- if id, err := strconv.ParseInt(accountIDStr, 10, 64); err == nil {
- accountID = id
- }
- }
- if groupIDStr := c.Query("group_id"); groupIDStr != "" {
- if id, err := strconv.ParseInt(groupIDStr, 10, 64); err == nil {
- groupID = id
- }
- }
- if requestTypeStr := strings.TrimSpace(c.Query("request_type")); requestTypeStr != "" {
- parsed, err := service.ParseUsageRequestType(requestTypeStr)
- if err != nil {
- response.BadRequest(c, err.Error())
- return
- }
- value := int16(parsed)
- requestType = &value
- } else if streamStr := c.Query("stream"); streamStr != "" {
- if streamVal, err := strconv.ParseBool(streamStr); err == nil {
- stream = &streamVal
- } else {
- response.BadRequest(c, "Invalid stream value, use true or false")
- return
- }
- }
- if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
- if v, err := strconv.ParseInt(billingTypeStr, 10, 8); err == nil {
- bt := int8(v)
- billingType = &bt
- } else {
- response.BadRequest(c, "Invalid billing_type")
- return
- }
- }
- nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
- if err != nil {
- response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
- return
- }
- upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
- if err != nil {
- response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
- return
- }
- stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
- if err != nil {
- response.Error(c, 500, "Failed to get group statistics")
- return
- }
- c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
- response.Success(c, gin.H{
- "groups": stats,
- "start_date": startTime.Format("2006-01-02"),
- "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
- })
- }
- // GetAPIKeyUsageTrend handles getting API key usage trend data
- // GET /api/v1/admin/dashboard/api-keys-trend
- // Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), limit (default 5)
- func (h *DashboardHandler) GetAPIKeyUsageTrend(c *gin.Context) {
- startTime, endTime := parseTimeRange(c)
- granularity := c.DefaultQuery("granularity", "day")
- limitStr := c.DefaultQuery("limit", "5")
- limit, err := strconv.Atoi(limitStr)
- if err != nil || limit <= 0 {
- limit = 5
- }
- trend, hit, err := h.getAPIKeyUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit)
- if err != nil {
- response.Error(c, 500, "Failed to get API key usage trend")
- return
- }
- c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
- response.Success(c, gin.H{
- "trend": trend,
- "start_date": startTime.Format("2006-01-02"),
- "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
- "granularity": granularity,
- })
- }
- // GetUserUsageTrend handles getting user usage trend data
- // GET /api/v1/admin/dashboard/users-trend
- // Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), limit (default 12)
- func (h *DashboardHandler) GetUserUsageTrend(c *gin.Context) {
- startTime, endTime := parseTimeRange(c)
- granularity := c.DefaultQuery("granularity", "day")
- limitStr := c.DefaultQuery("limit", "12")
- limit, err := strconv.Atoi(limitStr)
- if err != nil || limit <= 0 {
- limit = 12
- }
- trend, hit, err := h.getUserUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit)
- if err != nil {
- response.Error(c, 500, "Failed to get user usage trend")
- return
- }
- c.Header("X-Snapshot-Cache", cacheStatusValue(hit))
- response.Success(c, gin.H{
- "trend": trend,
- "start_date": startTime.Format("2006-01-02"),
- "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
- "granularity": granularity,
- })
- }
- // BatchUsersUsageRequest represents the request body for batch user usage stats
- type BatchUsersUsageRequest struct {
- UserIDs []int64 `json:"user_ids" binding:"required"`
- }
- var dashboardUsersRankingCache = newSnapshotCache(5 * time.Minute)
- var dashboardBatchUsersUsageCache = newSnapshotCache(30 * time.Second)
- var dashboardBatchAPIKeysUsageCache = newSnapshotCache(30 * time.Second)
- func parseRankingLimit(raw string) int {
- limit, err := strconv.Atoi(strings.TrimSpace(raw))
- if err != nil || limit <= 0 {
- return 12
- }
- if limit > 50 {
- return 50
- }
- return limit
- }
- // GetUserSpendingRanking handles getting user spending ranking data.
- // GET /api/v1/admin/dashboard/users-ranking
- func (h *DashboardHandler) GetUserSpendingRanking(c *gin.Context) {
- startTime, endTime := parseTimeRange(c)
- limit := parseRankingLimit(c.DefaultQuery("limit", "12"))
- keyRaw, _ := json.Marshal(struct {
- Start string `json:"start"`
- End string `json:"end"`
- Limit int `json:"limit"`
- }{
- Start: startTime.UTC().Format(time.RFC3339),
- End: endTime.UTC().Format(time.RFC3339),
- Limit: limit,
- })
- cacheKey := string(keyRaw)
- if cached, ok := dashboardUsersRankingCache.Get(cacheKey); ok {
- c.Header("X-Snapshot-Cache", "hit")
- response.Success(c, cached.Payload)
- return
- }
- ranking, err := h.dashboardService.GetUserSpendingRanking(c.Request.Context(), startTime, endTime, limit)
- if err != nil {
- response.Error(c, 500, "Failed to get user spending ranking")
- return
- }
- payload := gin.H{
- "ranking": ranking.Ranking,
- "total_actual_cost": ranking.TotalActualCost,
- "total_requests": ranking.TotalRequests,
- "total_tokens": ranking.TotalTokens,
- "start_date": startTime.Format("2006-01-02"),
- "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
- }
- dashboardUsersRankingCache.Set(cacheKey, payload)
- c.Header("X-Snapshot-Cache", "miss")
- response.Success(c, payload)
- }
- // GetBatchUsersUsage handles getting usage stats for multiple users
- // POST /api/v1/admin/dashboard/users-usage
- func (h *DashboardHandler) GetBatchUsersUsage(c *gin.Context) {
- var req BatchUsersUsageRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- userIDs := normalizeInt64IDList(req.UserIDs)
- if len(userIDs) == 0 {
- response.Success(c, gin.H{"stats": map[string]any{}})
- return
- }
- // cacheKey 必须包含当日日期,否则跨午夜后 30s 内会复用昨天的 "today_*" 结果。
- keyRaw, _ := json.Marshal(struct {
- V int `json:"v"`
- Day string `json:"day"`
- UserIDs []int64 `json:"user_ids"`
- }{
- V: 2, // bump 当响应结构变化(如加入 by_platform 时)
- Day: timezone.Today().Format("2006-01-02"),
- UserIDs: userIDs,
- })
- cacheKey := string(keyRaw)
- if cached, ok := dashboardBatchUsersUsageCache.Get(cacheKey); ok {
- c.Header("X-Snapshot-Cache", "hit")
- response.Success(c, cached.Payload)
- return
- }
- stats, err := h.dashboardService.GetBatchUserUsageStats(c.Request.Context(), userIDs, time.Time{}, time.Time{})
- if err != nil {
- response.Error(c, 500, "Failed to get user usage stats")
- return
- }
- payload := gin.H{"stats": stats}
- dashboardBatchUsersUsageCache.Set(cacheKey, payload)
- c.Header("X-Snapshot-Cache", "miss")
- response.Success(c, payload)
- }
- // BatchAPIKeysUsageRequest represents the request body for batch api key usage stats
- type BatchAPIKeysUsageRequest struct {
- APIKeyIDs []int64 `json:"api_key_ids" binding:"required"`
- }
- // GetBatchAPIKeysUsage handles getting usage stats for multiple API keys
- // POST /api/v1/admin/dashboard/api-keys-usage
- func (h *DashboardHandler) GetBatchAPIKeysUsage(c *gin.Context) {
- var req BatchAPIKeysUsageRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- apiKeyIDs := normalizeInt64IDList(req.APIKeyIDs)
- if len(apiKeyIDs) == 0 {
- response.Success(c, gin.H{"stats": map[string]any{}})
- return
- }
- keyRaw, _ := json.Marshal(struct {
- APIKeyIDs []int64 `json:"api_key_ids"`
- }{
- APIKeyIDs: apiKeyIDs,
- })
- cacheKey := string(keyRaw)
- if cached, ok := dashboardBatchAPIKeysUsageCache.Get(cacheKey); ok {
- c.Header("X-Snapshot-Cache", "hit")
- response.Success(c, cached.Payload)
- return
- }
- stats, err := h.dashboardService.GetBatchAPIKeyUsageStats(c.Request.Context(), apiKeyIDs, time.Time{}, time.Time{})
- if err != nil {
- response.Error(c, 500, "Failed to get API key usage stats")
- return
- }
- payload := gin.H{"stats": stats}
- dashboardBatchAPIKeysUsageCache.Set(cacheKey, payload)
- c.Header("X-Snapshot-Cache", "miss")
- response.Success(c, payload)
- }
- // GetUserBreakdown handles getting per-user usage breakdown within a dimension.
- // GET /api/v1/admin/dashboard/user-breakdown
- // Query params: start_date, end_date, group_id, model, endpoint, endpoint_type, limit
- func (h *DashboardHandler) GetUserBreakdown(c *gin.Context) {
- startTime, endTime := parseTimeRange(c)
- dim := usagestats.UserBreakdownDimension{}
- if v := c.Query("group_id"); v != "" {
- if id, err := strconv.ParseInt(v, 10, 64); err == nil {
- dim.GroupID = id
- }
- }
- dim.Model = c.Query("model")
- rawModelSource := strings.TrimSpace(c.DefaultQuery("model_source", usagestats.ModelSourceRequested))
- if !usagestats.IsValidModelSource(rawModelSource) {
- response.BadRequest(c, "Invalid model_source, use requested/upstream/mapping")
- return
- }
- dim.ModelType = rawModelSource
- dim.Endpoint = c.Query("endpoint")
- dim.EndpointType = c.DefaultQuery("endpoint_type", "inbound")
- // Additional filter conditions
- if v := c.Query("user_id"); v != "" {
- if id, err := strconv.ParseInt(v, 10, 64); err == nil {
- dim.UserID = id
- }
- }
- if v := c.Query("api_key_id"); v != "" {
- if id, err := strconv.ParseInt(v, 10, 64); err == nil {
- dim.APIKeyID = id
- }
- }
- if v := c.Query("account_id"); v != "" {
- if id, err := strconv.ParseInt(v, 10, 64); err == nil {
- dim.AccountID = id
- }
- }
- if v := strings.TrimSpace(c.Query("request_type")); v != "" {
- parsed, err := service.ParseUsageRequestType(v)
- if err != nil {
- response.BadRequest(c, err.Error())
- return
- }
- rtVal := int16(parsed)
- dim.RequestType = &rtVal
- }
- if v := c.Query("stream"); v != "" {
- if s, err := strconv.ParseBool(v); err == nil {
- dim.Stream = &s
- }
- }
- if v := strings.TrimSpace(c.Query("native_compaction_v2")); v != "" {
- value, err := strconv.ParseBool(v)
- if err != nil {
- response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
- return
- }
- dim.NativeCompactionV2 = &value
- }
- if v := c.Query("billing_type"); v != "" {
- if bt, err := strconv.ParseInt(v, 10, 8); err == nil {
- btVal := int8(bt)
- dim.BillingType = &btVal
- }
- }
- // sort_by 由 repo 层 allowlist 校验;非法值静默回退默认排序(actual_cost)。
- dim.SortBy = strings.TrimSpace(c.Query("sort_by"))
- limit := 50
- if v := c.Query("limit"); v != "" {
- if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
- limit = n
- }
- }
- stats, err := h.dashboardService.GetUserBreakdownStats(
- c.Request.Context(), startTime, endTime, dim, limit,
- )
- if err != nil {
- response.Error(c, 500, "Failed to get user breakdown stats")
- return
- }
- response.Success(c, gin.H{
- "users": stats,
- "start_date": startTime.Format("2006-01-02"),
- "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
- })
- }
|