diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 7a4fb371..7a8d7123 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -587,6 +587,48 @@ const docTemplate = `{ } } }, + "/admin/billing/official-pricing/openrouter": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "从 storage 缓存读取 OpenRouter 模型定价;缓存不存在、过期或 refresh=true 时由后端刷新。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-billing" + ], + "summary": "管理员获取 OpenRouter 官方模型定价", + "parameters": [ + { + "type": "boolean", + "description": "强制刷新缓存", + "name": "refresh", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_billing.OpenRouterOfficialPricingResponseDoc" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/internal_transport_http_billing.ErrorDoc" + } + } + } + } + }, "/admin/billing/plans/{id}": { "patch": { "security": [ @@ -4921,6 +4963,108 @@ const docTemplate = `{ } } }, + "/admin/usage-statistics": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "管理员按日期、统计对象、平台模型和计费范围查看全局费用、Token、调用次数及排名;用户与权限组筛选互斥", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "管理员查询全局用量统计", + "parameters": [ + { + "type": "string", + "description": "开始日期(YYYY-MM-DD),默认近30天", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "结束日期(YYYY-MM-DD,包含当日)", + "name": "end_date", + "in": "query" + }, + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "query" + }, + { + "type": "integer", + "description": "权限组ID,与 user_id 互斥", + "name": "permission_group_id", + "in": "query" + }, + { + "type": "string", + "description": "平台模型名", + "name": "platform_model_name", + "in": "query" + }, + { + "type": "string", + "description": "计费范围:all/free/billable", + "name": "billing_scope", + "in": "query" + }, + { + "type": "string", + "description": "返回范围:all/models/users", + "name": "section", + "in": "query" + }, + { + "type": "string", + "description": "模型排名指标:cost/tokens/calls", + "name": "model_rank_by", + "in": "query" + }, + { + "type": "string", + "description": "用户排名指标:cost/tokens/calls", + "name": "user_rank_by", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_transport_http_admin.ErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_transport_http_admin.ErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_admin.ErrorDoc" + } + } + } + } + }, "/admin/user-auth-events": { "get": { "security": [ @@ -7242,6 +7386,37 @@ const docTemplate = `{ } } }, + "/conversations/export": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "流式导出当前用户全部会话及消息为 NDJSON 文件", + "produces": [ + "application/x-ndjson" + ], + "tags": [ + "chat" + ], + "summary": "导出当前用户全部对话", + "responses": { + "200": { + "description": "NDJSON stream", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + } + }, "/conversations/project": { "post": { "security": [ @@ -11458,6 +11633,242 @@ const docTemplate = `{ } } }, + "internal_transport_http_admin.UsageStatisticsMetricsResponse": { + "type": "object", + "properties": { + "avgLatencyMS": { + "type": "integer" + }, + "billedNanousd": { + "type": "integer" + }, + "billedUSD": { + "type": "number" + }, + "cacheReadTokens": { + "type": "integer" + }, + "cacheWriteTokens": { + "type": "integer" + }, + "callCount": { + "type": "integer" + }, + "inputTokens": { + "type": "integer" + }, + "outputTokens": { + "type": "integer" + }, + "reasoningTokens": { + "type": "integer" + }, + "recordCount": { + "type": "integer" + }, + "totalTokens": { + "type": "integer" + } + } + }, + "internal_transport_http_admin.UsageStatisticsModelRankResponse": { + "type": "object", + "properties": { + "avgLatencyMS": { + "type": "integer" + }, + "billedNanousd": { + "type": "integer" + }, + "billedUSD": { + "type": "number" + }, + "cacheReadTokens": { + "type": "integer" + }, + "cacheWriteTokens": { + "type": "integer" + }, + "callCount": { + "type": "integer" + }, + "inputTokens": { + "type": "integer" + }, + "outputTokens": { + "type": "integer" + }, + "platformModelName": { + "type": "string" + }, + "reasoningTokens": { + "type": "integer" + }, + "recordCount": { + "type": "integer" + }, + "totalTokens": { + "type": "integer" + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsTrendResponse" + } + } + } + }, + "internal_transport_http_admin.UsageStatisticsResponse": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "endDate": { + "type": "string" + }, + "granularity": { + "type": "string" + }, + "startDate": { + "type": "string" + } + } + }, + "section": { + "type": "string" + }, + "topModels": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsModelRankResponse" + } + }, + "topUsers": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsUserRankResponse" + } + }, + "totals": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsMetricsResponse" + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsTrendResponse" + } + } + } + }, + "internal_transport_http_admin.UsageStatisticsResponseDoc": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, + "internal_transport_http_admin.UsageStatisticsTrendResponse": { + "type": "object", + "properties": { + "avgLatencyMS": { + "type": "integer" + }, + "billedNanousd": { + "type": "integer" + }, + "billedUSD": { + "type": "number" + }, + "cacheReadTokens": { + "type": "integer" + }, + "cacheWriteTokens": { + "type": "integer" + }, + "callCount": { + "type": "integer" + }, + "inputTokens": { + "type": "integer" + }, + "outputTokens": { + "type": "integer" + }, + "periodStart": { + "type": "string" + }, + "reasoningTokens": { + "type": "integer" + }, + "recordCount": { + "type": "integer" + }, + "totalTokens": { + "type": "integer" + } + } + }, + "internal_transport_http_admin.UsageStatisticsUserRankResponse": { + "type": "object", + "properties": { + "avgLatencyMS": { + "type": "integer" + }, + "billedNanousd": { + "type": "integer" + }, + "billedUSD": { + "type": "number" + }, + "cacheReadTokens": { + "type": "integer" + }, + "cacheWriteTokens": { + "type": "integer" + }, + "callCount": { + "type": "integer" + }, + "inputTokens": { + "type": "integer" + }, + "outputTokens": { + "type": "integer" + }, + "reasoningTokens": { + "type": "integer" + }, + "recordCount": { + "type": "integer" + }, + "totalTokens": { + "type": "integer" + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsTrendResponse" + } + }, + "userDisplayName": { + "type": "string" + }, + "userID": { + "type": "integer" + }, + "userLabel": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "internal_transport_http_admin.UserAuthEventListResponseDoc": { "type": "object", "properties": { @@ -13407,6 +13818,71 @@ const docTemplate = `{ } } }, + "internal_transport_http_billing.OpenRouterOfficialPricingDataResponse": { + "type": "object", + "properties": { + "cached": { + "type": "boolean" + }, + "fetchedAt": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_billing.OpenRouterOfficialPricingItemResponse" + } + }, + "stale": { + "type": "boolean" + } + } + }, + "internal_transport_http_billing.OpenRouterOfficialPricingItemResponse": { + "type": "object", + "properties": { + "canonicalSlug": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "pricing": { + "$ref": "#/definitions/internal_transport_http_billing.OpenRouterOfficialPricingUnitPricingResponse" + } + } + }, + "internal_transport_http_billing.OpenRouterOfficialPricingResponseDoc": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/internal_transport_http_billing.OpenRouterOfficialPricingDataResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, + "internal_transport_http_billing.OpenRouterOfficialPricingUnitPricingResponse": { + "type": "object", + "properties": { + "completion": { + "type": "string" + }, + "inputCacheRead": { + "type": "string" + }, + "inputCacheWrite": { + "type": "string" + }, + "prompt": { + "type": "string" + } + } + }, "internal_transport_http_billing.PatchRedemptionCodeRequestDoc": { "type": "object", "properties": { @@ -14758,7 +15234,8 @@ const docTemplate = `{ "enum": [ "chat", "image_generation", - "image_edit" + "image_edit", + "video_generation" ] } } diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index f430483f..1d402126 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -580,6 +580,48 @@ } } }, + "/admin/billing/official-pricing/openrouter": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "从 storage 缓存读取 OpenRouter 模型定价;缓存不存在、过期或 refresh=true 时由后端刷新。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-billing" + ], + "summary": "管理员获取 OpenRouter 官方模型定价", + "parameters": [ + { + "type": "boolean", + "description": "强制刷新缓存", + "name": "refresh", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_billing.OpenRouterOfficialPricingResponseDoc" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/internal_transport_http_billing.ErrorDoc" + } + } + } + } + }, "/admin/billing/plans/{id}": { "patch": { "security": [ @@ -4914,6 +4956,108 @@ } } }, + "/admin/usage-statistics": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "管理员按日期、统计对象、平台模型和计费范围查看全局费用、Token、调用次数及排名;用户与权限组筛选互斥", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "管理员查询全局用量统计", + "parameters": [ + { + "type": "string", + "description": "开始日期(YYYY-MM-DD),默认近30天", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "结束日期(YYYY-MM-DD,包含当日)", + "name": "end_date", + "in": "query" + }, + { + "type": "integer", + "description": "用户ID", + "name": "user_id", + "in": "query" + }, + { + "type": "integer", + "description": "权限组ID,与 user_id 互斥", + "name": "permission_group_id", + "in": "query" + }, + { + "type": "string", + "description": "平台模型名", + "name": "platform_model_name", + "in": "query" + }, + { + "type": "string", + "description": "计费范围:all/free/billable", + "name": "billing_scope", + "in": "query" + }, + { + "type": "string", + "description": "返回范围:all/models/users", + "name": "section", + "in": "query" + }, + { + "type": "string", + "description": "模型排名指标:cost/tokens/calls", + "name": "model_rank_by", + "in": "query" + }, + { + "type": "string", + "description": "用户排名指标:cost/tokens/calls", + "name": "user_rank_by", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_transport_http_admin.ErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_transport_http_admin.ErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_admin.ErrorDoc" + } + } + } + } + }, "/admin/user-auth-events": { "get": { "security": [ @@ -7235,6 +7379,37 @@ } } }, + "/conversations/export": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "流式导出当前用户全部会话及消息为 NDJSON 文件", + "produces": [ + "application/x-ndjson" + ], + "tags": [ + "chat" + ], + "summary": "导出当前用户全部对话", + "responses": { + "200": { + "description": "NDJSON stream", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_transport_http_conversation.ErrorDoc" + } + } + } + } + }, "/conversations/project": { "post": { "security": [ @@ -11451,6 +11626,242 @@ } } }, + "internal_transport_http_admin.UsageStatisticsMetricsResponse": { + "type": "object", + "properties": { + "avgLatencyMS": { + "type": "integer" + }, + "billedNanousd": { + "type": "integer" + }, + "billedUSD": { + "type": "number" + }, + "cacheReadTokens": { + "type": "integer" + }, + "cacheWriteTokens": { + "type": "integer" + }, + "callCount": { + "type": "integer" + }, + "inputTokens": { + "type": "integer" + }, + "outputTokens": { + "type": "integer" + }, + "reasoningTokens": { + "type": "integer" + }, + "recordCount": { + "type": "integer" + }, + "totalTokens": { + "type": "integer" + } + } + }, + "internal_transport_http_admin.UsageStatisticsModelRankResponse": { + "type": "object", + "properties": { + "avgLatencyMS": { + "type": "integer" + }, + "billedNanousd": { + "type": "integer" + }, + "billedUSD": { + "type": "number" + }, + "cacheReadTokens": { + "type": "integer" + }, + "cacheWriteTokens": { + "type": "integer" + }, + "callCount": { + "type": "integer" + }, + "inputTokens": { + "type": "integer" + }, + "outputTokens": { + "type": "integer" + }, + "platformModelName": { + "type": "string" + }, + "reasoningTokens": { + "type": "integer" + }, + "recordCount": { + "type": "integer" + }, + "totalTokens": { + "type": "integer" + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsTrendResponse" + } + } + } + }, + "internal_transport_http_admin.UsageStatisticsResponse": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "endDate": { + "type": "string" + }, + "granularity": { + "type": "string" + }, + "startDate": { + "type": "string" + } + } + }, + "section": { + "type": "string" + }, + "topModels": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsModelRankResponse" + } + }, + "topUsers": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsUserRankResponse" + } + }, + "totals": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsMetricsResponse" + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsTrendResponse" + } + } + } + }, + "internal_transport_http_admin.UsageStatisticsResponseDoc": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, + "internal_transport_http_admin.UsageStatisticsTrendResponse": { + "type": "object", + "properties": { + "avgLatencyMS": { + "type": "integer" + }, + "billedNanousd": { + "type": "integer" + }, + "billedUSD": { + "type": "number" + }, + "cacheReadTokens": { + "type": "integer" + }, + "cacheWriteTokens": { + "type": "integer" + }, + "callCount": { + "type": "integer" + }, + "inputTokens": { + "type": "integer" + }, + "outputTokens": { + "type": "integer" + }, + "periodStart": { + "type": "string" + }, + "reasoningTokens": { + "type": "integer" + }, + "recordCount": { + "type": "integer" + }, + "totalTokens": { + "type": "integer" + } + } + }, + "internal_transport_http_admin.UsageStatisticsUserRankResponse": { + "type": "object", + "properties": { + "avgLatencyMS": { + "type": "integer" + }, + "billedNanousd": { + "type": "integer" + }, + "billedUSD": { + "type": "number" + }, + "cacheReadTokens": { + "type": "integer" + }, + "cacheWriteTokens": { + "type": "integer" + }, + "callCount": { + "type": "integer" + }, + "inputTokens": { + "type": "integer" + }, + "outputTokens": { + "type": "integer" + }, + "reasoningTokens": { + "type": "integer" + }, + "recordCount": { + "type": "integer" + }, + "totalTokens": { + "type": "integer" + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_admin.UsageStatisticsTrendResponse" + } + }, + "userDisplayName": { + "type": "string" + }, + "userID": { + "type": "integer" + }, + "userLabel": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "internal_transport_http_admin.UserAuthEventListResponseDoc": { "type": "object", "properties": { @@ -13400,6 +13811,71 @@ } } }, + "internal_transport_http_billing.OpenRouterOfficialPricingDataResponse": { + "type": "object", + "properties": { + "cached": { + "type": "boolean" + }, + "fetchedAt": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_transport_http_billing.OpenRouterOfficialPricingItemResponse" + } + }, + "stale": { + "type": "boolean" + } + } + }, + "internal_transport_http_billing.OpenRouterOfficialPricingItemResponse": { + "type": "object", + "properties": { + "canonicalSlug": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "pricing": { + "$ref": "#/definitions/internal_transport_http_billing.OpenRouterOfficialPricingUnitPricingResponse" + } + } + }, + "internal_transport_http_billing.OpenRouterOfficialPricingResponseDoc": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/internal_transport_http_billing.OpenRouterOfficialPricingDataResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, + "internal_transport_http_billing.OpenRouterOfficialPricingUnitPricingResponse": { + "type": "object", + "properties": { + "completion": { + "type": "string" + }, + "inputCacheRead": { + "type": "string" + }, + "inputCacheWrite": { + "type": "string" + }, + "prompt": { + "type": "string" + } + } + }, "internal_transport_http_billing.PatchRedemptionCodeRequestDoc": { "type": "object", "properties": { @@ -14751,7 +15227,8 @@ "enum": [ "chat", "image_generation", - "image_edit" + "image_edit", + "video_generation" ] } } @@ -18124,4 +18601,4 @@ "in": "header" } } -} +} \ No newline at end of file diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 67ce60f8..2f8b9e24 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -821,6 +821,161 @@ definitions: username: type: string type: object + internal_transport_http_admin.UsageStatisticsMetricsResponse: + properties: + avgLatencyMS: + type: integer + billedNanousd: + type: integer + billedUSD: + type: number + cacheReadTokens: + type: integer + cacheWriteTokens: + type: integer + callCount: + type: integer + inputTokens: + type: integer + outputTokens: + type: integer + reasoningTokens: + type: integer + recordCount: + type: integer + totalTokens: + type: integer + type: object + internal_transport_http_admin.UsageStatisticsModelRankResponse: + properties: + avgLatencyMS: + type: integer + billedNanousd: + type: integer + billedUSD: + type: number + cacheReadTokens: + type: integer + cacheWriteTokens: + type: integer + callCount: + type: integer + inputTokens: + type: integer + outputTokens: + type: integer + platformModelName: + type: string + reasoningTokens: + type: integer + recordCount: + type: integer + totalTokens: + type: integer + trend: + items: + $ref: '#/definitions/internal_transport_http_admin.UsageStatisticsTrendResponse' + type: array + type: object + internal_transport_http_admin.UsageStatisticsResponse: + properties: + range: + properties: + endDate: + type: string + granularity: + type: string + startDate: + type: string + type: object + section: + type: string + topModels: + items: + $ref: '#/definitions/internal_transport_http_admin.UsageStatisticsModelRankResponse' + type: array + topUsers: + items: + $ref: '#/definitions/internal_transport_http_admin.UsageStatisticsUserRankResponse' + type: array + totals: + $ref: '#/definitions/internal_transport_http_admin.UsageStatisticsMetricsResponse' + trend: + items: + $ref: '#/definitions/internal_transport_http_admin.UsageStatisticsTrendResponse' + type: array + type: object + internal_transport_http_admin.UsageStatisticsResponseDoc: + properties: + data: + $ref: '#/definitions/internal_transport_http_admin.UsageStatisticsResponse' + errorMsg: + type: string + type: object + internal_transport_http_admin.UsageStatisticsTrendResponse: + properties: + avgLatencyMS: + type: integer + billedNanousd: + type: integer + billedUSD: + type: number + cacheReadTokens: + type: integer + cacheWriteTokens: + type: integer + callCount: + type: integer + inputTokens: + type: integer + outputTokens: + type: integer + periodStart: + type: string + reasoningTokens: + type: integer + recordCount: + type: integer + totalTokens: + type: integer + type: object + internal_transport_http_admin.UsageStatisticsUserRankResponse: + properties: + avgLatencyMS: + type: integer + billedNanousd: + type: integer + billedUSD: + type: number + cacheReadTokens: + type: integer + cacheWriteTokens: + type: integer + callCount: + type: integer + inputTokens: + type: integer + outputTokens: + type: integer + reasoningTokens: + type: integer + recordCount: + type: integer + totalTokens: + type: integer + trend: + items: + $ref: '#/definitions/internal_transport_http_admin.UsageStatisticsTrendResponse' + type: array + userDisplayName: + type: string + userID: + type: integer + userLabel: + type: string + username: + type: string + type: object internal_transport_http_admin.UserAuthEventListResponseDoc: properties: data: @@ -2120,6 +2275,48 @@ definitions: unit: type: string type: object + internal_transport_http_billing.OpenRouterOfficialPricingDataResponse: + properties: + cached: + type: boolean + fetchedAt: + type: string + items: + items: + $ref: '#/definitions/internal_transport_http_billing.OpenRouterOfficialPricingItemResponse' + type: array + stale: + type: boolean + type: object + internal_transport_http_billing.OpenRouterOfficialPricingItemResponse: + properties: + canonicalSlug: + type: string + id: + type: string + name: + type: string + pricing: + $ref: '#/definitions/internal_transport_http_billing.OpenRouterOfficialPricingUnitPricingResponse' + type: object + internal_transport_http_billing.OpenRouterOfficialPricingResponseDoc: + properties: + data: + $ref: '#/definitions/internal_transport_http_billing.OpenRouterOfficialPricingDataResponse' + errorMsg: + type: string + type: object + internal_transport_http_billing.OpenRouterOfficialPricingUnitPricingResponse: + properties: + completion: + type: string + inputCacheRead: + type: string + inputCacheWrite: + type: string + prompt: + type: string + type: object internal_transport_http_billing.PatchRedemptionCodeRequestDoc: properties: description: @@ -3031,6 +3228,7 @@ definitions: - chat - image_generation - image_edit + - video_generation type: string type: object internal_transport_http_channel.ModelProbeResponse: @@ -5270,7 +5468,7 @@ info: contact: {} description: DEEIX Chat 后端 API 文档 title: DEEIX Chat API - version: "0.3.2" + version: 0.3.2 paths: /admin/announcements: get: @@ -5636,6 +5834,32 @@ paths: summary: 管理员保存模型按量单价 tags: - admin-billing + /admin/billing/official-pricing/openrouter: + get: + consumes: + - application/json + description: 从 storage 缓存读取 OpenRouter 模型定价;缓存不存在、过期或 refresh=true 时由后端刷新。 + parameters: + - description: 强制刷新缓存 + in: query + name: refresh + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_transport_http_billing.OpenRouterOfficialPricingResponseDoc' + "502": + description: Bad Gateway + schema: + $ref: '#/definitions/internal_transport_http_billing.ErrorDoc' + security: + - BearerAuth: [] + summary: 管理员获取 OpenRouter 官方模型定价 + tags: + - admin-billing /admin/billing/plans/{id}: patch: consumes: @@ -8385,6 +8609,72 @@ paths: summary: 管理员查询系统事件 tags: - admin + /admin/usage-statistics: + get: + consumes: + - application/json + description: 管理员按日期、统计对象、平台模型和计费范围查看全局费用、Token、调用次数及排名;用户与权限组筛选互斥 + parameters: + - description: 开始日期(YYYY-MM-DD),默认近30天 + in: query + name: start_date + type: string + - description: 结束日期(YYYY-MM-DD,包含当日) + in: query + name: end_date + type: string + - description: 用户ID + in: query + name: user_id + type: integer + - description: 权限组ID,与 user_id 互斥 + in: query + name: permission_group_id + type: integer + - description: 平台模型名 + in: query + name: platform_model_name + type: string + - description: 计费范围:all/free/billable + in: query + name: billing_scope + type: string + - description: 返回范围:all/models/users + in: query + name: section + type: string + - description: 模型排名指标:cost/tokens/calls + in: query + name: model_rank_by + type: string + - description: 用户排名指标:cost/tokens/calls + in: query + name: user_rank_by + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_transport_http_admin.UsageStatisticsResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_transport_http_admin.ErrorDoc' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_transport_http_admin.ErrorDoc' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_transport_http_admin.ErrorDoc' + security: + - BearerAuth: [] + summary: 管理员查询全局用量统计 + tags: + - admin /admin/user-auth-events: get: consumes: @@ -10483,6 +10773,25 @@ paths: summary: 查询新会话默认模型候选 tags: - chat + /conversations/export: + get: + description: 流式导出当前用户全部会话及消息为 NDJSON 文件 + produces: + - application/x-ndjson + responses: + "200": + description: NDJSON stream + schema: + type: string + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_transport_http_conversation.ErrorDoc' + security: + - BearerAuth: [] + summary: 导出当前用户全部对话 + tags: + - chat /conversations/project: post: consumes: diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index f47228d1..64f7b040 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -283,6 +283,7 @@ func NewApp() (*App, error) { adminService.SetAuthSecurityService(authService) adminService.SetSystemEventService(systemEventService) adminService.SetUsageLogService(billingService) + adminService.SetUsageStatisticsService(billingService) adminService.SetOrderLogService(billingService) adminService.SetConversationEventService(conversationService) adminService.SetLogCleanupService(logCleanupService) diff --git a/backend/internal/application/admin/service.go b/backend/internal/application/admin/service.go index 592d36c6..8f13f1ed 100644 --- a/backend/internal/application/admin/service.go +++ b/backend/internal/application/admin/service.go @@ -96,6 +96,10 @@ type usageLogService interface { ListUsageLogs(ctx context.Context, page int, pageSize int, filter billing.UsageLogListFilter) ([]domainbilling.UsageLedger, int64, error) } +type usageStatisticsService interface { + GetUsageStatistics(ctx context.Context, filter billing.UsageStatisticsFilter) (domainbilling.UsageStatistics, error) +} + type orderLogService interface { ListPaymentOrders(ctx context.Context, page int, pageSize int, filter billing.PaymentOrderListFilter) ([]domainbilling.PaymentOrder, int64, error) } @@ -119,6 +123,7 @@ type Service struct { auditService auditService systemEventService systemEventService usageLogService usageLogService + usageStatisticsService usageStatisticsService orderLogService orderLogService conversationEventSvc conversationEventService logCleanupService logCleanupService @@ -187,6 +192,11 @@ func (s *Service) SetUsageLogService(service usageLogService) { s.usageLogService = service } +// SetUsageStatisticsService 注入管理员用量统计能力。 +func (s *Service) SetUsageStatisticsService(service usageStatisticsService) { + s.usageStatisticsService = service +} + // SetOrderLogService 注入支付订单日志查询能力。 func (s *Service) SetOrderLogService(service orderLogService) { s.orderLogService = service diff --git a/backend/internal/application/admin/service_logs.go b/backend/internal/application/admin/service_logs.go index 446829c4..82f26f43 100644 --- a/backend/internal/application/admin/service_logs.go +++ b/backend/internal/application/admin/service_logs.go @@ -28,6 +28,25 @@ func (s *Service) ListUsageLogs(ctx context.Context, page int, pageSize int, fil return s.usageLogService.ListUsageLogs(ctx, page, pageSize, filter) } +// GetUsageStatistics 查询管理员仪表盘的全局用量聚合。 +func (s *Service) GetUsageStatistics(ctx context.Context, filter billing.UsageStatisticsFilter) (domainbilling.UsageStatistics, error) { + if filter.UserID > 0 && filter.PermissionGroupID > 0 { + return domainbilling.UsageStatistics{}, billing.ErrInvalidUsageStatisticsSubject + } + if filter.PermissionGroupID > 0 { + if s.permissionGroupRepo == nil { + return domainbilling.UsageStatistics{}, ErrPermissionGroupRepoUnavailable + } + if _, err := s.permissionGroupRepo.GetPermissionGroup(ctx, filter.PermissionGroupID); err != nil { + return domainbilling.UsageStatistics{}, mapPermissionGroupRepoError(err) + } + } + if s.usageStatisticsService == nil { + return domainbilling.UsageStatistics{}, errors.New("usage statistics service unavailable") + } + return s.usageStatisticsService.GetUsageStatistics(ctx, filter) +} + // ListPaymentOrders 查询管理员支付订单记录。 func (s *Service) ListPaymentOrders(ctx context.Context, page int, pageSize int, filter billing.PaymentOrderListFilter) ([]domainbilling.PaymentOrder, int64, error) { if s.orderLogService == nil { diff --git a/backend/internal/application/admin/service_test.go b/backend/internal/application/admin/service_test.go index f83aad7a..5dd00037 100644 --- a/backend/internal/application/admin/service_test.go +++ b/backend/internal/application/admin/service_test.go @@ -10,6 +10,7 @@ import ( "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/billing" userapp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/user" domainaudit "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/audit" + domainbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/billing" domainchannel "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/channel" domainuser "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/user" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" @@ -79,6 +80,48 @@ func TestBuildUserViewsIncludesBillingBalanceOutsideSelfMode(t *testing.T) { } } +func TestGetUsageStatisticsRejectsConflictingSubjectFilters(t *testing.T) { + statistics := &usageStatisticsServiceFake{} + service := NewService(newAdminUserServiceFake(nil), auditServiceFake{}) + service.SetUsageStatisticsService(statistics) + + _, err := service.GetUsageStatistics(context.Background(), billing.UsageStatisticsFilter{ + UserID: 1, + PermissionGroupID: 2, + }) + if !errors.Is(err, billing.ErrInvalidUsageStatisticsSubject) { + t.Fatalf("expected conflicting subject filter error, got %v", err) + } + if statistics.calls != 0 { + t.Fatalf("expected statistics service not to be called, got %d calls", statistics.calls) + } +} + +func TestGetUsageStatisticsValidatesPermissionGroup(t *testing.T) { + statistics := &usageStatisticsServiceFake{} + service := NewService(newAdminUserServiceFake(nil), auditServiceFake{}) + service.SetUsageStatisticsService(statistics) + service.SetPermissionGroupRepo(permissionGroupRepoFake{ + groups: map[uint]domainchannel.PermissionGroup{7: {ID: 7, Name: "Pro"}}, + }) + + _, err := service.GetUsageStatistics(context.Background(), billing.UsageStatisticsFilter{PermissionGroupID: 7}) + if err != nil { + t.Fatalf("expected permission group filter to succeed, got %v", err) + } + if statistics.calls != 1 || statistics.filter.PermissionGroupID != 7 { + t.Fatalf("expected permission group filter to be forwarded, calls=%d filter=%+v", statistics.calls, statistics.filter) + } + + _, err = service.GetUsageStatistics(context.Background(), billing.UsageStatisticsFilter{PermissionGroupID: 8}) + if !errors.Is(err, ErrPermissionGroupNotFound) { + t.Fatalf("expected missing permission group error, got %v", err) + } + if statistics.calls != 1 { + t.Fatalf("expected missing permission group not to reach statistics service, got %d calls", statistics.calls) + } +} + func TestBuildUserViewsUsageModeKeepsAccountOnlyView(t *testing.T) { users := newAdminUserServiceFake(map[uint]domainuser.User{ 7: {ID: 7, Username: "alice", Role: domainuser.RoleUser}, @@ -570,6 +613,17 @@ type subscriptionResolverFake struct { accounts map[uint]billing.UserBillingAccountSnapshot } +type usageStatisticsServiceFake struct { + filter billing.UsageStatisticsFilter + calls int +} + +func (f *usageStatisticsServiceFake) GetUsageStatistics(_ context.Context, filter billing.UsageStatisticsFilter) (domainbilling.UsageStatistics, error) { + f.filter = filter + f.calls++ + return domainbilling.UsageStatistics{}, nil +} + func (s subscriptionResolverFake) ListCurrentSubscriptionSnapshots(context.Context, []uint, time.Time) (map[uint]billing.UserSubscriptionSnapshot, error) { if s.subscriptions == nil { return map[uint]billing.UserSubscriptionSnapshot{}, nil diff --git a/backend/internal/application/billing/errs.go b/backend/internal/application/billing/errs.go index b4f01a06..c3030edb 100644 --- a/backend/internal/application/billing/errs.go +++ b/backend/internal/application/billing/errs.go @@ -33,6 +33,8 @@ var ( ErrBillingPlanNotFound = errors.New("billing plan not found") // ErrInvalidPermissionGroup 非法权限组。 ErrInvalidPermissionGroup = errors.New("invalid permission group") + // ErrInvalidUsageStatisticsSubject 表示用量统计的用户与权限组筛选条件冲突。 + ErrInvalidUsageStatisticsSubject = errors.New("user and permission group filters are mutually exclusive") // ErrPermissionGroupReferenceCounterUnavailable 权限组套餐引用检查能力不可用。 ErrPermissionGroupReferenceCounterUnavailable = errors.New("permission group reference counter unavailable") // ErrSubscriptionEntitlementActive 当前仍存在有效付费订阅权益。 diff --git a/backend/internal/application/billing/service.go b/backend/internal/application/billing/service.go index 77d67642..a3585536 100644 --- a/backend/internal/application/billing/service.go +++ b/backend/internal/application/billing/service.go @@ -2266,6 +2266,110 @@ func (s *Service) ListUsageLogs(ctx context.Context, page int, pageSize int, fil }, offset, limit) } +// UsageStatisticsFilter 描述管理员仪表盘的用量统计条件。 +type UsageStatisticsFilter struct { + StartDate time.Time + EndDate time.Time + UserID uint + PermissionGroupID uint + PlatformModelName string + BillingScope string + Section string + ModelRankBy string + UserRankBy string +} + +// GetUsageStatistics 查询管理员仪表盘使用的全局用量统计。 +func (s *Service) GetUsageStatistics(ctx context.Context, filter UsageStatisticsFilter) (domainbilling.UsageStatistics, error) { + if filter.UserID > 0 && filter.PermissionGroupID > 0 { + return domainbilling.UsageStatistics{}, ErrInvalidUsageStatisticsSubject + } + statisticsRepo, ok := s.repo.(repository.UsageStatisticsRepository) + if !ok { + return domainbilling.UsageStatistics{}, errors.New("usage statistics repository unavailable") + } + + startDate := time.Date(filter.StartDate.Year(), filter.StartDate.Month(), filter.StartDate.Day(), 0, 0, 0, 0, filter.StartDate.Location()) + endDate := time.Date(filter.EndDate.Year(), filter.EndDate.Month(), filter.EndDate.Day(), 0, 0, 0, 0, filter.EndDate.Location()) + if endDate.Before(startDate) { + return domainbilling.UsageStatistics{}, errors.New("invalid usage statistics date range") + } + days := int(endDate.Sub(startDate).Hours()/24) + 1 + if days <= 0 || days > 366 { + return domainbilling.UsageStatistics{}, errors.New("invalid usage statistics date range") + } + granularity := usageStatisticsGranularity(days) + result, err := statisticsRepo.GetUsageStatistics(ctx, repository.UsageStatisticsFilter{ + StartDate: startDate, + EndDateExclusive: endDate.AddDate(0, 0, 1), + UserID: filter.UserID, + PermissionGroupID: filter.PermissionGroupID, + MembershipAt: time.Now(), + PlatformModelName: strings.TrimSpace(filter.PlatformModelName), + BillingScope: strings.TrimSpace(filter.BillingScope), + Granularity: granularity, + Section: strings.TrimSpace(filter.Section), + ModelRankBy: strings.TrimSpace(filter.ModelRankBy), + UserRankBy: strings.TrimSpace(filter.UserRankBy), + RankLimit: 10, + }) + if err != nil { + return domainbilling.UsageStatistics{}, err + } + result.Granularity = granularity + if strings.TrimSpace(filter.Section) == "" || strings.TrimSpace(filter.Section) == "all" { + result.Trend = fillUsageStatisticsTrend(result.Trend, startDate, endDate, granularity) + } + return result, nil +} + +func usageStatisticsGranularity(days int) string { + if days >= 180 { + return "month" + } + if days > 30 { + return "week" + } + return "day" +} + +func fillUsageStatisticsTrend( + items []domainbilling.UsageStatisticsTrendPoint, + startDate time.Time, + endDate time.Time, + granularity string, +) []domainbilling.UsageStatisticsTrendPoint { + byPeriod := make(map[string]domainbilling.UsageStatisticsTrendPoint, len(items)) + for _, item := range items { + byPeriod[item.PeriodStart.Format("2006-01-02")] = item + } + + current := startDate + if granularity == "week" { + daysSinceMonday := (int(startDate.Weekday()) + 6) % 7 + current = startDate.AddDate(0, 0, -daysSinceMonday) + } else if granularity == "month" { + current = time.Date(startDate.Year(), startDate.Month(), 1, 0, 0, 0, 0, startDate.Location()) + } + results := make([]domainbilling.UsageStatisticsTrendPoint, 0, len(items)) + for !current.After(endDate) { + key := current.Format("2006-01-02") + if item, exists := byPeriod[key]; exists { + results = append(results, item) + } else { + results = append(results, domainbilling.UsageStatisticsTrendPoint{PeriodStart: current}) + } + if granularity == "week" { + current = current.AddDate(0, 0, 7) + } else if granularity == "month" { + current = current.AddDate(0, 1, 0) + } else { + current = current.AddDate(0, 0, 1) + } + } + return results +} + // ListPaymentOrders 分页查询管理员支付订单记录。 func (s *Service) ListPaymentOrders(ctx context.Context, page int, pageSize int, filter PaymentOrderListFilter) ([]domainbilling.PaymentOrder, int64, error) { offset, limit := normalizePage(page, pageSize) diff --git a/backend/internal/application/billing/service_statistics_test.go b/backend/internal/application/billing/service_statistics_test.go new file mode 100644 index 00000000..94754c69 --- /dev/null +++ b/backend/internal/application/billing/service_statistics_test.go @@ -0,0 +1,55 @@ +package billing + +import ( + "testing" + "time" + + domainbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/billing" +) + +func TestUsageStatisticsGranularity(t *testing.T) { + tests := []struct { + days int + want string + }{ + {days: 1, want: "day"}, + {days: 30, want: "day"}, + {days: 31, want: "week"}, + {days: 90, want: "week"}, + {days: 179, want: "week"}, + {days: 180, want: "month"}, + {days: 366, want: "month"}, + } + + for _, tt := range tests { + if got := usageStatisticsGranularity(tt.days); got != tt.want { + t.Fatalf("usageStatisticsGranularity(%d) = %q, want %q", tt.days, got, tt.want) + } + } +} + +func TestFillUsageStatisticsTrendUsesMondayWeekBoundaries(t *testing.T) { + location := time.UTC + startDate := time.Date(2026, 7, 1, 0, 0, 0, 0, location) + endDate := time.Date(2026, 7, 20, 0, 0, 0, 0, location) + items := []domainbilling.UsageStatisticsTrendPoint{ + { + PeriodStart: time.Date(2026, 7, 6, 0, 0, 0, 0, location), + Metrics: domainbilling.UsageStatisticsMetrics{CallCount: 3}, + }, + } + + result := fillUsageStatisticsTrend(items, startDate, endDate, "week") + wantDates := []string{"2026-06-29", "2026-07-06", "2026-07-13", "2026-07-20"} + if len(result) != len(wantDates) { + t.Fatalf("weekly trend length = %d, want %d", len(result), len(wantDates)) + } + for index, wantDate := range wantDates { + if got := result[index].PeriodStart.Format("2006-01-02"); got != wantDate { + t.Fatalf("weekly trend[%d] = %q, want %q", index, got, wantDate) + } + } + if result[1].Metrics.CallCount != 3 { + t.Fatalf("weekly trend did not preserve existing metrics: %+v", result[1]) + } +} diff --git a/backend/internal/domain/billing/types.go b/backend/internal/domain/billing/types.go index c4190fe6..367c0899 100644 --- a/backend/internal/domain/billing/types.go +++ b/backend/internal/domain/billing/types.go @@ -406,3 +406,45 @@ type UsageDailyModelSummary struct { AvgLatencyMS int64 BilledNanousd int64 } + +// UsageStatisticsMetrics 表示一组聚合后的用量指标。 +type UsageStatisticsMetrics struct { + RecordCount int64 + InputTokens int64 + CacheReadTokens int64 + CacheWriteTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CallCount int64 + AvgLatencyMS int64 + BilledNanousd int64 +} + +// UsageStatisticsTrendPoint 表示一个按日或按月聚合的趋势点。 +type UsageStatisticsTrendPoint struct { + PeriodStart time.Time + Metrics UsageStatisticsMetrics +} + +// UsageStatisticsModelRank 表示平台模型的用量排名项。 +type UsageStatisticsModelRank struct { + PlatformModelName string + Metrics UsageStatisticsMetrics + Trend []UsageStatisticsTrendPoint +} + +// UsageStatisticsUserRank 表示用户的用量排名项。 +type UsageStatisticsUserRank struct { + UserID uint + Metrics UsageStatisticsMetrics + Trend []UsageStatisticsTrendPoint +} + +// UsageStatistics 表示管理员仪表盘使用的全局用量聚合。 +type UsageStatistics struct { + Granularity string + Totals UsageStatisticsMetrics + Trend []UsageStatisticsTrendPoint + TopModels []UsageStatisticsModelRank + TopUsers []UsageStatisticsUserRank +} diff --git a/backend/internal/infra/persistence/postgres/billing/repository.go b/backend/internal/infra/persistence/postgres/billing/repository.go index d22740a6..0686f568 100644 --- a/backend/internal/infra/persistence/postgres/billing/repository.go +++ b/backend/internal/infra/persistence/postgres/billing/repository.go @@ -67,6 +67,13 @@ func (r *Repo) usageMonthKeyExpression() string { return "TO_CHAR(date_trunc('month', usage_date), 'YYYY-MM-DD')" } +func (r *Repo) usageWeekKeyExpression() string { + if r.sqliteDialect() { + return "date(usage_date, '-' || ((CAST(strftime('%w', usage_date) AS INTEGER) + 6) % 7) || ' days')" + } + return "TO_CHAR(date_trunc('week', usage_date), 'YYYY-MM-DD')" +} + // ListActivePlans 查询启用套餐。 func (r *Repo) ListActivePlans(ctx context.Context) ([]domainbilling.Plan, error) { items := make([]model.BillingPlan, 0) @@ -1447,6 +1454,292 @@ func (r *Repo) ListUsageLogs(ctx context.Context, filter repository.UsageLogList return results, total, nil } +type usageStatisticsMetricRow struct { + RecordCount int64 `gorm:"column:record_count"` + InputTokens int64 `gorm:"column:input_tokens"` + CacheReadTokens int64 `gorm:"column:cache_read_tokens"` + CacheWriteTokens int64 `gorm:"column:cache_write_tokens"` + OutputTokens int64 `gorm:"column:output_tokens"` + ReasoningTokens int64 `gorm:"column:reasoning_tokens"` + TotalTokens int64 `gorm:"column:total_tokens"` + CallCount int64 `gorm:"column:call_count"` + AvgLatencyMS int64 `gorm:"column:avg_latency_ms"` + BilledNanousd int64 `gorm:"column:billed_nanousd"` +} + +type usageStatisticsTrendRow struct { + PeriodKey string `gorm:"column:period_key"` + Metrics usageStatisticsMetricRow `gorm:"embedded"` +} + +type usageStatisticsModelRow struct { + PlatformModelName string `gorm:"column:platform_model_name"` + Metrics usageStatisticsMetricRow `gorm:"embedded"` +} + +type usageStatisticsModelTrendRow struct { + PeriodKey string `gorm:"column:period_key"` + PlatformModelName string `gorm:"column:platform_model_name"` + Metrics usageStatisticsMetricRow `gorm:"embedded"` +} + +type usageStatisticsUserRow struct { + UserID uint `gorm:"column:user_id"` + Metrics usageStatisticsMetricRow `gorm:"embedded"` +} + +type usageStatisticsUserTrendRow struct { + PeriodKey string `gorm:"column:period_key"` + UserID uint `gorm:"column:user_id"` + Metrics usageStatisticsMetricRow `gorm:"embedded"` +} + +const usageStatisticsMetricsSelect = ` + COUNT(*) AS record_count, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens, + COALESCE(SUM(input_tokens + cache_read_tokens + cache_write_tokens + output_tokens + reasoning_tokens), 0) AS total_tokens, + COALESCE(SUM(call_count), 0) AS call_count, + COALESCE(ROUND(AVG(NULLIF(latency_ms, 0))), 0) AS avg_latency_ms, + COALESCE(SUM(billed_nanousd), 0) AS billed_nanousd` + +func usageStatisticsMetricsFromRow(row usageStatisticsMetricRow) domainbilling.UsageStatisticsMetrics { + return domainbilling.UsageStatisticsMetrics{ + RecordCount: row.RecordCount, + InputTokens: row.InputTokens, + CacheReadTokens: row.CacheReadTokens, + CacheWriteTokens: row.CacheWriteTokens, + OutputTokens: row.OutputTokens, + ReasoningTokens: row.ReasoningTokens, + CallCount: row.CallCount, + AvgLatencyMS: row.AvgLatencyMS, + BilledNanousd: row.BilledNanousd, + } +} + +func (r *Repo) usageStatisticsQuery(ctx context.Context, filter repository.UsageStatisticsFilter) *gorm.DB { + query := r.db.WithContext(ctx). + Model(&model.UsageLedger{}). + Where("usage_date >= ? AND usage_date < ?", filter.StartDate, filter.EndDateExclusive) + if filter.UserID > 0 { + query = query.Where("user_id = ?", filter.UserID) + } + if filter.PermissionGroupID > 0 { + membershipAt := filter.MembershipAt + if membershipAt.IsZero() { + membershipAt = time.Now() + } + query = query.Where(` + EXISTS ( + SELECT 1 + FROM permission_groups AS permission_group + WHERE permission_group.id = ? + AND ( + permission_group.is_default = ? + OR EXISTS ( + SELECT 1 + FROM permission_group_user_access AS manual_access + WHERE manual_access.group_id = permission_group.id + AND manual_access.user_id = billing_usage_ledgers.user_id + ) + OR EXISTS ( + SELECT 1 + FROM billing_subscriptions AS subscription + JOIN billing_plans AS plan ON plan.id = subscription.plan_id + WHERE plan.permission_group_id = permission_group.id + AND subscription.user_id = billing_usage_ledgers.user_id + AND plan.deleted_at IS NULL + AND subscription.deleted_at IS NULL + AND plan.is_active = ? + AND subscription.status = ? + AND subscription.current_period_start_at <= ? + AND (subscription.current_period_end_at IS NULL OR subscription.current_period_end_at > ?) + ) + ) + ) + `, filter.PermissionGroupID, true, true, "active", membershipAt, membershipAt) + } + if platformModelName := strings.TrimSpace(filter.PlatformModelName); platformModelName != "" { + query = query.Where("platform_model_name = ?", platformModelName) + } + switch strings.TrimSpace(filter.BillingScope) { + case "free": + query = query.Where("is_free_model = ?", true) + case "billable": + query = query.Where("is_free_model = ?", false) + } + return query +} + +func usageStatisticsRankOrder(rankBy string, finalTieBreaker string) string { + primary := "billed_nanousd DESC" + switch strings.TrimSpace(rankBy) { + case "tokens": + primary = "total_tokens DESC" + case "calls": + primary = "call_count DESC" + } + return primary + ", total_tokens DESC, billed_nanousd DESC, " + finalTieBreaker +} + +// GetUsageStatistics 查询管理员仪表盘使用的全局用量聚合。 +func (r *Repo) GetUsageStatistics(ctx context.Context, filter repository.UsageStatisticsFilter) (domainbilling.UsageStatistics, error) { + result := domainbilling.UsageStatistics{ + Granularity: filter.Granularity, + Trend: []domainbilling.UsageStatisticsTrendPoint{}, + TopModels: []domainbilling.UsageStatisticsModelRank{}, + TopUsers: []domainbilling.UsageStatisticsUserRank{}, + } + section := strings.TrimSpace(filter.Section) + includeOverview := section == "" || section == "all" + includeModels := includeOverview || section == "models" + includeUsers := includeOverview || section == "users" + periodExpression := r.usageDayKeyExpression() + if filter.Granularity == "week" { + periodExpression = r.usageWeekKeyExpression() + } else if filter.Granularity == "month" { + periodExpression = r.usageMonthKeyExpression() + } + + if includeOverview { + var totals usageStatisticsMetricRow + if err := r.usageStatisticsQuery(ctx, filter). + Select(usageStatisticsMetricsSelect). + Scan(&totals).Error; err != nil { + return result, translateError(err) + } + result.Totals = usageStatisticsMetricsFromRow(totals) + + trendRows := make([]usageStatisticsTrendRow, 0) + if err := r.usageStatisticsQuery(ctx, filter). + Select(periodExpression + " AS period_key," + usageStatisticsMetricsSelect). + Group(periodExpression). + Order("period_key ASC"). + Scan(&trendRows).Error; err != nil { + return result, translateError(err) + } + for _, row := range trendRows { + periodStart, err := time.Parse("2006-01-02", row.PeriodKey) + if err != nil { + return result, err + } + result.Trend = append(result.Trend, domainbilling.UsageStatisticsTrendPoint{ + PeriodStart: periodStart, + Metrics: usageStatisticsMetricsFromRow(row.Metrics), + }) + } + } + + rankLimit := filter.RankLimit + if rankLimit <= 0 || rankLimit > 50 { + rankLimit = 10 + } + if includeModels { + modelRows := make([]usageStatisticsModelRow, 0, rankLimit) + if err := r.usageStatisticsQuery(ctx, filter). + Select("platform_model_name," + usageStatisticsMetricsSelect). + Group("platform_model_name"). + Order(usageStatisticsRankOrder(filter.ModelRankBy, "platform_model_name ASC")). + Limit(rankLimit). + Scan(&modelRows).Error; err != nil { + return result, translateError(err) + } + for _, row := range modelRows { + result.TopModels = append(result.TopModels, domainbilling.UsageStatisticsModelRank{ + PlatformModelName: row.PlatformModelName, + Metrics: usageStatisticsMetricsFromRow(row.Metrics), + Trend: []domainbilling.UsageStatisticsTrendPoint{}, + }) + } + if len(result.TopModels) > 0 { + modelNames := make([]string, 0, len(result.TopModels)) + modelIndexes := make(map[string]int, len(result.TopModels)) + for index, item := range result.TopModels { + modelNames = append(modelNames, item.PlatformModelName) + modelIndexes[item.PlatformModelName] = index + } + modelTrendRows := make([]usageStatisticsModelTrendRow, 0) + if err := r.usageStatisticsQuery(ctx, filter). + Where("platform_model_name IN ?", modelNames). + Select(periodExpression + " AS period_key, platform_model_name," + usageStatisticsMetricsSelect). + Group(periodExpression + ", platform_model_name"). + Order("period_key ASC, platform_model_name ASC"). + Scan(&modelTrendRows).Error; err != nil { + return result, translateError(err) + } + for _, row := range modelTrendRows { + periodStart, err := time.Parse("2006-01-02", row.PeriodKey) + if err != nil { + return result, err + } + index, exists := modelIndexes[row.PlatformModelName] + if !exists { + continue + } + result.TopModels[index].Trend = append(result.TopModels[index].Trend, domainbilling.UsageStatisticsTrendPoint{ + PeriodStart: periodStart, + Metrics: usageStatisticsMetricsFromRow(row.Metrics), + }) + } + } + } + + if includeUsers { + userRows := make([]usageStatisticsUserRow, 0, rankLimit) + if err := r.usageStatisticsQuery(ctx, filter). + Select("user_id," + usageStatisticsMetricsSelect). + Group("user_id"). + Order(usageStatisticsRankOrder(filter.UserRankBy, "user_id ASC")). + Limit(rankLimit). + Scan(&userRows).Error; err != nil { + return result, translateError(err) + } + for _, row := range userRows { + result.TopUsers = append(result.TopUsers, domainbilling.UsageStatisticsUserRank{ + UserID: row.UserID, + Metrics: usageStatisticsMetricsFromRow(row.Metrics), + Trend: []domainbilling.UsageStatisticsTrendPoint{}, + }) + } + if len(result.TopUsers) > 0 { + userIDs := make([]uint, 0, len(result.TopUsers)) + userIndexes := make(map[uint]int, len(result.TopUsers)) + for index, item := range result.TopUsers { + userIDs = append(userIDs, item.UserID) + userIndexes[item.UserID] = index + } + userTrendRows := make([]usageStatisticsUserTrendRow, 0) + if err := r.usageStatisticsQuery(ctx, filter). + Where("user_id IN ?", userIDs). + Select(periodExpression + " AS period_key, user_id," + usageStatisticsMetricsSelect). + Group(periodExpression + ", user_id"). + Order("period_key ASC, user_id ASC"). + Scan(&userTrendRows).Error; err != nil { + return result, translateError(err) + } + for _, row := range userTrendRows { + periodStart, err := time.Parse("2006-01-02", row.PeriodKey) + if err != nil { + return result, err + } + index, exists := userIndexes[row.UserID] + if !exists { + continue + } + result.TopUsers[index].Trend = append(result.TopUsers[index].Trend, domainbilling.UsageStatisticsTrendPoint{ + PeriodStart: periodStart, + Metrics: usageStatisticsMetricsFromRow(row.Metrics), + }) + } + } + } + + return result, nil +} + // ListPaymentOrders 分页查询管理员支付订单记录。 func (r *Repo) ListPaymentOrders(ctx context.Context, filter repository.PaymentOrderListFilter, offset int, limit int) ([]domainbilling.PaymentOrder, int64, error) { items := make([]model.PaymentOrder, 0) diff --git a/backend/internal/infra/persistence/postgres/billing/repository_sqlite_test.go b/backend/internal/infra/persistence/postgres/billing/repository_sqlite_test.go index 9afc8f62..7585c410 100644 --- a/backend/internal/infra/persistence/postgres/billing/repository_sqlite_test.go +++ b/backend/internal/infra/persistence/postgres/billing/repository_sqlite_test.go @@ -90,6 +90,152 @@ func TestUsageQueriesUseSQLitePortableExpressions(t *testing.T) { if len(daily[0].Models) != 2 { t.Fatalf("expected two daily model summaries, got %d", len(daily[0].Models)) } + + statistics, err := repo.GetUsageStatistics(ctx, repository.UsageStatisticsFilter{ + StartDate: usageDate, + EndDateExclusive: usageDate.AddDate(0, 0, 1), + Granularity: "day", + ModelRankBy: "tokens", + UserRankBy: "cost", + RankLimit: 10, + }) + if err != nil { + t.Fatalf("GetUsageStatistics() error = %v", err) + } + if len(statistics.TopModels) != 3 || statistics.TopModels[0].PlatformModelName != "gpt-test" { + t.Fatalf("expected models ranked by tokens, got %+v", statistics.TopModels) + } + if len(statistics.TopUsers) != 2 || statistics.TopUsers[0].UserID != 2 { + t.Fatalf("expected users ranked by cost, got %+v", statistics.TopUsers) + } + + modelStatistics, err := repo.GetUsageStatistics(ctx, repository.UsageStatisticsFilter{ + StartDate: usageDate, + EndDateExclusive: usageDate.AddDate(0, 0, 1), + Granularity: "day", + Section: "models", + ModelRankBy: "tokens", + RankLimit: 10, + }) + if err != nil { + t.Fatalf("GetUsageStatistics(models) error = %v", err) + } + if len(modelStatistics.TopModels) != 3 || len(modelStatistics.TopUsers) != 0 || len(modelStatistics.Trend) != 0 { + t.Fatalf("expected model-only statistics, got %+v", modelStatistics) + } + + weeklyStatistics, err := repo.GetUsageStatistics(ctx, repository.UsageStatisticsFilter{ + StartDate: usageDate, + EndDateExclusive: usageDate.AddDate(0, 0, 1), + Granularity: "week", + Section: "all", + ModelRankBy: "cost", + UserRankBy: "cost", + RankLimit: 10, + }) + if err != nil { + t.Fatalf("GetUsageStatistics(week) error = %v", err) + } + if len(weeklyStatistics.Trend) != 1 || weeklyStatistics.Trend[0].PeriodStart.Format("2006-01-02") != "2026-06-01" { + t.Fatalf("unexpected weekly statistics: %+v", weeklyStatistics.Trend) + } +} + +func TestUsageStatisticsFiltersByCurrentPermissionGroupMembership(t *testing.T) { + db := openBillingSQLiteTestDB(t) + if err := db.AutoMigrate( + &model.PermissionGroup{}, + &model.PermissionGroupUserAccess{}, + &model.BillingPlan{}, + &model.Subscription{}, + ); err != nil { + t.Fatalf("migrate permission group tables: %v", err) + } + + groups := []model.PermissionGroup{ + {Name: "Default", IsDefault: true}, + {Name: "Pro"}, + } + if err := db.Create(&groups).Error; err != nil { + t.Fatalf("create permission groups: %v", err) + } + if err := db.Create(&model.PermissionGroupUserAccess{GroupID: groups[1].ID, UserID: 1}).Error; err != nil { + t.Fatalf("create manual group member: %v", err) + } + + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + activePlan := model.BillingPlan{Code: "pro", Name: "Pro", IsActive: true, PermissionGroupID: &groups[1].ID} + inactivePlan := model.BillingPlan{Code: "legacy", Name: "Legacy", IsActive: false, PermissionGroupID: &groups[1].ID} + if err := db.Create(&[]model.BillingPlan{activePlan, inactivePlan}).Error; err != nil { + t.Fatalf("create billing plans: %v", err) + } + var plans []model.BillingPlan + if err := db.Order("id ASC").Find(&plans).Error; err != nil { + t.Fatalf("reload billing plans: %v", err) + } + periodEnd := now.Add(24 * time.Hour) + expiredEnd := now.Add(-time.Hour) + futureStart := now.Add(time.Hour) + subscriptions := []model.Subscription{ + {UserID: 1, PlanID: plans[0].ID, PriceID: 1, Status: "active", StartAt: now.Add(-time.Hour), CurrentPeriodStartAt: now.Add(-time.Hour), CurrentPeriodEndAt: &periodEnd}, + {UserID: 2, PlanID: plans[0].ID, PriceID: 1, Status: "active", StartAt: now.Add(-time.Hour), CurrentPeriodStartAt: now.Add(-time.Hour), CurrentPeriodEndAt: &periodEnd}, + {UserID: 3, PlanID: plans[0].ID, PriceID: 1, Status: "active", StartAt: now.Add(-48 * time.Hour), CurrentPeriodStartAt: now.Add(-48 * time.Hour), CurrentPeriodEndAt: &expiredEnd}, + {UserID: 4, PlanID: plans[0].ID, PriceID: 1, Status: "active", StartAt: futureStart, CurrentPeriodStartAt: futureStart, CurrentPeriodEndAt: &periodEnd}, + {UserID: 5, PlanID: plans[1].ID, PriceID: 1, Status: "active", StartAt: now.Add(-time.Hour), CurrentPeriodStartAt: now.Add(-time.Hour), CurrentPeriodEndAt: &periodEnd}, + {UserID: 6, PlanID: plans[0].ID, PriceID: 1, Status: "active", StartAt: now.Add(-time.Hour), CurrentPeriodStartAt: now.Add(-time.Hour), CurrentPeriodEndAt: &periodEnd}, + } + if err := db.Create(&subscriptions).Error; err != nil { + t.Fatalf("create subscriptions: %v", err) + } + if err := db.Delete(&subscriptions[5]).Error; err != nil { + t.Fatalf("soft delete active subscription: %v", err) + } + + usageDate := time.Date(2026, 7, 15, 0, 0, 0, 0, time.UTC) + ledgers := make([]model.UsageLedger, 0, 6) + for userID := uint(1); userID <= 6; userID++ { + ledgers = append(ledgers, model.UsageLedger{ + UserID: userID, + PlatformModelName: "gpt-test", + BillingAt: usageDate, + UsageDate: usageDate, + CallCount: 1, + BilledNanousd: int64(userID) * 100, + }) + } + if err := db.Create(&ledgers).Error; err != nil { + t.Fatalf("create usage ledgers: %v", err) + } + + filter := repository.UsageStatisticsFilter{ + StartDate: usageDate, + EndDateExclusive: usageDate.AddDate(0, 0, 1), + PermissionGroupID: groups[1].ID, + MembershipAt: now, + Granularity: "day", + ModelRankBy: "cost", + UserRankBy: "cost", + RankLimit: 10, + } + statistics, err := NewRepo(db).GetUsageStatistics(context.Background(), filter) + if err != nil { + t.Fatalf("GetUsageStatistics() error = %v", err) + } + if statistics.Totals.RecordCount != 2 || statistics.Totals.CallCount != 2 || statistics.Totals.BilledNanousd != 300 { + t.Fatalf("expected users 1 and 2 to be counted once, got %+v", statistics.Totals) + } + if len(statistics.TopUsers) != 2 || statistics.TopUsers[0].UserID != 2 || statistics.TopUsers[1].UserID != 1 { + t.Fatalf("unexpected permission group user ranking: %+v", statistics.TopUsers) + } + + filter.PermissionGroupID = groups[0].ID + statistics, err = NewRepo(db).GetUsageStatistics(context.Background(), filter) + if err != nil { + t.Fatalf("GetUsageStatistics(default group) error = %v", err) + } + if statistics.Totals.RecordCount != 6 || statistics.Totals.BilledNanousd != 2100 { + t.Fatalf("expected default group to include all users, got %+v", statistics.Totals) + } } func TestAddUsageAndSettleBalanceRecordsDebtWithoutReservation(t *testing.T) { diff --git a/backend/internal/repository/billing.go b/backend/internal/repository/billing.go index f0922aca..4c16cce6 100644 --- a/backend/internal/repository/billing.go +++ b/backend/internal/repository/billing.go @@ -111,6 +111,28 @@ type UsageLogListFilter struct { Sort string } +// UsageStatisticsFilter 描述管理员用量统计的聚合范围和维度。 +type UsageStatisticsFilter struct { + StartDate time.Time + EndDateExclusive time.Time + UserID uint + PermissionGroupID uint + MembershipAt time.Time + PlatformModelName string + BillingScope string + Granularity string + Section string + ModelRankBy string + UserRankBy string + RankLimit int +} + +// UsageStatisticsRepository 提供独立于计费写入仓储的管理员统计能力。 +// 独立接口可避免为统计功能扩大 BillingRepository 及其测试桩。 +type UsageStatisticsRepository interface { + GetUsageStatistics(ctx context.Context, filter UsageStatisticsFilter) (domainbilling.UsageStatistics, error) +} + // PaymentOrderListFilter 描述管理员支付订单列表筛选和排序条件。 type PaymentOrderListFilter struct { Query string diff --git a/backend/internal/transport/http/admin/dto.go b/backend/internal/transport/http/admin/dto.go index 60e855de..298cc655 100644 --- a/backend/internal/transport/http/admin/dto.go +++ b/backend/internal/transport/http/admin/dto.go @@ -287,6 +287,58 @@ type UsageLogResponse struct { UpdatedAt time.Time `json:"updatedAt"` } +// UsageStatisticsMetricsResponse 用量统计指标响应。 +type UsageStatisticsMetricsResponse struct { + RecordCount int64 `json:"recordCount"` + InputTokens int64 `json:"inputTokens"` + CacheReadTokens int64 `json:"cacheReadTokens"` + CacheWriteTokens int64 `json:"cacheWriteTokens"` + OutputTokens int64 `json:"outputTokens"` + ReasoningTokens int64 `json:"reasoningTokens"` + TotalTokens int64 `json:"totalTokens"` + CallCount int64 `json:"callCount"` + AvgLatencyMS int64 `json:"avgLatencyMS"` + BilledNanousd int64 `json:"billedNanousd"` + BilledUSD float64 `json:"billedUSD"` +} + +// UsageStatisticsTrendResponse 用量趋势点响应。 +type UsageStatisticsTrendResponse struct { + PeriodStart time.Time `json:"periodStart"` + UsageStatisticsMetricsResponse +} + +// UsageStatisticsModelRankResponse 模型排名响应。 +type UsageStatisticsModelRankResponse struct { + PlatformModelName string `json:"platformModelName"` + UsageStatisticsMetricsResponse + Trend []UsageStatisticsTrendResponse `json:"trend"` +} + +// UsageStatisticsUserRankResponse 用户排名响应。 +type UsageStatisticsUserRankResponse struct { + UserID uint `json:"userID"` + Username string `json:"username"` + UserDisplayName string `json:"userDisplayName"` + UserLabel string `json:"userLabel"` + UsageStatisticsMetricsResponse + Trend []UsageStatisticsTrendResponse `json:"trend"` +} + +// UsageStatisticsResponse 管理员用量统计响应。 +type UsageStatisticsResponse struct { + Section string `json:"section"` + Range struct { + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + Granularity string `json:"granularity"` + } `json:"range"` + Totals UsageStatisticsMetricsResponse `json:"totals"` + Trend []UsageStatisticsTrendResponse `json:"trend"` + TopModels []UsageStatisticsModelRankResponse `json:"topModels"` + TopUsers []UsageStatisticsUserRankResponse `json:"topUsers"` +} + // PaymentOrderResponse 支付订单记录响应。 type PaymentOrderResponse struct { ID uint `json:"id"` @@ -549,6 +601,12 @@ type UsageLogListResponseDoc struct { } `json:"data"` } +// UsageStatisticsResponseDoc 管理员用量统计响应。 +type UsageStatisticsResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data UsageStatisticsResponse `json:"data"` +} + // PaymentOrderListResponseDoc 支付订单分页响应。 type PaymentOrderListResponseDoc struct { ErrorMsg string `json:"errorMsg"` @@ -740,6 +798,73 @@ func toUsageLogResponse(item domainbilling.UsageLedger, label appadmin.UserLabel } } +func toUsageStatisticsMetricsResponse(item domainbilling.UsageStatisticsMetrics) UsageStatisticsMetricsResponse { + totalTokens := item.InputTokens + item.CacheReadTokens + item.CacheWriteTokens + item.OutputTokens + item.ReasoningTokens + return UsageStatisticsMetricsResponse{ + RecordCount: item.RecordCount, + InputTokens: item.InputTokens, + CacheReadTokens: item.CacheReadTokens, + CacheWriteTokens: item.CacheWriteTokens, + OutputTokens: item.OutputTokens, + ReasoningTokens: item.ReasoningTokens, + TotalTokens: totalTokens, + CallCount: item.CallCount, + AvgLatencyMS: item.AvgLatencyMS, + BilledNanousd: item.BilledNanousd, + BilledUSD: float64(item.BilledNanousd) / 1_000_000_000, + } +} + +func toUsageStatisticsTrendResponses(items []domainbilling.UsageStatisticsTrendPoint) []UsageStatisticsTrendResponse { + result := make([]UsageStatisticsTrendResponse, 0, len(items)) + for _, point := range items { + result = append(result, UsageStatisticsTrendResponse{ + PeriodStart: point.PeriodStart, + UsageStatisticsMetricsResponse: toUsageStatisticsMetricsResponse(point.Metrics), + }) + } + return result +} + +func toUsageStatisticsResponse( + item domainbilling.UsageStatistics, + startDate time.Time, + endDate time.Time, + section string, + userLabels map[uint]appadmin.UserLabel, +) UsageStatisticsResponse { + result := UsageStatisticsResponse{ + Section: section, + Totals: toUsageStatisticsMetricsResponse(item.Totals), + Trend: make([]UsageStatisticsTrendResponse, 0, len(item.Trend)), + TopModels: make([]UsageStatisticsModelRankResponse, 0, len(item.TopModels)), + TopUsers: make([]UsageStatisticsUserRankResponse, 0, len(item.TopUsers)), + } + result.Range.StartDate = startDate.Format("2006-01-02") + result.Range.EndDate = endDate.Format("2006-01-02") + result.Range.Granularity = item.Granularity + result.Trend = toUsageStatisticsTrendResponses(item.Trend) + for _, model := range item.TopModels { + result.TopModels = append(result.TopModels, UsageStatisticsModelRankResponse{ + PlatformModelName: model.PlatformModelName, + UsageStatisticsMetricsResponse: toUsageStatisticsMetricsResponse(model.Metrics), + Trend: toUsageStatisticsTrendResponses(model.Trend), + }) + } + for _, rankedUser := range item.TopUsers { + label := userLabels[rankedUser.UserID] + result.TopUsers = append(result.TopUsers, UsageStatisticsUserRankResponse{ + UserID: rankedUser.UserID, + Username: label.Username, + UserDisplayName: label.DisplayName, + UserLabel: label.Label, + UsageStatisticsMetricsResponse: toUsageStatisticsMetricsResponse(rankedUser.Metrics), + Trend: toUsageStatisticsTrendResponses(rankedUser.Trend), + }) + } + return result +} + func toPaymentOrderResponse(item domainbilling.PaymentOrder, label appadmin.UserLabel) PaymentOrderResponse { return PaymentOrderResponse{ ID: item.ID, diff --git a/backend/internal/transport/http/admin/handler.go b/backend/internal/transport/http/admin/handler.go index 416d5689..8d7248bc 100644 --- a/backend/internal/transport/http/admin/handler.go +++ b/backend/internal/transport/http/admin/handler.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "time" appadmin "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/admin" @@ -484,6 +485,125 @@ func (h *Handler) ListUsageLogs(c *gin.Context) { response.SuccessPage(c, total, logs) } +// GetUsageStatistics godoc +// @Summary 管理员查询全局用量统计 +// @Description 管理员按日期、统计对象、平台模型和计费范围查看全局费用、Token、调用次数及排名;用户与权限组筛选互斥 +// @Tags admin +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param start_date query string false "开始日期(YYYY-MM-DD),默认近30天" +// @Param end_date query string false "结束日期(YYYY-MM-DD,包含当日)" +// @Param user_id query int false "用户ID" +// @Param permission_group_id query int false "权限组ID,与 user_id 互斥" +// @Param platform_model_name query string false "平台模型名" +// @Param billing_scope query string false "计费范围:all/free/billable" +// @Param section query string false "返回范围:all/models/users" +// @Param model_rank_by query string false "模型排名指标:cost/tokens/calls" +// @Param user_rank_by query string false "用户排名指标:cost/tokens/calls" +// @Success 200 {object} UsageStatisticsResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /admin/usage-statistics [get] +// GetUsageStatistics 查询管理员全局用量统计。 +func (h *Handler) GetUsageStatistics(c *gin.Context) { + userID, ok := parseOptionalUintQuery(c, "user_id") + if !ok { + return + } + permissionGroupID, ok := parseOptionalUintQuery(c, "permission_group_id") + if !ok { + return + } + + startDateText := strings.TrimSpace(c.Query("start_date")) + endDateText := strings.TrimSpace(c.Query("end_date")) + now := time.Now() + endDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + startDate := endDate.AddDate(0, 0, -29) + if startDateText != "" || endDateText != "" { + if startDateText == "" || endDateText == "" { + response.ErrorWithCode(c, http.StatusBadRequest, "usage_statistics.invalid_date_range", "start_date and end_date must be provided together") + return + } + parsedStartDate, startErr := time.Parse("2006-01-02", startDateText) + parsedEndDate, endErr := time.Parse("2006-01-02", endDateText) + if startErr != nil || endErr != nil { + response.ErrorWithCode(c, http.StatusBadRequest, "usage_statistics.invalid_date_range", "invalid usage statistics date range") + return + } + startDate = parsedStartDate + endDate = parsedEndDate + } + if endDate.Before(startDate) || int(endDate.Sub(startDate).Hours()/24)+1 > 366 { + response.ErrorWithCode(c, http.StatusBadRequest, "usage_statistics.invalid_date_range", "invalid usage statistics date range") + return + } + + billingScope := strings.TrimSpace(c.Query("billing_scope")) + if billingScope == "" { + billingScope = "all" + } + if billingScope != "all" && billingScope != "free" && billingScope != "billable" { + response.ErrorWithCode(c, http.StatusBadRequest, "usage_statistics.invalid_billing_scope", "invalid billing_scope") + return + } + section := strings.TrimSpace(c.Query("section")) + if section == "" { + section = "all" + } + if section != "all" && section != "models" && section != "users" { + response.ErrorWithCode(c, http.StatusBadRequest, "usage_statistics.invalid_section", "invalid section") + return + } + modelRankBy := strings.TrimSpace(c.Query("model_rank_by")) + if modelRankBy == "" { + modelRankBy = "cost" + } + if modelRankBy != "cost" && modelRankBy != "tokens" && modelRankBy != "calls" { + response.ErrorWithCode(c, http.StatusBadRequest, "usage_statistics.invalid_rank_by", "invalid model_rank_by") + return + } + userRankBy := strings.TrimSpace(c.Query("user_rank_by")) + if userRankBy == "" { + userRankBy = "cost" + } + if userRankBy != "cost" && userRankBy != "tokens" && userRankBy != "calls" { + response.ErrorWithCode(c, http.StatusBadRequest, "usage_statistics.invalid_rank_by", "invalid user_rank_by") + return + } + + statistics, err := h.service.GetUsageStatistics(c.Request.Context(), appbilling.UsageStatisticsFilter{ + StartDate: startDate, + EndDate: endDate, + UserID: userID, + PermissionGroupID: permissionGroupID, + PlatformModelName: c.Query("platform_model_name"), + BillingScope: billingScope, + Section: section, + ModelRankBy: modelRankBy, + UserRankBy: userRankBy, + }) + if err != nil { + switch { + case errors.Is(err, appbilling.ErrInvalidUsageStatisticsSubject): + response.ErrorWithCode(c, http.StatusBadRequest, "usage_statistics.subject_conflict", err.Error()) + case errors.Is(err, appadmin.ErrPermissionGroupNotFound): + response.ErrorFrom(c, http.StatusNotFound, err) + default: + response.Error(c, http.StatusInternalServerError, "get usage statistics failed") + } + return + } + userIDs := make([]uint, 0, len(statistics.TopUsers)) + for _, rankedUser := range statistics.TopUsers { + userIDs = append(userIDs, rankedUser.UserID) + } + userLabels := h.service.ResolveUserLabels(c.Request.Context(), userIDs) + response.Success(c, toUsageStatisticsResponse(statistics, startDate, endDate, section, userLabels)) +} + // ListPaymentOrders godoc // @Summary 管理员查询支付订单记录 // @Description 管理员分页查看订阅和充值支付单 diff --git a/backend/internal/transport/http/admin/handler_security_test.go b/backend/internal/transport/http/admin/handler_security_test.go index 12f6544e..dfdc917c 100644 --- a/backend/internal/transport/http/admin/handler_security_test.go +++ b/backend/internal/transport/http/admin/handler_security_test.go @@ -10,7 +10,9 @@ import ( appadmin "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/admin" auditapp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/audit" + appbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/billing" domainaudit "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/audit" + domainbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/billing" domainuser "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/user" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/middleware" @@ -47,6 +49,79 @@ func TestPatchUserReturnsForbiddenWhenAdminManagesSuperAdmin(t *testing.T) { } } +func TestGetUsageStatisticsRejectsConflictingSubjectFilters(t *testing.T) { + gin.SetMode(gin.TestMode) + + service := appadmin.NewService(&handlerUserServiceFake{}, handlerAuditServiceFake{}) + service.SetUsageStatisticsService(handlerUsageStatisticsServiceFake{}) + handler := NewHandler(service) + router := gin.New() + router.GET("/admin/usage-statistics", handler.GetUsageStatistics) + + request := httptest.NewRequest(http.MethodGet, "/admin/usage-statistics?user_id=1&permission_group_id=2", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected bad request, got status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), "usage_statistics.subject_conflict") { + t.Fatalf("expected stable subject conflict error code, got body=%s", recorder.Body.String()) + } +} + +func TestGetUsageStatisticsResolvesRankingMetrics(t *testing.T) { + tests := []struct { + name string + query string + wantSection string + wantModelRankBy string + wantUserRankBy string + }{ + { + name: "default ranking metrics", + wantSection: "all", + wantModelRankBy: "cost", + wantUserRankBy: "cost", + }, + { + name: "independent ranking metrics", + query: "?section=models&model_rank_by=tokens&user_rank_by=calls", + wantSection: "models", + wantModelRankBy: "tokens", + wantUserRankBy: "calls", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + capturedFilter := appbilling.UsageStatisticsFilter{} + service := appadmin.NewService(&handlerUserServiceFake{}, handlerAuditServiceFake{}) + service.SetUsageStatisticsService(handlerUsageStatisticsCaptureFake{filter: &capturedFilter}) + handler := NewHandler(service) + router := gin.New() + router.GET("/admin/usage-statistics", handler.GetUsageStatistics) + + request := httptest.NewRequest(http.MethodGet, "/admin/usage-statistics"+tt.query, nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected success, got status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if capturedFilter.Section != tt.wantSection || + capturedFilter.ModelRankBy != tt.wantModelRankBy || + capturedFilter.UserRankBy != tt.wantUserRankBy { + t.Fatalf("unexpected ranking metrics: %+v", capturedFilter) + } + if !strings.Contains(recorder.Body.String(), `"section":"`+tt.wantSection+`"`) { + t.Fatalf("expected response section %q, got body=%s", tt.wantSection, recorder.Body.String()) + } + }) + } +} + type handlerUserServiceFake struct { users map[uint]domainuser.User } @@ -136,6 +211,21 @@ func (s *handlerUserServiceFake) ListAuthEvents(context.Context, uint, string, s type handlerAuditServiceFake struct{} +type handlerUsageStatisticsServiceFake struct{} + +func (handlerUsageStatisticsServiceFake) GetUsageStatistics(context.Context, appbilling.UsageStatisticsFilter) (domainbilling.UsageStatistics, error) { + return domainbilling.UsageStatistics{}, nil +} + +type handlerUsageStatisticsCaptureFake struct { + filter *appbilling.UsageStatisticsFilter +} + +func (f handlerUsageStatisticsCaptureFake) GetUsageStatistics(_ context.Context, filter appbilling.UsageStatisticsFilter) (domainbilling.UsageStatistics, error) { + *f.filter = filter + return domainbilling.UsageStatistics{}, nil +} + func (handlerAuditServiceFake) Write(context.Context, string, uint, string, string, string, string, string, interface{}) { } diff --git a/backend/internal/transport/http/admin/router.go b/backend/internal/transport/http/admin/router.go index 4a94e016..33f4286f 100644 --- a/backend/internal/transport/http/admin/router.go +++ b/backend/internal/transport/http/admin/router.go @@ -15,6 +15,7 @@ func (m *Module) RegisterRoutes(adminGroup *gin.RouterGroup) { adminGroup.DELETE("/users/:id", m.Handler.DeleteUser) adminGroup.GET("/user-auth-events", m.Handler.ListUserAuthEvents) adminGroup.GET("/audit-logs", m.Handler.ListAuditLogs) + adminGroup.GET("/usage-statistics", m.Handler.GetUsageStatistics) adminGroup.GET("/call-logs", m.Handler.ListUsageLogs) adminGroup.GET("/payment-orders", m.Handler.ListPaymentOrders) adminGroup.GET("/conversation-events", m.Handler.ListConversationEvents) diff --git a/frontend/app/(admin)/admin/page.tsx b/frontend/app/(admin)/admin/page.tsx index d4a5cf36..26269c91 100644 --- a/frontend/app/(admin)/admin/page.tsx +++ b/frontend/app/(admin)/admin/page.tsx @@ -1,5 +1,5 @@ -import { AdminAccountsPage } from "@/features/admin/components/sections/accounts/admin-accounts"; +import { redirect } from "next/navigation"; export default function Page() { - return ; + redirect("/admin/statistics"); } diff --git a/frontend/app/(admin)/admin/statistics/page.tsx b/frontend/app/(admin)/admin/statistics/page.tsx new file mode 100644 index 00000000..5c87aa42 --- /dev/null +++ b/frontend/app/(admin)/admin/statistics/page.tsx @@ -0,0 +1,5 @@ +import { AdminStatisticsPage } from "@/features/admin/components/sections/statistics/admin-statistics"; + +export default function Page() { + return ; +} diff --git a/frontend/components/ui/chart.tsx b/frontend/components/ui/chart.tsx index 011836a0..013672b2 100644 --- a/frontend/components/ui/chart.tsx +++ b/frontend/components/ui/chart.tsx @@ -4,6 +4,7 @@ import * as React from "react" import * as RechartsPrimitive from "recharts" import type { TooltipValueType } from "recharts" +import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" // Format: { THEME_NAME: CSS_SELECTOR } @@ -27,6 +28,13 @@ type ChartContextProps = { config: ChartConfig } +type ChartInteractiveLegendItem = { + id: string + label: string + title?: string + color: string +} + const ChartContext = React.createContext(null) function useChart() { @@ -327,6 +335,42 @@ function ChartLegendContent({ ) } +function ChartInteractiveLegend({ + items, + hiddenSeries, + onToggle, +}: { + items: ChartInteractiveLegendItem[] + hiddenSeries: ReadonlySet + onToggle: (id: string) => void +}) { + return ( +
+ {items.map((item) => { + const visible = !hiddenSeries.has(item.id) + return ( + + ) + })} +
+ ) +} + // Helper to extract item config from a payload. function getPayloadConfigFromPayload( config: ChartConfig, @@ -370,5 +414,7 @@ export { ChartTooltipContent, ChartLegend, ChartLegendContent, + ChartInteractiveLegend, ChartStyle, } +export type { ChartInteractiveLegendItem } diff --git a/frontend/features/admin/api/index.ts b/frontend/features/admin/api/index.ts index c6bb0a9a..c0779c7c 100644 --- a/frontend/features/admin/api/index.ts +++ b/frontend/features/admin/api/index.ts @@ -8,3 +8,4 @@ export * from "./mcp"; export * from "./permission-groups"; export * from "./reference-data"; export * from "./settings"; +export * from "./statistics"; diff --git a/frontend/features/admin/api/statistics.ts b/frontend/features/admin/api/statistics.ts new file mode 100644 index 00000000..def358a9 --- /dev/null +++ b/frontend/features/admin/api/statistics.ts @@ -0,0 +1,92 @@ +import { authedRequest } from "@/shared/api/authed-client"; + +export type AdminUsageStatisticsRankBy = "cost" | "tokens" | "calls"; +export type AdminUsageStatisticsBillingScope = "all" | "free" | "billable"; +export type AdminUsageStatisticsSection = "all" | "models" | "users"; + +export type AdminUsageStatisticsMetricsDTO = { + recordCount: number; + inputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + outputTokens: number; + reasoningTokens: number; + totalTokens: number; + callCount: number; + avgLatencyMS: number; + billedNanousd: number; + billedUSD: number; +}; + +export type AdminUsageStatisticsTrendDTO = AdminUsageStatisticsMetricsDTO & { + periodStart: string; +}; + +export type AdminUsageStatisticsModelRankDTO = AdminUsageStatisticsMetricsDTO & { + platformModelName: string; + trend: AdminUsageStatisticsTrendDTO[]; +}; + +export type AdminUsageStatisticsUserRankDTO = AdminUsageStatisticsMetricsDTO & { + userID: number; + username: string; + userDisplayName: string; + userLabel: string; + trend: AdminUsageStatisticsTrendDTO[]; +}; + +export type AdminUsageStatisticsData = { + section: AdminUsageStatisticsSection; + range: { + startDate: string; + endDate: string; + granularity: "day" | "month" | string; + }; + totals: AdminUsageStatisticsMetricsDTO; + trend: AdminUsageStatisticsTrendDTO[]; + topModels: AdminUsageStatisticsModelRankDTO[]; + topUsers: AdminUsageStatisticsUserRankDTO[]; +}; + +type AdminUsageStatisticsSubjectOptions = + | { userID?: never; permissionGroupID?: never } + | { userID: number; permissionGroupID?: never } + | { userID?: never; permissionGroupID: number }; + +export type GetAdminUsageStatisticsOptions = { + startDate: string; + endDate: string; + platformModelName?: string; + billingScope?: AdminUsageStatisticsBillingScope; + section?: AdminUsageStatisticsSection; + modelRankBy?: AdminUsageStatisticsRankBy; + userRankBy?: AdminUsageStatisticsRankBy; +} & AdminUsageStatisticsSubjectOptions; + +export async function getAdminUsageStatistics( + accessToken: string, + options: GetAdminUsageStatisticsOptions, +): Promise { + const params = new URLSearchParams({ + start_date: options.startDate, + end_date: options.endDate, + billing_scope: options.billingScope ?? "all", + section: options.section ?? "all", + model_rank_by: options.modelRankBy ?? "cost", + user_rank_by: options.userRankBy ?? "cost", + }); + if (options.userID && options.userID > 0) { + params.set("user_id", String(options.userID)); + } + if (options.permissionGroupID && options.permissionGroupID > 0) { + params.set("permission_group_id", String(options.permissionGroupID)); + } + if (options.platformModelName?.trim()) { + params.set("platform_model_name", options.platformModelName.trim()); + } + return authedRequest( + `/api/v1/admin/usage-statistics?${params.toString()}`, + { accessToken }, + true, + ); +} diff --git a/frontend/features/admin/components/admin-date-range-filter.tsx b/frontend/features/admin/components/admin-date-range-filter.tsx index 7a91576c..ad1f53e0 100644 --- a/frontend/features/admin/components/admin-date-range-filter.tsx +++ b/frontend/features/admin/components/admin-date-range-filter.tsx @@ -1,10 +1,10 @@ "use client"; -import { format } from "date-fns"; +import { differenceInCalendarDays, format } from "date-fns"; import { enUS, zhCN } from "date-fns/locale"; import { CalendarIcon } from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; -import type { DateRange } from "react-day-picker"; +import type { DateRange, Matcher } from "react-day-picker"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; @@ -20,7 +20,9 @@ type AdminDateRangeFilterProps = { onFromChange: (value: string) => void; onToChange: (value: string) => void; disabled?: boolean; + maxRangeDays?: number; placeholder?: string; + triggerClassName?: string; }; function parseDateValue(value: string): Date | undefined { @@ -42,7 +44,9 @@ export function AdminDateRangeFilter({ onFromChange, onToChange, disabled = false, + maxRangeDays, placeholder, + triggerClassName, }: AdminDateRangeFilterProps) { const locale = useLocale(); const t = useTranslations("common.dateRange"); @@ -53,6 +57,10 @@ export function AdminDateRangeFilter({ to: parseDateValue(toValue), } : undefined; + const rangeStart = selectedRange?.from; + const disabledDates: Matcher | undefined = maxRangeDays && rangeStart && !selectedRange?.to + ? (date) => Math.abs(differenceInCalendarDays(date, rangeStart)) >= maxRangeDays + : undefined; const triggerLabel = selectedRange?.from ? selectedRange.to ? `${format(selectedRange.from, "yyyy-MM-dd")} - ${format(selectedRange.to, "yyyy-MM-dd")}` @@ -60,7 +68,7 @@ export function AdminDateRangeFilter({ : (placeholder ?? t("placeholder")); return ( -
+
+ + + {options.map((option) => ( + onChange(option.value)}> + {option.label} + {value === option.value ? : null} + + ))} + + + ); +} diff --git a/frontend/features/admin/components/sections/statistics/admin-statistics-charts.tsx b/frontend/features/admin/components/sections/statistics/admin-statistics-charts.tsx new file mode 100644 index 00000000..f21ae98f --- /dev/null +++ b/frontend/features/admin/components/sections/statistics/admin-statistics-charts.tsx @@ -0,0 +1,663 @@ +"use client"; + +import * as React from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { Area, Bar, BarChart, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from "recharts"; + +import { + ChartContainer, + ChartInteractiveLegend, + ChartTooltip, + type ChartConfig, + type ChartInteractiveLegendItem, +} from "@/components/ui/chart"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { + AdminUsageStatisticsMetricsDTO, + AdminUsageStatisticsModelRankDTO, + AdminUsageStatisticsRankBy, + AdminUsageStatisticsTrendDTO, + AdminUsageStatisticsUserRankDTO, +} from "@/features/admin/api"; +import { + formatBillingDisplayAmountFromUSD, + type BillingDisplayOptions, +} from "@/shared/lib/billing-display"; + +const STACK_COLORS = [ + "#2563eb", + "#06b6d4", + "#f97316", + "#7c3aed", + "#22c55e", + "#eab308", + "#ec4899", + "#4f46e5", + "#14b8a6", + "#ef4444", +]; + +const CHART_ANIMATION_DURATION_MS = 240; + +function displayCost(value: number, billingDisplay: BillingDisplayOptions): string { + return formatBillingDisplayAmountFromUSD(value, billingDisplay, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +function chartMetricValue( + metrics: AdminUsageStatisticsMetricsDTO, + rankBy: AdminUsageStatisticsRankBy, + billingDisplay: BillingDisplayOptions, +): number { + const value = rawMetricValue(metrics, rankBy); + if (rankBy !== "cost") return value; + if (billingDisplay.currency === "CNY" && Number(billingDisplay.usdToCnyRate) > 0) { + return value * Number(billingDisplay.usdToCnyRate); + } + return value; +} + +function rawMetricValue( + metrics: AdminUsageStatisticsMetricsDTO, + rankBy: AdminUsageStatisticsRankBy, +): number { + if (rankBy === "tokens") return metrics.totalTokens; + if (rankBy === "calls") return metrics.callCount; + return metrics.billedUSD; +} + +function compactNumber(value: number, locale: string): string { + if (!Number.isFinite(value) || value === 0) return "0"; + return new Intl.NumberFormat(locale, { + notation: "compact", + maximumFractionDigits: 1, + }).format(value); +} + +function chartAxisValue( + value: number, + rankBy: AdminUsageStatisticsRankBy, + billingDisplay: BillingDisplayOptions, + locale: string, +): string { + if (rankBy !== "cost") return compactNumber(value, locale); + const symbol = billingDisplay.currency === "CNY" && Number(billingDisplay.usdToCnyRate) > 0 ? "¥" : "$"; + return `${symbol}${value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +} + +function formatLatency(value: number, locale: string): string { + if (!Number.isFinite(value) || value <= 0) return "0"; + if (value < 1000) return `${Math.round(value).toLocaleString(locale)}ms`; + return `${(value / 1000).toLocaleString(locale, { maximumFractionDigits: 2 })}s`; +} + +function parsePeriodDate(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value); + if (!match) return null; + const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + return Number.isNaN(date.getTime()) ? null : date; +} + +function periodLabels( + value: string, + granularity: string, + locale: string, + rangeStartDate?: string, + rangeEndDate?: string, +): { short: string; full: string } { + const date = parsePeriodDate(value); + if (!date) return { short: "-", full: "-" }; + if (granularity === "week") { + const selectedStart = rangeStartDate ? parsePeriodDate(rangeStartDate) : null; + const selectedEnd = rangeEndDate ? parsePeriodDate(rangeEndDate) : null; + const weekStart = selectedStart && selectedStart > date ? selectedStart : date; + const weekEnd = new Date(date); + weekEnd.setDate(weekEnd.getDate() + 6); + const visibleWeekEnd = selectedEnd && selectedEnd < weekEnd ? selectedEnd : weekEnd; + const shortFormatter = new Intl.DateTimeFormat(locale, { month: "2-digit", day: "2-digit" }); + const fullFormatter = new Intl.DateTimeFormat(locale, { year: "numeric", month: "2-digit", day: "2-digit" }); + return { + short: shortFormatter.format(weekStart), + full: `${fullFormatter.format(weekStart)} - ${fullFormatter.format(visibleWeekEnd)}`, + }; + } + if (granularity === "month") { + return { + short: new Intl.DateTimeFormat(locale, { month: "short" }).format(date), + full: new Intl.DateTimeFormat(locale, { year: "numeric", month: "long" }).format(date), + }; + } + return { + short: new Intl.DateTimeFormat(locale, { month: "2-digit", day: "2-digit" }).format(date), + full: new Intl.DateTimeFormat(locale, { year: "numeric", month: "2-digit", day: "2-digit" }).format(date), + }; +} + +function StatisticsTooltipContent({ + active, + payload, + label, + billingDisplay, +}: { + active?: boolean; + payload?: Array<{ payload?: { metrics?: AdminUsageStatisticsMetricsDTO; fullLabel?: string } }>; + label?: string; + billingDisplay: BillingDisplayOptions; +}) { + const t = useTranslations("adminStatistics"); + const locale = useLocale(); + const item = payload?.[0]?.payload; + const metrics = item?.metrics; + if (!active || !metrics) return null; + return ( +
+

{item.fullLabel || label}

+
+ + + + +
+
+ ); +} + +function TooltipRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function ChartLoadingSkeleton({ horizontal = false }: { horizontal?: boolean }) { + return ( +
+ {Array.from({ length: 10 }).map((_, index) => ( + + ))} +
+ ); +} + +function useHiddenChartSeries() { + const [hiddenSeries, setHiddenSeries] = React.useState>(() => new Set()); + const toggleSeries = React.useCallback((id: string) => { + setHiddenSeries((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + return { hiddenSeries, toggleSeries }; +} + +export const StatisticsTrendChart = React.memo(function StatisticsTrendChart({ + items, + granularity, + rangeStartDate, + rangeEndDate, + rankBy, + billingDisplay, + loading, +}: { + items: AdminUsageStatisticsTrendDTO[]; + granularity: string; + rangeStartDate?: string; + rangeEndDate?: string; + rankBy: AdminUsageStatisticsRankBy; + billingDisplay: BillingDisplayOptions; + loading: boolean; +}) { + const t = useTranslations("adminStatistics"); + const locale = useLocale(); + const { hiddenSeries, toggleSeries } = useHiddenChartSeries(); + const chartConfig = React.useMemo(() => ({ + metricValue: { + label: t(`rankBy.${rankBy}`), + color: "var(--chart-1)", + }, + avgLatencyMS: { + label: t("metrics.latency"), + color: "var(--chart-2)", + }, + }), [rankBy, t]); + const data = React.useMemo( + () => + items.map((item) => { + const labels = periodLabels(item.periodStart, granularity, locale, rangeStartDate, rangeEndDate); + return { + label: labels.short, + fullLabel: labels.full, + metricValue: chartMetricValue(item, rankBy, billingDisplay), + avgLatencyMS: item.avgLatencyMS, + metrics: item, + }; + }), + [billingDisplay, granularity, items, locale, rangeEndDate, rangeStartDate, rankBy], + ); + const hasData = items.some((item) => item.recordCount > 0 || item.totalTokens > 0 || item.callCount > 0 || item.billedUSD > 0); + const legendItems = React.useMemo(() => [ + { id: "metricValue", label: t(`rankBy.${rankBy}`), color: "var(--chart-1)" }, + { id: "avgLatencyMS", label: t("metrics.latency"), color: "var(--chart-2)" }, + ], [rankBy, t]); + + if (loading) return ; + if (!loading && !hasData) { + return
{t("empty")}
; + } + return ( +
+ + event.preventDefault()} + > + + + + + + + + + chartAxisValue(value, rankBy, billingDisplay, locale)} + /> + formatLatency(value, locale)} + /> + } + /> + + + + + +
+ ); +}); + +type StatisticsStackedSeries = { + id: string; + key: string; + label: string; + fullLabel: string; + color: string; + trend: AdminUsageStatisticsTrendDTO[]; +}; + +type StatisticsStackedSegment = { + id: string; + key: string; + label: string; + fullLabel: string; + color: string; + rawValue: number; + chartValue: number; +}; + +type StatisticsStackedChartPoint = Record & { + label: string; + fullLabel: string; + totalRawValue: number; + segments: StatisticsStackedSegment[]; +}; + +function statisticsPeriodKey(value: string): string { + return value.slice(0, 10); +} + +function formatStackedMetricValue( + value: number, + rankBy: AdminUsageStatisticsRankBy, + billingDisplay: BillingDisplayOptions, + locale: string, +): string { + if (rankBy === "cost") return displayCost(value, billingDisplay); + return new Intl.NumberFormat(locale).format(value); +} + +function StatisticsStackedTooltipContent({ + active, + payload, + rankBy, + billingDisplay, + hiddenSeries, +}: { + active?: boolean; + payload?: Array<{ payload?: StatisticsStackedChartPoint }>; + rankBy: AdminUsageStatisticsRankBy; + billingDisplay: BillingDisplayOptions; + hiddenSeries: ReadonlySet; +}) { + const t = useTranslations("adminStatistics"); + const locale = useLocale(); + const item = payload?.[0]?.payload; + if (!active || !item) return null; + const segments = item.segments.filter((segment) => segment.rawValue > 0 && !hiddenSeries.has(segment.id)); + if (segments.length === 0) return null; + return ( +
+

{item.fullLabel}

+
+ {segments.map((segment) => ( +
+ + + {segment.label} + + + {formatStackedMetricValue(segment.rawValue, rankBy, billingDisplay, locale)} + +
+ ))} +
+
+ total + segment.rawValue, 0), + rankBy, + billingDisplay, + locale, + )} + /> +
+
+ ); +} + +function StatisticsStackedTrendChart({ + periods, + granularity, + rangeStartDate, + rangeEndDate, + series, + rankBy, + billingDisplay, + loading, +}: { + periods: AdminUsageStatisticsTrendDTO[]; + granularity: string; + rangeStartDate?: string; + rangeEndDate?: string; + series: StatisticsStackedSeries[]; + rankBy: AdminUsageStatisticsRankBy; + billingDisplay: BillingDisplayOptions; + loading: boolean; +}) { + const t = useTranslations("adminStatistics"); + const locale = useLocale(); + const { hiddenSeries, toggleSeries } = useHiddenChartSeries(); + const config = React.useMemo( + () => Object.fromEntries(series.map((item) => [item.key, { label: item.label, color: item.color }])), + [series], + ); + const data = React.useMemo( + () => { + const metricsBySeries = series.map((item) => + new Map(item.trend.map((point) => [statisticsPeriodKey(point.periodStart), point])), + ); + const points = periods.map((period) => { + const labels = periodLabels(period.periodStart, granularity, locale, rangeStartDate, rangeEndDate); + const segments = series.map((item, index) => { + const metrics = metricsBySeries[index]?.get(statisticsPeriodKey(period.periodStart)); + return { + id: item.id, + key: item.key, + label: item.label, + fullLabel: item.fullLabel, + color: item.color, + rawValue: metrics ? rawMetricValue(metrics, rankBy) : 0, + chartValue: metrics ? chartMetricValue(metrics, rankBy, billingDisplay) : 0, + }; + }); + const point: StatisticsStackedChartPoint = { + label: labels.short, + fullLabel: labels.full, + totalRawValue: segments.reduce((total, segment) => total + segment.rawValue, 0), + segments, + }; + for (const segment of segments) point[segment.key] = segment.chartValue; + return point; + }); + return points; + }, + [billingDisplay, granularity, locale, periods, rangeEndDate, rangeStartDate, rankBy, series], + ); + const hasData = data.some((point) => point.totalRawValue > 0); + const legendItems = React.useMemo( + () => series.map((item) => ({ id: item.id, label: item.label, title: item.fullLabel, color: item.color })), + [series], + ); + const topVisibleSeriesID = React.useMemo( + () => [...series].reverse().find((item) => !hiddenSeries.has(item.id))?.id, + [hiddenSeries, series], + ); + if (loading) return ; + if (!loading && !hasData) { + return
{t("empty")}
; + } + return ( +
+ + event.preventDefault()} + > + + + chartAxisValue(value, rankBy, billingDisplay, locale)} + /> + + } + /> + {series.map((item) => ( + + ))} + + + +
+ ); +} + +export const StatisticsModelRankingChart = React.memo(function StatisticsModelRankingChart({ + items, + periods, + granularity, + rangeStartDate, + rangeEndDate, + rankBy, + billingDisplay, + loading, +}: { + items: AdminUsageStatisticsModelRankDTO[]; + periods: AdminUsageStatisticsTrendDTO[]; + granularity: string; + rangeStartDate?: string; + rangeEndDate?: string; + rankBy: AdminUsageStatisticsRankBy; + billingDisplay: BillingDisplayOptions; + loading: boolean; +}) { + const t = useTranslations("adminStatistics"); + const series = React.useMemo( + () => + items.map((item, index) => ({ + id: item.platformModelName.trim() || `unknown-model-${index}`, + key: `model_${index}`, + label: item.platformModelName.trim() || t("unknownModel"), + fullLabel: item.platformModelName.trim() || t("unknownModel"), + color: STACK_COLORS[index % STACK_COLORS.length], + trend: item.trend ?? [], + })), + [items, t], + ); + return ( + + ); +}); + +export const StatisticsUserRankingChart = React.memo(function StatisticsUserRankingChart({ + items, + periods, + granularity, + rangeStartDate, + rangeEndDate, + rankBy, + billingDisplay, + loading, +}: { + items: AdminUsageStatisticsUserRankDTO[]; + periods: AdminUsageStatisticsTrendDTO[]; + granularity: string; + rangeStartDate?: string; + rangeEndDate?: string; + rankBy: AdminUsageStatisticsRankBy; + billingDisplay: BillingDisplayOptions; + loading: boolean; +}) { + const t = useTranslations("adminStatistics"); + const series = React.useMemo( + () => + items.map((item, index) => { + const label = item.userLabel.trim() || item.userDisplayName.trim() || item.username.trim() || t("unknownUser", { id: item.userID }); + return { + id: String(item.userID), + key: `user_${index}`, + label, + fullLabel: `${label} (#${item.userID})`, + color: STACK_COLORS[index % STACK_COLORS.length], + trend: item.trend ?? [], + }; + }), + [items, t], + ); + return ( + + ); +}); + +export function formatStatisticsCost(value: number, billingDisplay: BillingDisplayOptions): string { + return displayCost(value, billingDisplay); +} + +export function formatStatisticsCount(value: number, locale: string): string { + return new Intl.NumberFormat(locale).format(value); +} + +export function formatStatisticsLatency(value: number, locale: string): string { + return formatLatency(value, locale); +} diff --git a/frontend/features/admin/components/sections/statistics/admin-statistics-model-filter.tsx b/frontend/features/admin/components/sections/statistics/admin-statistics-model-filter.tsx new file mode 100644 index 00000000..89ccc908 --- /dev/null +++ b/frontend/features/admin/components/sections/statistics/admin-statistics-model-filter.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { Bot, Sparkles } from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, +} from "@/components/ui/combobox"; +import { cn } from "@/lib/utils"; +import type { ModelSelectOption } from "@/shared/components/model-select"; +import { ModelOptionIcon } from "@/shared/components/model-option-icon"; + +export function AdminStatisticsModelFilter({ + value, + fallbackValue, + options, + label, + triggerClassName, + disabled = false, + onChange, +}: { + value: string; + fallbackValue: string; + options: ModelSelectOption[]; + label: string; + triggerClassName?: string; + disabled?: boolean; + onChange: (value: string) => void; +}) { + const t = useTranslations("common.modelSelect"); + const selectedOption = options.find((option) => option.value === value) ?? options.find((option) => option.value === fallbackValue); + + return ( + option.label} + isItemEqualToValue={(option, selected) => option.value === selected.value} + onValueChange={(option) => onChange(option?.value ?? fallbackValue)} + > + + + {label} + + {selectedOption?.label ?? t("empty")} + + + } + /> + + + {t("empty")} + + {(option: ModelSelectOption) => ( + + {option.value === fallbackValue ? ( + + + + ) : ( + + )} + + {option.label} + + + )} + + + + ); +} diff --git a/frontend/features/admin/components/sections/statistics/admin-statistics-subject-filter.tsx b/frontend/features/admin/components/sections/statistics/admin-statistics-subject-filter.tsx new file mode 100644 index 00000000..2a545815 --- /dev/null +++ b/frontend/features/admin/components/sections/statistics/admin-statistics-subject-filter.tsx @@ -0,0 +1,284 @@ +"use client"; + +import * as React from "react"; +import { LoaderCircle, ShieldCheck, UsersRound } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, +} from "@/components/ui/combobox"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { listAdminUsers, type PermissionGroup } from "@/features/admin/api"; +import type { AdminStatisticsSubject } from "@/features/admin/hooks/use-admin-statistics"; +import { resolveAdminErrorMessage } from "@/features/admin/utils/admin-error"; +import { cn } from "@/lib/utils"; +import type { UserDTO } from "@/shared/api/auth.types"; +import { resolveAccessToken } from "@/shared/auth/resolve-access-token"; +import { resolveAvatarImageSrc } from "@/shared/lib/avatar"; + +type SubjectMode = "user" | "permission-group"; + +type SubjectFilterOption = + | { key: "all"; type: "all" } + | { key: string; type: "user"; user: UserDTO } + | { key: string; type: "permission-group"; permissionGroup: PermissionGroup }; + +const ALL_SUBJECT_OPTION: SubjectFilterOption = { key: "all", type: "all" }; + +function userPrimaryLabel(user: UserDTO): string { + return user.displayName.trim() || user.username.trim() || `#${user.id}`; +} + +function userSecondaryLabel(user: UserDTO): string { + const values = [user.username.trim(), user.email.trim(), `#${user.id}`].filter(Boolean); + return values.join(" · "); +} + +function subjectLabel(subject: AdminStatisticsSubject, allUsersLabel: string): string { + if (subject.type === "user") return userPrimaryLabel(subject.user); + if (subject.type === "permission-group") return subject.permissionGroup.name; + return allUsersLabel; +} + +export function AdminStatisticsSubjectFilter({ + value, + permissionGroups, + onChange, + disabled = false, + label, + triggerClassName, +}: { + value: AdminStatisticsSubject; + permissionGroups: PermissionGroup[]; + onChange: (subject: AdminStatisticsSubject) => void; + disabled?: boolean; + label: string; + triggerClassName?: string; +}) { + const t = useTranslations("adminStatistics.filters.subjectSelect"); + const [open, setOpen] = React.useState(false); + const [mode, setMode] = React.useState("user"); + const [query, setQuery] = React.useState(""); + const [debouncedQuery, setDebouncedQuery] = React.useState(""); + const [users, setUsers] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const requestSequenceRef = React.useRef(0); + + React.useEffect(() => { + const timer = window.setTimeout(() => setDebouncedQuery(query.trim()), 250); + return () => window.clearTimeout(timer); + }, [query]); + + React.useEffect(() => { + if (!open || mode !== "user") return; + const requestSequence = requestSequenceRef.current + 1; + requestSequenceRef.current = requestSequence; + setLoading(true); + void (async () => { + try { + const token = await resolveAccessToken(); + if (!token) return; + const result = await listAdminUsers(token, { + page: 1, + pageSize: 20, + query: debouncedQuery, + }); + if (requestSequence === requestSequenceRef.current) { + setUsers(result.results); + } + } catch (error) { + if (requestSequence === requestSequenceRef.current) { + toast.error(t("loadFailed"), { description: resolveAdminErrorMessage(error) }); + } + } finally { + if (requestSequence === requestSequenceRef.current) setLoading(false); + } + })(); + }, [debouncedQuery, mode, open, t]); + + const options = React.useMemo(() => { + if (mode === "permission-group") { + const normalizedQuery = query.trim().toLocaleLowerCase(); + const groupOptions = permissionGroups + .filter((group) => { + if (!normalizedQuery) return true; + return `${group.name} ${group.description}`.toLocaleLowerCase().includes(normalizedQuery); + }) + .map((permissionGroup) => ({ + key: `permission-group:${permissionGroup.id}`, + type: "permission-group" as const, + permissionGroup, + })); + return normalizedQuery ? groupOptions : [ALL_SUBJECT_OPTION, ...groupOptions]; + } + + const userOptions: SubjectFilterOption[] = users.map((user) => ({ + key: `user:${user.id}`, + type: "user", + user, + })); + if (query.trim()) return userOptions; + if (value.type === "user" && !userOptions.some((option) => option.type === "user" && option.user.id === value.user.id)) { + userOptions.unshift({ key: `user:${value.user.id}`, type: "user", user: value.user }); + } + return [ALL_SUBJECT_OPTION, ...userOptions]; + }, [mode, permissionGroups, query, users, value]); + + const selectedOption: SubjectFilterOption = value.type === "user" + ? { key: `user:${value.user.id}`, type: "user", user: value.user } + : value.type === "permission-group" + ? { + key: `permission-group:${value.permissionGroup.id}`, + type: "permission-group", + permissionGroup: value.permissionGroup, + } + : ALL_SUBJECT_OPTION; + + return ( + { + if (option.type === "user") return userSecondaryLabel(option.user); + if (option.type === "permission-group") return option.permissionGroup.name; + return t("allUsers"); + }} + isItemEqualToValue={(option, selected) => option.key === selected.key} + onInputValueChange={setQuery} + onOpenChange={(nextOpen) => { + setOpen(nextOpen); + if (nextOpen) { + setMode(value.type === "permission-group" ? "permission-group" : "user"); + } else { + setQuery(""); + } + }} + onValueChange={(option) => { + if (!option || option.type === "all") { + onChange({ type: "all" }); + } else if (option.type === "user") { + onChange({ type: "user", user: option.user }); + } else { + onChange({ type: "permission-group", permissionGroup: option.permissionGroup }); + } + setQuery(""); + setOpen(false); + }} + > + + + {label} + + {subjectLabel(value, t("allUsers"))} + + + } + /> + + { + setMode(nextMode as SubjectMode); + setQuery(""); + }} + > + + {t("users")} + {t("permissionGroups")} + + + + {mode === "user" && loading ? ( + + ) : null} + + {mode === "user" ? t("emptyUsers") : t("emptyPermissionGroups")} + + {(option: SubjectFilterOption) => { + if (option.type === "user") { + return ( + + + + + {userPrimaryLabel(option.user).charAt(0).toUpperCase()} + + + + {userPrimaryLabel(option.user)} + {option.user.username.trim() ? ( + (@{option.user.username.trim()}) + ) : null} + + + #{option.user.id} + + + ); + } + if (option.type === "permission-group") { + return ( + + + + + + + {option.permissionGroup.name} + + {t("memberCount", { count: option.permissionGroup.userCount })} + + + ); + } + return ( + + + + + + + {t("allUsers")} + + ); + }} + + + + ); +} diff --git a/frontend/features/admin/components/sections/statistics/admin-statistics.tsx b/frontend/features/admin/components/sections/statistics/admin-statistics.tsx new file mode 100644 index 00000000..f427edac --- /dev/null +++ b/frontend/features/admin/components/sections/statistics/admin-statistics.tsx @@ -0,0 +1,389 @@ +"use client"; + +import * as React from "react"; +import { Activity, BadgeDollarSign, Braces, CalendarDays, Check, Funnel, RefreshCw, Timer } from "lucide-react"; +import { useLocale, useTranslations } from "next-intl"; + +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { AdminDateRangeFilter } from "@/features/admin/components/admin-date-range-filter"; +import type { + AdminUsageStatisticsMetricsDTO, + AdminUsageStatisticsRankBy, +} from "@/features/admin/api"; +import { useAdminStatistics } from "@/features/admin/hooks/use-admin-statistics"; +import { cn } from "@/lib/utils"; +import { AdminStatisticsBillingFilter } from "./admin-statistics-billing-filter"; +import { AdminStatisticsModelFilter } from "./admin-statistics-model-filter"; +import { AdminStatisticsSubjectFilter } from "./admin-statistics-subject-filter"; +import { + formatStatisticsCost, + formatStatisticsCount, + formatStatisticsLatency, + StatisticsModelRankingChart, + StatisticsTrendChart, + StatisticsUserRankingChart, +} from "./admin-statistics-charts"; + +const ALL_MODELS_VALUE = "__all_models__"; + +function MetricCard({ + label, + value, + icon, + loading, +}: { + label: string; + value: string; + icon: React.ReactNode; + loading: boolean; +}) { + return ( +
+
+ {icon} + {label} +
+ {loading ? ( + + ) : ( +

{value}

+ )} +
+ ); +} + +function emptyMetrics(): AdminUsageStatisticsMetricsDTO { + return { + recordCount: 0, + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + callCount: 0, + avgLatencyMS: 0, + billedNanousd: 0, + billedUSD: 0, + }; +} + +export function AdminStatisticsPage() { + const t = useTranslations("adminStatistics"); + const tTable = useTranslations("common.table"); + const locale = useLocale(); + const statistics = useAdminStatistics(); + const [dateFilterOpen, setDateFilterOpen] = React.useState(false); + const [trendMetric, setTrendMetric] = React.useState("tokens"); + const totals = statistics.statistics?.totals ?? emptyMetrics(); + const trendItems = statistics.statistics?.trend ?? []; + const granularity = statistics.statistics?.range.granularity ?? "day"; + const chartRangeStartDate = statistics.statistics?.range.startDate ?? statistics.startDate; + const chartRangeEndDate = statistics.statistics?.range.endDate ?? statistics.endDate; + const initialLoading = statistics.loading && !statistics.statistics; + const modelOptions = React.useMemo( + () => [{ label: t("filters.allModels"), value: ALL_MODELS_VALUE }, ...statistics.modelOptions], + [statistics.modelOptions, t], + ); + const rangeErrorText = statistics.rangeError ? t(`filters.rangeErrors.${statistics.rangeError}`) : ""; + const rangeLabel = statistics.rangePreset === "custom" + ? statistics.startDate && statistics.endDate + ? `${statistics.startDate} - ${statistics.endDate}` + : t("filters.custom") + : t(`filters.last${statistics.rangePreset}Days`); + const activeFilterCount = Number(statistics.subject.type !== "all") + Number(Boolean(statistics.platformModelName)) + Number(statistics.billingScope !== "all"); + + return ( +
+
+
+

{t("title")}

+
+ + + + + +
+ {(["7", "30", "90"] as const).map((preset) => ( + + ))} + +
+ {statistics.rangePreset === "custom" ? ( +
+ { + statistics.setEndDate(value); + if (value) setDateFilterOpen(false); + }} + maxRangeDays={366} + disabled={initialLoading} + triggerClassName="h-8 bg-background dark:bg-input/30" + /> +
+ ) : null} +
+
+ + + + + +
+ + statistics.setPlatformModelName(value === ALL_MODELS_VALUE ? "" : value)} + /> + +
+ {activeFilterCount > 0 ? ( +
+ +
+ ) : null} +
+
+ +
+
+ {rangeErrorText ?

{rangeErrorText}

: null} + +
+ } + loading={initialLoading} + /> + } + loading={initialLoading} + /> + } + loading={initialLoading} + /> + } + loading={initialLoading} + /> +
+
+ + + +
+
+

