diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index 81aa78e1..3f3238dd 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -163,7 +163,7 @@ func (h *UsageHandler) List(c *gin.Context) { return } - out := make([]dto.UsageLog, 0, len(records)) + out := make([]dto.AdminUsageLog, 0, len(records)) for i := range records { out = append(out, *dto.UsageLogFromServiceAdmin(&records[i])) } diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 66b86ea0..202b6869 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -302,14 +302,9 @@ func AccountSummaryFromService(a *service.Account) *AccountSummary { } } -// usageLogFromServiceBase is a helper that converts service UsageLog to DTO. -// The account parameter allows caller to control what Account info is included. -// The includeIPAddress parameter controls whether to include the IP address (admin-only). -func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, includeIPAddress bool) *UsageLog { - if l == nil { - return nil - } - result := &UsageLog{ +func usageLogFromServiceUser(l *service.UsageLog) UsageLog { + // 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、ip_address、account)。 + return UsageLog{ ID: l.ID, UserID: l.UserID, APIKeyID: l.APIKeyID, @@ -331,7 +326,6 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu TotalCost: l.TotalCost, ActualCost: l.ActualCost, RateMultiplier: l.RateMultiplier, - AccountRateMultiplier: l.AccountRateMultiplier, BillingType: l.BillingType, Stream: l.Stream, DurationMs: l.DurationMs, @@ -342,30 +336,33 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu CreatedAt: l.CreatedAt, User: UserFromServiceShallow(l.User), APIKey: APIKeyFromService(l.APIKey), - Account: account, Group: GroupFromServiceShallow(l.Group), Subscription: UserSubscriptionFromService(l.Subscription), } - // IP 地址仅对管理员可见 - if includeIPAddress { - result.IPAddress = l.IPAddress - } - return result } // UsageLogFromService converts a service UsageLog to DTO for regular users. // It excludes Account details and IP address - users should not see these. func UsageLogFromService(l *service.UsageLog) *UsageLog { - return usageLogFromServiceBase(l, nil, false) + if l == nil { + return nil + } + u := usageLogFromServiceUser(l) + return &u } // UsageLogFromServiceAdmin converts a service UsageLog to DTO for admin users. // It includes minimal Account info (ID, Name only) and IP address. -func UsageLogFromServiceAdmin(l *service.UsageLog) *UsageLog { +func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog { if l == nil { return nil } - return usageLogFromServiceBase(l, AccountSummaryFromService(l.Account), true) + return &AdminUsageLog{ + UsageLog: usageLogFromServiceUser(l), + AccountRateMultiplier: l.AccountRateMultiplier, + IPAddress: l.IPAddress, + Account: AccountSummaryFromService(l.Account), + } } func UsageCleanupTaskFromService(task *service.UsageCleanupTask) *UsageCleanupTask { diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 4247dcbf..e0dd50ca 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -190,6 +190,7 @@ type RedeemCode struct { Group *Group `json:"group,omitempty"` } +// UsageLog 是普通用户接口使用的 usage log DTO(不包含管理员字段)。 type UsageLog struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` @@ -209,14 +210,13 @@ type UsageLog struct { CacheCreation5mTokens int `json:"cache_creation_5m_tokens"` CacheCreation1hTokens int `json:"cache_creation_1h_tokens"` - InputCost float64 `json:"input_cost"` - OutputCost float64 `json:"output_cost"` - CacheCreationCost float64 `json:"cache_creation_cost"` - CacheReadCost float64 `json:"cache_read_cost"` - TotalCost float64 `json:"total_cost"` - ActualCost float64 `json:"actual_cost"` - RateMultiplier float64 `json:"rate_multiplier"` - AccountRateMultiplier *float64 `json:"account_rate_multiplier"` + InputCost float64 `json:"input_cost"` + OutputCost float64 `json:"output_cost"` + CacheCreationCost float64 `json:"cache_creation_cost"` + CacheReadCost float64 `json:"cache_read_cost"` + TotalCost float64 `json:"total_cost"` + ActualCost float64 `json:"actual_cost"` + RateMultiplier float64 `json:"rate_multiplier"` BillingType int8 `json:"billing_type"` Stream bool `json:"stream"` @@ -230,18 +230,28 @@ type UsageLog struct { // User-Agent UserAgent *string `json:"user_agent"` - // IP 地址(仅管理员可见) - IPAddress *string `json:"ip_address,omitempty"` - CreatedAt time.Time `json:"created_at"` User *User `json:"user,omitempty"` APIKey *APIKey `json:"api_key,omitempty"` - Account *AccountSummary `json:"account,omitempty"` // Use minimal AccountSummary to prevent data leakage Group *Group `json:"group,omitempty"` Subscription *UserSubscription `json:"subscription,omitempty"` } +// AdminUsageLog 是管理员接口使用的 usage log DTO(包含管理员字段)。 +type AdminUsageLog struct { + UsageLog + + // AccountRateMultiplier 账号计费倍率快照(nil 表示按 1.0 处理) + AccountRateMultiplier *float64 `json:"account_rate_multiplier"` + + // IPAddress 用户请求 IP(仅管理员可见) + IPAddress *string `json:"ip_address,omitempty"` + + // Account 最小账号信息(避免泄露敏感字段) + Account *AccountSummary `json:"account,omitempty"` +} + type UsageCleanupFilters struct { StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index be8a8df8..f551c7f9 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -190,24 +190,25 @@ func TestAPIContracts(t *testing.T) { t.Helper() deps.usageRepo.SetUserLogs(1, []service.UsageLog{ { - ID: 1, - UserID: 1, - APIKeyID: 100, - AccountID: 200, - RequestID: "req_123", - Model: "claude-3", - InputTokens: 10, - OutputTokens: 20, - CacheCreationTokens: 1, - CacheReadTokens: 2, - TotalCost: 0.5, - ActualCost: 0.5, - RateMultiplier: 1, - BillingType: service.BillingTypeBalance, - Stream: true, - DurationMs: ptr(100), - FirstTokenMs: ptr(50), - CreatedAt: deps.now, + ID: 1, + UserID: 1, + APIKeyID: 100, + AccountID: 200, + AccountRateMultiplier: ptr(0.5), + RequestID: "req_123", + Model: "claude-3", + InputTokens: 10, + OutputTokens: 20, + CacheCreationTokens: 1, + CacheReadTokens: 2, + TotalCost: 0.5, + ActualCost: 0.5, + RateMultiplier: 1, + BillingType: service.BillingTypeBalance, + Stream: true, + DurationMs: ptr(100), + FirstTokenMs: ptr(50), + CreatedAt: deps.now, }, }) }, @@ -238,10 +239,9 @@ func TestAPIContracts(t *testing.T) { "output_cost": 0, "cache_creation_cost": 0, "cache_read_cost": 0, - "total_cost": 0.5, + "total_cost": 0.5, "actual_cost": 0.5, "rate_multiplier": 1, - "account_rate_multiplier": null, "billing_type": 0, "stream": true, "duration_ms": 100, diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index c271a2d0..94f7b57b 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -4,7 +4,7 @@ */ import { apiClient } from '../client' -import type { UsageLog, UsageQueryParams, PaginatedResponse } from '@/types' +import type { AdminUsageLog, UsageQueryParams, PaginatedResponse } from '@/types' // ==================== Types ==================== @@ -85,8 +85,8 @@ export interface AdminUsageQueryParams extends UsageQueryParams { export async function list( params: AdminUsageQueryParams, options?: { signal?: AbortSignal } -): Promise> { - const { data } = await apiClient.get>('/admin/usage', { +): Promise> { + const { data } = await apiClient.get>('/admin/usage', { params, signal: options?.signal }) diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index d2260c59..f6d1b1be 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -239,7 +239,7 @@ import { formatDateTime } from '@/utils/format' import DataTable from '@/components/common/DataTable.vue' import EmptyState from '@/components/common/EmptyState.vue' import Icon from '@/components/icons/Icon.vue' -import type { UsageLog } from '@/types' +import type { AdminUsageLog } from '@/types' defineProps(['data', 'loading']) const { t } = useI18n() @@ -247,12 +247,12 @@ const { t } = useI18n() // Tooltip state - cost const tooltipVisible = ref(false) const tooltipPosition = ref({ x: 0, y: 0 }) -const tooltipData = ref(null) +const tooltipData = ref(null) // Tooltip state - token const tokenTooltipVisible = ref(false) const tokenTooltipPosition = ref({ x: 0, y: 0 }) -const tokenTooltipData = ref(null) +const tokenTooltipData = ref(null) const cols = computed(() => [ { key: 'user', label: t('admin.usage.user'), sortable: false }, @@ -296,7 +296,7 @@ const formatDuration = (ms: number | null | undefined): string => { } // Cost tooltip functions -const showTooltip = (event: MouseEvent, row: UsageLog) => { +const showTooltip = (event: MouseEvent, row: AdminUsageLog) => { const target = event.currentTarget as HTMLElement const rect = target.getBoundingClientRect() tooltipData.value = row @@ -311,7 +311,7 @@ const hideTooltip = () => { } // Token tooltip functions -const showTokenTooltip = (event: MouseEvent, row: UsageLog) => { +const showTokenTooltip = (event: MouseEvent, row: AdminUsageLog) => { const target = event.currentTarget as HTMLElement const rect = target.getBoundingClientRect() tokenTooltipData.value = row diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 35e256e6..1149b6c6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -636,7 +636,6 @@ export interface UsageLog { total_cost: number actual_cost: number rate_multiplier: number - account_rate_multiplier?: number | null billing_type: number stream: boolean @@ -650,18 +649,30 @@ export interface UsageLog { // User-Agent user_agent: string | null - // IP 地址(仅管理员可见) - ip_address: string | null - created_at: string user?: User api_key?: ApiKey - account?: Account group?: Group subscription?: UserSubscription } +export interface UsageLogAccountSummary { + id: number + name: string +} + +export interface AdminUsageLog extends UsageLog { + // 账号计费倍率(仅管理员可见) + account_rate_multiplier?: number | null + + // 用户请求 IP(仅管理员可见) + ip_address?: string | null + + // 最小账号信息(仅管理员接口返回) + account?: UsageLogAccountSummary +} + export interface UsageCleanupFilters { start_time: string end_time: string diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 40b63ec3..9904405b 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -42,11 +42,11 @@ import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; impo import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue' import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue' import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue' -import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage' +import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage' const { t } = useI18n() const appStore = useAppStore() -const usageStats = ref(null); const usageLogs = ref([]); const loading = ref(false); const exporting = ref(false) +const usageStats = ref(null); const usageLogs = ref([]); const loading = ref(false); const exporting = ref(false) const trendData = ref([]); const modelStats = ref([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day') let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' }) @@ -92,7 +92,7 @@ const exportToExcel = async () => { if (exporting.value) return; exporting.value = true; exportProgress.show = true const c = new AbortController(); exportAbortController = c try { - const all: UsageLog[] = []; let p = 1; let total = pagination.total + const all: AdminUsageLog[] = []; let p = 1; let total = pagination.total while (true) { const res = await adminUsageAPI.list({ page: p, page_size: 100, ...filters.value }, { signal: c.signal }) if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }