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 @@
+
+