diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index 460f6357..e9fbb630 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -546,9 +546,14 @@ func (h *DashboardHandler) GetBatchUsersUsage(c *gin.Context) { 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) diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index fe5f98d6..39283d22 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -230,6 +230,20 @@ type UserDashboardStats struct { // 性能指标 Rpm int64 `json:"rpm"` // 近5分钟平均每分钟请求数 Tpm int64 `json:"tpm"` // 近5分钟平均每分钟Token数 + + // 按"有效平台"维度拆分(与 ops 路径口径一致:group.platform 优先,否则 account.platform) + ByPlatform []PlatformDashboardStats `json:"by_platform,omitempty"` +} + +// PlatformDashboardStats 单个平台的用量明细。 +type PlatformDashboardStats struct { + Platform string `json:"platform"` + TotalRequests int64 `json:"total_requests"` + TotalTokens int64 `json:"total_tokens"` + TotalActualCost float64 `json:"total_actual_cost"` + TodayRequests int64 `json:"today_requests"` + TodayTokens int64 `json:"today_tokens"` + TodayActualCost float64 `json:"today_actual_cost"` } // UsageLogFilters represents filters for usage log queries @@ -265,13 +279,22 @@ type UsageStats struct { EndpointPaths []EndpointStat `json:"endpoint_paths,omitempty"` } -// BatchUserUsageStats represents usage stats for a single user -type BatchUserUsageStats struct { - UserID int64 `json:"user_id"` +// PlatformUsage 表示某用户/某 API key 在单个"有效平台"维度的用量明细。 +// Platform 取值与 ops 路径口径一致:优先 groups.platform,否则 accounts.platform。 +type PlatformUsage struct { + Platform string `json:"platform"` TodayActualCost float64 `json:"today_actual_cost"` TotalActualCost float64 `json:"total_actual_cost"` } +// BatchUserUsageStats represents usage stats for a single user +type BatchUserUsageStats struct { + UserID int64 `json:"user_id"` + TodayActualCost float64 `json:"today_actual_cost"` + TotalActualCost float64 `json:"total_actual_cost"` + ByPlatform []PlatformUsage `json:"by_platform,omitempty"` +} + // BatchAPIKeyUsageStats represents usage stats for a single API key type BatchAPIKeyUsageStats struct { APIKeyID int64 `json:"api_key_id"` diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index c4f35d4d..f11910a0 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -96,6 +96,22 @@ const rawUsageLogModelColumn = "model" // Historical rows may contain upstream/billing model values, while newer rows store requested_model. // Requested/upstream/mapping analytics must use resolveModelDimensionExpression instead. +// usageLogSuccessFilterUL 用于把"失败请求 usage log"(tokens=0、cost=0、不计费的占位记录) +// 从统计性聚合中排除,避免污染 Dashboard / 用量拆分等指标。 +// +// schema 中没有 success bool 列;新增列要做迁移,风险大;这里用 actual_cost > 0 作为代理: +// 任何成功落账的请求都会产生 actual_cost(包括 token 计费、纯图片 token 计费、按次/按图计费), +// 反之 failed-request usage log 的 actual_cost 为 0。 +// 早期版本用 4 项 token 和 > 0 判定会把"按次/按图计费"与"image_output_tokens 独立计费"的纯图片 +// 请求误判为失败,导致这部分请求从用量统计里消失,故改用 actual_cost。 +// 配合 `FROM usage_logs ul` JOIN 查询使用。 +const usageLogSuccessFilterUL = "ul.actual_cost > 0" + +// usageLogEffectivePlatformExpr 用于按"有效平台"维度聚合 usage_logs: +// 优先取请求实际走的分组 platform,若分组未设置 platform 再 fallback 到 account.platform。 +// 配套要求查询里 LEFT JOIN groups g ON g.id = ul.group_id 与 LEFT JOIN accounts a ON a.id = ul.account_id。 +const usageLogEffectivePlatformExpr = "COALESCE(NULLIF(g.platform,''), a.platform)" + // dateFormatWhitelist 将 granularity 参数映射为 PostgreSQL TO_CHAR 格式字符串,防止外部输入直接拼入 SQL var dateFormatWhitelist = map[string]string{ "hour": "YYYY-MM-DD HH24:00", @@ -2414,6 +2430,9 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi // UserDashboardStats 用户仪表盘统计 type UserDashboardStats = usagestats.UserDashboardStats +// PlatformDashboardStats 单平台用量明细 +type PlatformDashboardStats = usagestats.PlatformDashboardStats + // GetUserDashboardStats 获取用户专属的仪表盘统计 func (r *usageLogRepository) GetUserDashboardStats(ctx context.Context, userID int64) (*UserDashboardStats, error) { stats := &UserDashboardStats{} @@ -2509,6 +2528,57 @@ func (r *usageLogRepository) GetUserDashboardStats(ctx context.Context, userID i 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 } @@ -2769,6 +2839,9 @@ type UsageStats = usagestats.UsageStats // BatchUserUsageStats represents usage stats for a single user type BatchUserUsageStats = usagestats.BatchUserUsageStats +// PlatformUsage represents per-platform usage breakdown +type PlatformUsage = usagestats.PlatformUsage + func normalizePositiveInt64IDs(ids []int64) []int64 { if len(ids) == 0 { return nil @@ -2809,15 +2882,21 @@ func (r *usageLogRepository) GetBatchUserUsageStats(ctx context.Context, userIDs result[id] = &BatchUserUsageStats{UserID: id} } + // GROUP BY (user_id, effective_platform) 一次查询同时得到总值与按平台拆分。 + // 应用层把同一 user_id 的多行累加为总值,并把非空 platform 行收集到 ByPlatform。 query := ` SELECT - user_id, - COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $2 AND created_at < $3), 0) as total_cost, - COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $4), 0) as today_cost - FROM usage_logs - WHERE user_id = ANY($1) - AND created_at >= LEAST($2, $4) - GROUP BY user_id + ul.user_id, + ` + usageLogEffectivePlatformExpr + ` as platform, + COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $2 AND ul.created_at < $3), 0) as total_cost, + COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $4), 0) as today_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 = ANY($1) + AND ul.created_at >= LEAST($2, $4) + AND ` + usageLogSuccessFilterUL + ` + GROUP BY ul.user_id, ` + usageLogEffectivePlatformExpr + ` ` today := timezone.Today() rows, err := r.sql.QueryContext(ctx, query, pq.Array(normalizedUserIDs), startTime, endTime, today) @@ -2826,15 +2905,25 @@ func (r *usageLogRepository) GetBatchUserUsageStats(ctx context.Context, userIDs } for rows.Next() { var userID int64 + var platform sql.NullString var total float64 var todayTotal float64 - if err := rows.Scan(&userID, &total, &todayTotal); err != nil { + if err := rows.Scan(&userID, &platform, &total, &todayTotal); err != nil { _ = rows.Close() return nil, err } - if stats, ok := result[userID]; ok { - stats.TotalActualCost = total - stats.TodayActualCost = todayTotal + stats, ok := result[userID] + if !ok { + continue + } + stats.TotalActualCost += total + stats.TodayActualCost += todayTotal + if platform.Valid && platform.String != "" { + stats.ByPlatform = append(stats.ByPlatform, PlatformUsage{ + Platform: platform.String, + TotalActualCost: total, + TodayActualCost: todayTotal, + }) } } if err := rows.Close(); err != nil { diff --git a/frontend/src/api/admin/dashboard.ts b/frontend/src/api/admin/dashboard.ts index 49e487b7..dda7d892 100644 --- a/frontend/src/api/admin/dashboard.ts +++ b/frontend/src/api/admin/dashboard.ts @@ -266,10 +266,17 @@ export async function getUserSpendingRanking( return data } +export interface PlatformUsage { + platform: string + today_actual_cost: number + total_actual_cost: number +} + export interface BatchUserUsageStats { user_id: number today_actual_cost: number total_actual_cost: number + by_platform?: PlatformUsage[] } export interface BatchUsersUsageResponse { diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 802c428f..7169b698 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -15,6 +15,16 @@ import type { // ==================== Dashboard Types ==================== +export interface PlatformDashboardStats { + platform: string + total_requests: number + total_tokens: number + total_actual_cost: number + today_requests: number + today_tokens: number + today_actual_cost: number +} + export interface UserDashboardStats { total_api_keys: number active_api_keys: number @@ -37,6 +47,7 @@ export interface UserDashboardStats { average_duration_ms: number rpm: number // 近5分钟平均每分钟请求数 tpm: number // 近5分钟平均每分钟Token数 + by_platform?: PlatformDashboardStats[] } export interface TrendParams { diff --git a/frontend/src/components/user/PlatformCostCell.vue b/frontend/src/components/user/PlatformCostCell.vue new file mode 100644 index 00000000..dd5de111 --- /dev/null +++ b/frontend/src/components/user/PlatformCostCell.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/components/user/PlatformUsageBreakdown.vue b/frontend/src/components/user/PlatformUsageBreakdown.vue new file mode 100644 index 00000000..e995bc01 --- /dev/null +++ b/frontend/src/components/user/PlatformUsageBreakdown.vue @@ -0,0 +1,103 @@ + + + diff --git a/frontend/src/components/user/dashboard/UserDashboardStats.vue b/frontend/src/components/user/dashboard/UserDashboardStats.vue index dfba3a51..97d2da3d 100644 --- a/frontend/src/components/user/dashboard/UserDashboardStats.vue +++ b/frontend/src/components/user/dashboard/UserDashboardStats.vue @@ -131,20 +131,118 @@ + + +
+
+

{{ t('dashboard.platformBreakdown') }}

+ + {{ t('dashboard.platformCount', { count: sortedPlatforms.length }) }} + +
+
+
+
+ + {{ item.isOther ? t('dashboard.platformOther') : platformLabel(item.platform) }} + + + ${{ formatCost(item.total_actual_cost) }} + +
+
+
+ {{ t('dashboard.todayCost') }} + ${{ formatCost(item.today_actual_cost) }} +
+
+ {{ t('dashboard.requests') }} + + {{ item.total_requests > 0 ? formatNumber(item.total_requests) : '-' }} + +
+
+ {{ t('dashboard.tokens') }} + + {{ item.total_tokens > 0 ? formatTokens(item.total_tokens) : '-' }} + +
+
+
+
+