{t("trend.title")}

+ setTrendMetric(value as AdminUsageStatisticsRankBy)} + > + + {(["tokens", "cost", "calls"] as const).map((metric) => ( + + {t(`rankBy.${metric}`)} + + ))} + + +
+
+ +
+
+ + + +
+
+

{t("rankings.models")}

+ statistics.setModelRankingMetric(value as AdminUsageStatisticsRankBy)} + > + + {(["tokens", "cost", "calls"] as const).map((metric) => ( + + {t(`rankBy.${metric}`)} + + ))} + + +
+
+ +
+
+ + + +
+
+

{t("rankings.users")}

+ statistics.setUserRankingMetric(value as AdminUsageStatisticsRankBy)} + > + + {(["tokens", "cost", "calls"] as const).map((metric) => ( + + {t(`rankBy.${metric}`)} + + ))} + + +
+
+ +
+
+
+ ); +} diff --git a/frontend/features/admin/hooks/use-admin-statistics.ts b/frontend/features/admin/hooks/use-admin-statistics.ts new file mode 100644 index 00000000..b42d019d --- /dev/null +++ b/frontend/features/admin/hooks/use-admin-statistics.ts @@ -0,0 +1,330 @@ +import * as React from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; + +import { + getAdminBillingConfig, + getAdminUsageStatistics, + listAdminLLMModels, + listPermissionGroups, + type AdminUsageStatisticsBillingScope, + type AdminUsageStatisticsData, + type AdminUsageStatisticsModelRankDTO, + type AdminUsageStatisticsRankBy, + type AdminUsageStatisticsSection, + type AdminUsageStatisticsUserRankDTO, + type PermissionGroup, +} from "@/features/admin/api"; +import { listAllAdminPages } from "@/features/admin/api/shared"; +import { resolveAdminErrorMessage } from "@/features/admin/utils/admin-error"; +import type { ModelSelectOption } from "@/shared/components/model-select"; +import { resolveAccessToken } from "@/shared/auth/resolve-access-token"; +import { + normalizeBillingDisplayCurrency, + type BillingDisplayOptions, +} from "@/shared/lib/billing-display"; +import { resolveModelOptionIconUrl } from "@/shared/lib/model-option-display"; +import type { UserDTO } from "@/shared/api/auth.types"; + +export type AdminStatisticsRangePreset = "7" | "30" | "90" | "custom"; +export type AdminStatisticsRangeError = "incomplete" | "invalid" | "tooLong" | null; +export type AdminStatisticsSubject = + | { type: "all" } + | { type: "user"; user: UserDTO } + | { type: "permission-group"; permissionGroup: PermissionGroup }; + +function formatLocalDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function recentDateRange(days: number): { startDate: string; endDate: string } { + const end = new Date(); + const start = new Date(end); + start.setDate(start.getDate() - Math.max(0, days - 1)); + return { startDate: formatLocalDate(start), endDate: formatLocalDate(end) }; +} + +function parseDateValue(value: string): number | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const timestamp = Date.UTC(year, month - 1, day); + const date = new Date(timestamp); + if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) { + return null; + } + return timestamp; +} + +function validateDateRange(startDate: string, endDate: string): AdminStatisticsRangeError { + if (!startDate.trim() || !endDate.trim()) return "incomplete"; + const start = parseDateValue(startDate); + const end = parseDateValue(endDate); + if (start === null || end === null || end < start) return "invalid"; + const days = Math.floor((end - start) / 86_400_000) + 1; + return days > 366 ? "tooLong" : null; +} + +export function useAdminStatistics() { + const t = useTranslations("adminStatistics"); + const initialRangeRef = React.useRef(recentDateRange(30)); + const [startDate, setStartDateState] = React.useState(initialRangeRef.current.startDate); + const [endDate, setEndDateState] = React.useState(initialRangeRef.current.endDate); + const [rangePreset, setRangePresetState] = React.useState("30"); + const [subject, setSubject] = React.useState({ type: "all" }); + const [platformModelName, setPlatformModelName] = React.useState(""); + const [billingScope, setBillingScope] = React.useState("all"); + const [modelRankingMetric, setModelRankingMetric] = React.useState("cost"); + const [userRankingMetric, setUserRankingMetric] = React.useState("cost"); + const [appliedModelRankingMetric, setAppliedModelRankingMetric] = React.useState("cost"); + const [appliedUserRankingMetric, setAppliedUserRankingMetric] = React.useState("cost"); + const [statistics, setStatistics] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [reloadKey, setReloadKey] = React.useState(0); + const [modelOptions, setModelOptions] = React.useState([]); + const [permissionGroups, setPermissionGroups] = React.useState([]); + const [referenceLoading, setReferenceLoading] = React.useState(true); + const [billingDisplay, setBillingDisplay] = React.useState({ + currency: "USD", + usdToCnyRate: null, + }); + const requestSequenceRef = React.useRef(0); + const loadedFilterKeyRef = React.useRef(""); + const appliedModelRankingMetricRef = React.useRef("cost"); + const appliedUserRankingMetricRef = React.useRef("cost"); + const modelRankingCacheRef = React.useRef(new Map()); + const userRankingCacheRef = React.useRef(new Map()); + const rangeError = React.useMemo( + () => validateDateRange(startDate, endDate), + [endDate, startDate], + ); + const statisticsFilterKey = React.useMemo( + () => JSON.stringify([ + startDate, + endDate, + platformModelName, + billingScope, + reloadKey, + subject.type, + subject.type === "user" + ? subject.user.id + : subject.type === "permission-group" + ? subject.permissionGroup.id + : 0, + ]), + [billingScope, endDate, platformModelName, reloadKey, startDate, subject], + ); + + React.useEffect(() => { + let cancelled = false; + setReferenceLoading(true); + void (async () => { + try { + const token = await resolveAccessToken(); + if (!token) return; + const [configResult, modelsResult, permissionGroupsResult] = await Promise.allSettled([ + getAdminBillingConfig(token), + listAllAdminPages((options) => listAdminLLMModels(token, { ...options, onlyActive: false })), + listPermissionGroups(token), + ]); + if (cancelled) return; + if (configResult.status === "fulfilled") { + setBillingDisplay({ + currency: normalizeBillingDisplayCurrency(configResult.value.config.displayCurrency), + usdToCnyRate: configResult.value.config.usdToCNYRate ?? null, + }); + } + if (modelsResult.status === "fulfilled") { + const nextModelOptions = modelsResult.value + .map((model) => ({ + label: model.platformModelName.trim(), + value: model.platformModelName.trim(), + iconUrl: resolveModelOptionIconUrl({ + platformModelName: model.platformModelName, + vendor: model.vendor ?? "", + icon: model.icon ?? "", + }), + })) + .filter((model) => model.value); + setModelOptions( + [...new Map(nextModelOptions.map((model) => [model.value, model])).values()].sort((left, right) => + left.label.localeCompare(right.label), + ), + ); + } + if (permissionGroupsResult.status === "fulfilled") { + setPermissionGroups(permissionGroupsResult.value); + } + const rejectedResult = [configResult, modelsResult, permissionGroupsResult].find((result) => result.status === "rejected"); + if (rejectedResult?.status === "rejected") { + toast.error(t("toasts.referenceLoadFailed"), { description: resolveAdminErrorMessage(rejectedResult.reason) }); + } + } catch (error) { + if (!cancelled) { + toast.error(t("toasts.referenceLoadFailed"), { description: resolveAdminErrorMessage(error) }); + } + } finally { + if (!cancelled) setReferenceLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [t]); + + React.useEffect(() => { + if (rangeError) { + requestSequenceRef.current += 1; + setLoading(false); + return; + } + const filterChanged = loadedFilterKeyRef.current !== statisticsFilterKey; + const modelRankingChanged = appliedModelRankingMetricRef.current !== modelRankingMetric; + const userRankingChanged = appliedUserRankingMetricRef.current !== userRankingMetric; + if (!filterChanged && !modelRankingChanged && !userRankingChanged) { + setLoading(false); + return; + } + let section: AdminUsageStatisticsSection = "all"; + if (!filterChanged && modelRankingChanged && !userRankingChanged) { + section = "models"; + } else if (!filterChanged && userRankingChanged && !modelRankingChanged) { + section = "users"; + } + + if (filterChanged) { + modelRankingCacheRef.current.clear(); + userRankingCacheRef.current.clear(); + } else if (section === "models") { + const cachedModels = modelRankingCacheRef.current.get(modelRankingMetric); + if (cachedModels) { + requestSequenceRef.current += 1; + setStatistics((current) => current ? { ...current, topModels: cachedModels } : current); + appliedModelRankingMetricRef.current = modelRankingMetric; + setAppliedModelRankingMetric(modelRankingMetric); + setLoading(false); + return; + } + } else if (section === "users") { + const cachedUsers = userRankingCacheRef.current.get(userRankingMetric); + if (cachedUsers) { + requestSequenceRef.current += 1; + setStatistics((current) => current ? { ...current, topUsers: cachedUsers } : current); + appliedUserRankingMetricRef.current = userRankingMetric; + setAppliedUserRankingMetric(userRankingMetric); + setLoading(false); + return; + } + } + + const requestSequence = requestSequenceRef.current + 1; + requestSequenceRef.current = requestSequence; + setLoading(true); + void (async () => { + try { + const token = await resolveAccessToken(); + if (!token) { + toast.error(t("toasts.sessionExpired"), { description: t("toasts.signInAgain") }); + return; + } + const data = await getAdminUsageStatistics(token, { + startDate, + endDate, + platformModelName, + billingScope, + section, + modelRankBy: modelRankingMetric, + userRankBy: userRankingMetric, + ...(subject.type === "user" + ? { userID: subject.user.id } + : subject.type === "permission-group" + ? { permissionGroupID: subject.permissionGroup.id } + : {}), + }); + if (requestSequence === requestSequenceRef.current) { + setStatistics((current) => section === "models" && current + ? { ...current, topModels: data.topModels } + : section === "users" && current + ? { ...current, topUsers: data.topUsers } + : data); + if (section !== "users") { + modelRankingCacheRef.current.set(modelRankingMetric, data.topModels); + appliedModelRankingMetricRef.current = modelRankingMetric; + setAppliedModelRankingMetric(modelRankingMetric); + } + if (section !== "models") { + userRankingCacheRef.current.set(userRankingMetric, data.topUsers); + appliedUserRankingMetricRef.current = userRankingMetric; + setAppliedUserRankingMetric(userRankingMetric); + } + loadedFilterKeyRef.current = statisticsFilterKey; + } + } catch (error) { + if (requestSequence === requestSequenceRef.current) { + const rollbackModelMetric = modelRankingMetric !== appliedModelRankingMetricRef.current; + const rollbackUserMetric = userRankingMetric !== appliedUserRankingMetricRef.current; + if (rollbackModelMetric || rollbackUserMetric) { + if (rollbackModelMetric) setModelRankingMetric(appliedModelRankingMetricRef.current); + if (rollbackUserMetric) setUserRankingMetric(appliedUserRankingMetricRef.current); + } + toast.error(t("toasts.loadFailed"), { description: resolveAdminErrorMessage(error) }); + } + } finally { + if (requestSequence === requestSequenceRef.current) { + setLoading(false); + } + } + })(); + }, [billingScope, endDate, modelRankingMetric, platformModelName, rangeError, startDate, statisticsFilterKey, subject, t, userRankingMetric]); + + const setRangePreset = React.useCallback((preset: AdminStatisticsRangePreset) => { + setRangePresetState(preset); + if (preset === "custom") return; + const range = recentDateRange(Number(preset)); + setStartDateState(range.startDate); + setEndDateState(range.endDate); + }, []); + const setStartDate = React.useCallback((value: string) => { + setRangePresetState("custom"); + setStartDateState(value); + }, []); + const setEndDate = React.useCallback((value: string) => { + setRangePresetState("custom"); + setEndDateState(value); + }, []); + const refresh = React.useCallback(() => setReloadKey((current) => current + 1), []); + + return { + statistics, + loading, + referenceLoading, + billingDisplay, + modelOptions, + permissionGroups, + startDate, + endDate, + rangePreset, + rangeError, + subject, + platformModelName, + billingScope, + modelRankingMetric, + userRankingMetric, + appliedModelRankingMetric, + appliedUserRankingMetric, + setStartDate, + setEndDate, + setRangePreset, + setSubject, + setPlatformModelName, + setBillingScope, + setModelRankingMetric, + setUserRankingMetric, + refresh, + }; +} diff --git a/frontend/features/admin/model/admin-sections.ts b/frontend/features/admin/model/admin-sections.ts index f1d80afb..dd8a1457 100644 --- a/frontend/features/admin/model/admin-sections.ts +++ b/frontend/features/admin/model/admin-sections.ts @@ -1,4 +1,5 @@ export const ADMIN_SECTIONS = [ + { id: "statistics", label: "Statistics", href: "/statistics" }, { id: "accounts", label: "Accounts", href: "/users" }, { id: "groups", label: "Permission Groups", href: "/groups" }, { id: "upstreams", label: "Upstreams", href: "/upstreams" }, @@ -19,5 +20,5 @@ export function resolveAdminSection(section?: string | null): AdminSection { if (ADMIN_SECTIONS.some((item) => item.id === section)) { return section as AdminSection; } - return "accounts"; + return "statistics"; } diff --git a/frontend/features/settings/components/sections/subscription/subscription-trend.tsx b/frontend/features/settings/components/sections/subscription/subscription-trend.tsx index 8e9f2fac..53503f17 100644 --- a/frontend/features/settings/components/sections/subscription/subscription-trend.tsx +++ b/frontend/features/settings/components/sections/subscription/subscription-trend.tsx @@ -1,13 +1,14 @@ "use client"; import * as React from "react"; -import { Bar, BarChart, CartesianGrid, Rectangle, XAxis, YAxis } from "recharts"; -import type { BarShapeProps, RectangleProps } from "recharts"; +import { Activity, BadgeDollarSign, Braces, Timer } from "lucide-react"; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { useTranslations } from "next-intl"; -import { ChartContainer, ChartTooltip } from "@/components/ui/chart"; -import type { ChartConfig } from "@/components/ui/chart"; +import { ChartContainer, ChartInteractiveLegend, ChartTooltip } from "@/components/ui/chart"; +import type { ChartConfig, ChartInteractiveLegendItem } from "@/components/ui/chart"; import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useAppLocale } from "@/i18n/app-i18n-provider"; import type { BillingUsageDailyDTO, BillingUsageMonthlyDTO } from "@/shared/api/billing.types"; import { @@ -24,6 +25,11 @@ import { } from "@/features/settings/model/subscription-format"; import type { BillingDisplayOptions } from "@/shared/lib/billing-display"; +type DailyUsageChartModel = BillingUsageDailyDTO["models"][number] & { + color?: string; + modelLabel?: string; +}; + type DailyUsageChartPoint = { dayLabel: string; fullDayLabel: string; @@ -32,8 +38,8 @@ type DailyUsageChartPoint = { callCount: number; recordCount: number; avgLatencyMS: number; - models: Array; - [key: string]: string | number | Array; + models: DailyUsageChartModel[]; + [key: string]: string | number | DailyUsageChartModel[]; }; type MonthlyUsageChartPoint = { @@ -69,13 +75,44 @@ const usageTokenChartConfig = { }, } satisfies ChartConfig; -const STACK_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"]; +const STACK_COLORS = [ + "#2563eb", + "#06b6d4", + "#f97316", + "#7c3aed", + "#22c55e", + "#eab308", + "#ec4899", + "#4f46e5", + "#14b8a6", + "#ef4444", +]; +const CHART_ANIMATION_DURATION_MS = 240; +const MAX_DAILY_MODEL_SERIES = 10; +const OTHER_MODEL_KEY = "__other_models__"; +const OTHER_MODEL_COLOR = "#64748b"; + +function useHiddenUsageSeries() { + const [hiddenSeries, setHiddenSeries] = React.useState>(() => new Set()); + const toggleSeries = React.useCallback((id: string) => { + setHiddenSeries((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + return { hiddenSeries, toggleSeries }; +} -function MetricTile({ label, value }: { label: string; value: string }) { +function MetricTile({ label, value, icon }: { label: string; value: string; icon: React.ReactNode }) { return ( -
-

{label}

-

{value}

+
+
+ {icon} + {label} +
+

{value}

); } @@ -84,10 +121,26 @@ function UsageTrendMetricTiles({ stats, billingDisplay }: { stats: UsageTrendSta const t = useTranslations("settings.subscriptionPage.usageTrend.metrics"); return (
- - - - + } + /> + } + /> + } + /> + } + />
); } @@ -136,46 +189,121 @@ function calculateMonthlyTrendStats(items: BillingUsageMonthlyDTO[]): UsageTrend }; } +function aggregateDailyModels(models: BillingUsageDailyDTO["models"], modelLabel: string): DailyUsageChartModel { + const totals = models.reduce( + (result, model) => ({ + recordCount: result.recordCount + model.recordCount, + inputTokens: result.inputTokens + model.inputTokens, + cacheReadTokens: result.cacheReadTokens + model.cacheReadTokens, + cacheWriteTokens: result.cacheWriteTokens + model.cacheWriteTokens, + outputTokens: result.outputTokens + model.outputTokens, + reasoningTokens: result.reasoningTokens + model.reasoningTokens, + totalTokens: result.totalTokens + model.totalTokens, + callCount: result.callCount + model.callCount, + durationSeconds: result.durationSeconds + model.durationSeconds, + latency: result.latency + model.avgLatencyMS * model.recordCount, + billedNanousd: result.billedNanousd + model.billedNanousd, + billedUSD: result.billedUSD + model.billedUSD, + }), + { + recordCount: 0, + inputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + callCount: 0, + durationSeconds: 0, + latency: 0, + billedNanousd: 0, + billedUSD: 0, + }, + ); + return { + platformModelName: OTHER_MODEL_KEY, + recordCount: totals.recordCount, + inputTokens: totals.inputTokens, + cacheReadTokens: totals.cacheReadTokens, + cacheWriteTokens: totals.cacheWriteTokens, + outputTokens: totals.outputTokens, + reasoningTokens: totals.reasoningTokens, + totalTokens: totals.totalTokens, + callCount: totals.callCount, + durationSeconds: totals.durationSeconds, + avgLatencyMS: totals.recordCount > 0 ? totals.latency / totals.recordCount : 0, + billedNanousd: totals.billedNanousd, + billedUSD: totals.billedUSD, + modelLabel, + }; +} + function DailyUsageChartTooltip({ active, payload, billingDisplay, + hiddenSeries, }: { active?: boolean; payload?: Array<{ payload?: DailyUsageChartPoint; }>; billingDisplay: BillingDisplayOptions; + hiddenSeries: ReadonlySet; }) { const t = useTranslations("settings.subscriptionPage.usageTrend.tooltip"); const item = payload?.[0]?.payload; if (!active || !item) { return null; } + const visibleModels = item.models.filter((model) => !hiddenSeries.has(model.platformModelName || "-")); + const visibleMetrics = item.models.length > 0 + ? visibleModels.reduce( + (totals, model) => ({ + tokens: totals.tokens + model.totalTokens, + billedUsd: totals.billedUsd + model.billedUSD, + calls: totals.calls + model.callCount, + latency: totals.latency + model.avgLatencyMS * model.recordCount, + records: totals.records + model.recordCount, + }), + { tokens: 0, billedUsd: 0, calls: 0, latency: 0, records: 0 }, + ) + : { + tokens: item.totalTokens, + billedUsd: item.billedUsd, + calls: item.callCount, + latency: item.avgLatencyMS * item.recordCount, + records: item.recordCount, + }; + const visibleLatency = visibleMetrics.records > 0 ? visibleMetrics.latency / visibleMetrics.records : 0; return (

{item.fullDayLabel}

+
+ Tokens + {formatTokenCount(visibleMetrics.tokens)} +
{t("cost")} - {formatUsageSummaryCost(item.billedUsd, billingDisplay)} + {formatUsageSummaryCost(visibleMetrics.billedUsd, billingDisplay)}
{t("calls")} - {item.callCount.toLocaleString("en-US")} + {visibleMetrics.calls.toLocaleString("en-US")}
- Tokens - {formatTokenCount(item.totalTokens)} + {t("latency")} + {formatUsageTrendLatency(visibleLatency)}
- {item.models.length > 0 ? ( + {visibleModels.length > 0 ? (
- {item.models.slice(0, 6).map((model) => ( + {visibleModels.slice(0, 10).map((model) => (
- {modelDisplayLabel(model)} + {model.modelLabel ?? modelDisplayLabel(model)} {formatTokenCount(model.totalTokens)}
@@ -187,31 +315,6 @@ function DailyUsageChartTooltip({ ); } -function isTopStackSegment(point: DailyUsageChartPoint, modelSeries: ModelSeries[], modelIndex: number): boolean { - const current = Number(point[modelSeries[modelIndex]?.key] ?? 0); - if (current <= 0) return false; - for (let index = modelIndex + 1; index < modelSeries.length; index += 1) { - if (Number(point[modelSeries[index].key] ?? 0) > 0) { - return false; - } - } - return true; -} - -function DailyUsageStackBarShape({ - modelIndex, - modelSeries, - props, -}: { - modelIndex: number; - modelSeries: ModelSeries[]; - props: BarShapeProps; -}) { - const point: DailyUsageChartPoint | undefined = props.payload; - const radius: RectangleProps["radius"] = point && isTopStackSegment(point, modelSeries, modelIndex) ? [4, 4, 0, 0] : 0; - return ; -} - function DailyUsageChart({ items, loading, @@ -223,6 +326,13 @@ function DailyUsageChart({ }) { const t = useTranslations("settings.subscriptionPage.usageTrend"); const { locale } = useAppLocale(); + const { hiddenSeries, toggleSeries } = useHiddenUsageSeries(); + const otherModelLabel = t("otherModels"); + const [todayEndMS] = React.useState(() => { + const today = new Date(); + today.setHours(23, 59, 59, 999); + return today.getTime(); + }); const modelSeries = React.useMemo(() => { const totals = new Map(); for (const item of items) { @@ -236,22 +346,40 @@ function DailyUsageChart({ }); } } - return Array.from(totals.entries()) - .sort((left, right) => right[1].totalTokens - left[1].totalTokens || left[0].localeCompare(right[0])) + const orderedModels = Array.from(totals.entries()) + .sort((left, right) => right[1].totalTokens - left[1].totalTokens || left[0].localeCompare(right[0])); + const topModels = orderedModels.slice(0, MAX_DAILY_MODEL_SERIES) .map(([platformModelName, summary], index) => ({ key: `model_${index}`, platformModelName, modelLabel: summary.label, - color: STACK_COLORS[index % STACK_COLORS.length], + color: STACK_COLORS[index], })); - }, [items]); + if (orderedModels.length > MAX_DAILY_MODEL_SERIES) { + topModels.push({ + key: "model_other", + platformModelName: OTHER_MODEL_KEY, + modelLabel: otherModelLabel, + color: OTHER_MODEL_COLOR, + }); + } + return topModels; + }, [items, otherModelLabel]); + const topModelNames = React.useMemo( + () => new Set(modelSeries.filter((item) => item.platformModelName !== OTHER_MODEL_KEY).map((item) => item.platformModelName)), + [modelSeries], + ); const modelKeyByName = React.useMemo(() => new Map(modelSeries.map((item) => [item.platformModelName, item.key])), [modelSeries]); const modelColorByName = React.useMemo(() => new Map(modelSeries.map((item) => [item.platformModelName, item.color])), [modelSeries]); const chartData = React.useMemo( () => [...items] + .filter((item) => new Date(item.usageDate).getTime() <= todayEndMS) .sort((left, right) => new Date(left.usageDate).getTime() - new Date(right.usageDate).getTime()) .map((item) => { + const models: DailyUsageChartModel[] = (item.models ?? []).filter((model) => topModelNames.has(model.platformModelName || "-")); + const otherModels = (item.models ?? []).filter((model) => !topModelNames.has(model.platformModelName || "-")); + if (otherModels.length > 0) models.push(aggregateDailyModels(otherModels, otherModelLabel)); const point: DailyUsageChartPoint = { dayLabel: formatDay(item.usageDate), fullDayLabel: formatShortDate(item.usageDate, locale), @@ -260,12 +388,13 @@ function DailyUsageChart({ callCount: item.callCount, recordCount: item.recordCount, avgLatencyMS: item.avgLatencyMS, - models: (item.models ?? []).map((model) => ({ + models: models.map((model) => ({ ...model, color: modelColorByName.get(model.platformModelName || "-"), + modelLabel: model.modelLabel, })), }; - for (const model of item.models ?? []) { + for (const model of models) { const key = modelKeyByName.get(model.platformModelName || "-"); if (key) { point[key] = model.totalTokens; @@ -273,7 +402,7 @@ function DailyUsageChart({ } return point; }), - [items, locale, modelColorByName, modelKeyByName], + [items, locale, modelColorByName, modelKeyByName, otherModelLabel, todayEndMS, topModelNames], ); const chartConfig = React.useMemo(() => { if (modelSeries.length === 0) return usageTokenChartConfig; @@ -281,6 +410,20 @@ function DailyUsageChart({ }, [modelSeries]); const rangeLabel = chartData.length > 0 ? `${chartData[0].fullDayLabel} - ${chartData[chartData.length - 1].fullDayLabel}` : ""; const hasUsageData = chartData.some((item) => item.billedUsd > 0 || item.totalTokens > 0 || item.callCount > 0 || item.recordCount > 0); + const legendItems = React.useMemo( + () => modelSeries.length > 0 + ? modelSeries.map((item) => ({ + id: item.platformModelName, + label: item.modelLabel, + color: item.color, + })) + : [{ id: "totalTokens", label: "Tokens", color: "var(--chart-1)" }], + [modelSeries], + ); + const topVisibleModelName = React.useMemo( + () => [...modelSeries].reverse().find((item) => !hiddenSeries.has(item.platformModelName))?.platformModelName, + [hiddenSeries, modelSeries], + ); return (
@@ -291,36 +434,60 @@ function DailyUsageChart({ {loading ? : null} {!loading && !hasUsageData ?
{t("empty")}
: null} {!loading && hasUsageData ? ( - - - - - formatUsageAxisTokens(value)} - /> - } /> - {modelSeries.length > 0 ? ( - modelSeries.map((model, modelIndex) => ( +
+ + + + + formatUsageAxisTokens(value)} + /> + } + /> + {modelSeries.length > 0 ? ( + modelSeries.map((model) => ( + + )) + ) : ( ( - - )} + hide={hiddenSeries.has("totalTokens")} + isAnimationActive + animationDuration={CHART_ANIMATION_DURATION_MS} + animationEasing="ease-out" /> - )) - ) : ( - - )} - - + )} + + + +
) : null}
); @@ -361,6 +528,10 @@ function MonthlyUsageChartTooltip({

{item.fullMonthLabel}

+
+ Tokens + {formatTokenCount(item.totalTokens)} +
{t("cost")} {formatUsageSummaryCost(item.billedUsd, billingDisplay)} @@ -370,8 +541,8 @@ function MonthlyUsageChartTooltip({ {item.callCount.toLocaleString("en-US")}
- Tokens - {formatTokenCount(item.totalTokens)} + {t("latency")} + {formatUsageTrendLatency(item.avgLatencyMS)}
@@ -389,6 +560,7 @@ function MonthlyUsageChart({ }) { const t = useTranslations("settings.subscriptionPage.usageTrend"); const { locale } = useAppLocale(); + const { hiddenSeries, toggleSeries } = useHiddenUsageSeries(); const chartData = React.useMemo( () => [...items] @@ -406,6 +578,10 @@ function MonthlyUsageChart({ ); const rangeLabel = chartData.length > 0 ? `${chartData[0].fullMonthLabel} - ${chartData[chartData.length - 1].fullMonthLabel}` : ""; const hasUsageData = chartData.some((item) => item.billedUsd > 0 || item.totalTokens > 0 || item.callCount > 0 || item.recordCount > 0); + const legendItems = React.useMemo( + () => [{ id: "totalTokens", label: "Tokens", color: "var(--chart-1)" }], + [], + ); return (
@@ -416,15 +592,34 @@ function MonthlyUsageChart({ {loading ? : null} {!loading && !hasUsageData ?
{t("empty")}
: null} {!loading && hasUsageData ? ( - - - - - formatUsageAxisTokens(value)} /> - } /> - - - +
+ + + + + formatUsageAxisTokens(value)} /> + } /> + + + + +
) : null}
); @@ -455,22 +650,12 @@ export function SubscriptionTrend({

{view === "daily" ? t("usageTrend.dailyTitle") : t("usageTrend.monthlyTitle")}

-
- - -
+ onViewChange(value as UsageTrendView)}> + + {t("usageTrend.daily")} + {t("usageTrend.monthly")} + +
{view === "daily" ? ( diff --git a/frontend/i18n/messages.ts b/frontend/i18n/messages.ts index a5fe10d3..9493daf1 100644 --- a/frontend/i18n/messages.ts +++ b/frontend/i18n/messages.ts @@ -7,6 +7,7 @@ import enAdminLogin from "@/i18n/messages/en-US/admin-login.json"; import enAdminLogs from "@/i18n/messages/en-US/admin-logs.json"; import enAdminModels from "@/i18n/messages/en-US/admin-models.json"; import enAdminPrompts from "@/i18n/messages/en-US/admin-prompts.json"; +import enAdminStatistics from "@/i18n/messages/en-US/admin-statistics.json"; import enAdminTools from "@/i18n/messages/en-US/admin-tools.json"; import enAdminUpstreams from "@/i18n/messages/en-US/admin-upstreams.json"; import enAdminUsers from "@/i18n/messages/en-US/admin-users.json"; @@ -47,6 +48,7 @@ const ENGLISH_MESSAGES = { adminLogs: enAdminLogs, adminModels: enAdminModels, adminPrompts: enAdminPrompts, + adminStatistics: enAdminStatistics, adminTools: enAdminTools, adminUpstreams: enAdminUpstreams, adminUsers: enAdminUsers, @@ -128,6 +130,7 @@ export async function loadLocaleMessages(locale: AppLocale): Promise