feat: add usage statistics and records feature with new API routes, frontend types, services, and UI components

This commit is contained in:
AoaoMH
2026-01-05 17:03:05 +08:00
parent 465da6f818
commit 93ab9b6a5e
8 changed files with 112 additions and 25 deletions

View File

@@ -262,7 +262,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -306,7 +305,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -1600,7 +1598,6 @@
"integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==", "integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.10.0" "undici-types": "~7.10.0"
} }
@@ -1679,7 +1676,6 @@
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/scope-manager": "8.49.0",
"@typescript-eslint/types": "8.49.0", "@typescript-eslint/types": "8.49.0",
@@ -2008,7 +2004,6 @@
"integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==", "integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/utils": "4.0.10", "@vitest/utils": "4.0.10",
"fflate": "^0.8.2", "fflate": "^0.8.2",
@@ -2306,7 +2301,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2608,7 +2602,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.2", "baseline-browser-mapping": "^2.8.2",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001741",
@@ -2725,7 +2718,6 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@kurkle/color": "^0.3.0" "@kurkle/color": "^0.3.0"
}, },
@@ -2948,7 +2940,6 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/kossnocorp" "url": "https://github.com/sponsors/kossnocorp"
@@ -3201,7 +3192,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -4101,7 +4091,6 @@
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.23", "@acemir/cssom": "^0.9.23",
"@asamuzakjp/dom-selector": "^6.7.4", "@asamuzakjp/dom-selector": "^6.7.4",
@@ -4666,7 +4655,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -4734,7 +4722,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -5765,7 +5752,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -5859,7 +5845,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -5935,7 +5920,6 @@
"integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==", "integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "4.0.10", "@vitest/expect": "4.0.10",
"@vitest/mocker": "4.0.10", "@vitest/mocker": "4.0.10",
@@ -6020,7 +6004,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==", "integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.21", "@vue/compiler-dom": "3.5.21",
"@vue/compiler-sfc": "3.5.21", "@vue/compiler-sfc": "3.5.21",
@@ -6053,6 +6036,7 @@
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"debug": "^4.4.0", "debug": "^4.4.0",
"eslint-scope": "^8.2.0", "eslint-scope": "^8.2.0",
@@ -6077,6 +6061,7 @@
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },

View File

@@ -166,6 +166,7 @@ export const usageApi = {
end_date?: string end_date?: string
user_id?: string // UUID user_id?: string // UUID
username?: string username?: string
user_api_key_name?: string
model?: string model?: string
provider?: string provider?: string
status?: string // 'stream' | 'standard' | 'error' status?: string // 'stream' | 'standard' | 'error'

View File

@@ -56,6 +56,20 @@
</SelectContent> </SelectContent>
</Select> </Select>
<!-- Key 名称筛选请求使用的用户 Key仅管理员可见 -->
<div
v-if="isAdmin"
class="relative"
>
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="usage-records-key-name"
v-model="localKeyName"
placeholder="搜索密钥名称"
class="w-24 sm:w-40 h-8 text-xs border-border/60 pl-8"
/>
</div>
<!-- 模型筛选 --> <!-- 模型筛选 -->
<Select <Select
v-model:open="filterModelSelectOpen" v-model:open="filterModelSelectOpen"
@@ -218,7 +232,18 @@
class="py-4 w-[100px] truncate" class="py-4 w-[100px] truncate"
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')" :title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
> >
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }} <div class="flex flex-col text-xs gap-0.5">
<span class="truncate">
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
</span>
<span
v-if="record.api_key?.name"
class="text-muted-foreground truncate"
:title="record.api_key.name"
>
{{ record.api_key.name }}
</span>
</div>
</TableCell> </TableCell>
<TableCell <TableCell
class="font-medium py-4 w-[140px]" class="font-medium py-4 w-[140px]"
@@ -438,6 +463,7 @@ import {
TableCard, TableCard,
Badge, Badge,
Button, Button,
Input,
Select, Select,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
@@ -451,7 +477,7 @@ import {
TableCell, TableCell,
Pagination, Pagination,
} from '@/components/ui' } from '@/components/ui'
import { RefreshCcw } from 'lucide-vue-next' import { RefreshCcw, Search } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format' import { formatTokens, formatCurrency } from '@/utils/format'
import { formatDateTime } from '../composables' import { formatDateTime } from '../composables'
import { useRowClick } from '@/composables/useRowClick' import { useRowClick } from '@/composables/useRowClick'
@@ -472,6 +498,7 @@ const props = defineProps<{
selectedPeriod: string selectedPeriod: string
// 筛选 // 筛选
filterUser: string filterUser: string
filterKeyName: string
filterModel: string filterModel: string
filterProvider: string filterProvider: string
filterStatus: string filterStatus: string
@@ -490,6 +517,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'update:selectedPeriod': [value: string] 'update:selectedPeriod': [value: string]
'update:filterUser': [value: string] 'update:filterUser': [value: string]
'update:filterKeyName': [value: string]
'update:filterModel': [value: string] 'update:filterModel': [value: string]
'update:filterProvider': [value: string] 'update:filterProvider': [value: string]
'update:filterStatus': [value: string] 'update:filterStatus': [value: string]
@@ -507,6 +535,23 @@ const filterModelSelectOpen = ref(false)
const filterProviderSelectOpen = ref(false) const filterProviderSelectOpen = ref(false)
const filterStatusSelectOpen = ref(false) const filterStatusSelectOpen = ref(false)
// Key 名称筛选(输入防抖)
const localKeyName = ref(props.filterKeyName)
let keyNameDebounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => props.filterKeyName, (value) => {
if (value !== localKeyName.value) {
localKeyName.value = value
}
})
watch(localKeyName, (value) => {
if (keyNameDebounceTimer) clearTimeout(keyNameDebounceTimer)
keyNameDebounceTimer = setTimeout(() => {
emit('update:filterKeyName', value)
}, 300)
})
// 动态计时器相关 // 动态计时器相关
const now = ref(Date.now()) const now = ref(Date.now())
let timerInterval: ReturnType<typeof setInterval> | null = null let timerInterval: ReturnType<typeof setInterval> | null = null
@@ -574,6 +619,10 @@ function handleRowClick(event: MouseEvent, id: string) {
// 组件卸载时清理 // 组件卸载时清理
onUnmounted(() => { onUnmounted(() => {
stopTimer() stopTimer()
if (keyNameDebounceTimer) {
clearTimeout(keyNameDebounceTimer)
keyNameDebounceTimer = null
}
}) })
// 格式化 API 格式显示名称 // 格式化 API 格式显示名称

View File

@@ -24,6 +24,7 @@ export interface PaginationParams {
export interface FilterParams { export interface FilterParams {
user_id?: string user_id?: string
user_api_key_name?: string
model?: string model?: string
provider?: string provider?: string
status?: string status?: string
@@ -255,6 +256,9 @@ export function useUsageData(options: UseUsageDataOptions) {
if (filters?.user_id) { if (filters?.user_id) {
params.user_id = filters.user_id params.user_id = filters.user_id
} }
if (filters?.user_api_key_name) {
params.user_api_key_name = filters.user_api_key_name
}
if (filters?.model) { if (filters?.model) {
params.model = filters.model params.model = filters.model
} }

View File

@@ -61,6 +61,11 @@ export interface UsageRecord {
user_id?: string user_id?: string
username?: string username?: string
user_email?: string user_email?: string
api_key?: {
id: string | null
name: string | null
display: string | null
} | null
provider: string provider: string
api_key_name?: string api_key_name?: string
rate_multiplier?: number rate_multiplier?: number

View File

@@ -367,6 +367,11 @@ function generateMockUsageRecords(count: number = 100) {
user_id: user.id, user_id: user.id,
username: user.username, username: user.username,
user_email: user.email, user_email: user.email,
api_key: {
id: `key-${user.id}-${Math.ceil(Math.random() * 2)}`,
name: `${user.username} Key ${Math.ceil(Math.random() * 3)}`,
display: `sk-ae...${String(1000 + Math.floor(Math.random() * 9000))}`
},
provider: model.provider, provider: model.provider,
api_key_name: `${model.provider}-key-${Math.ceil(Math.random() * 3)}`, api_key_name: `${model.provider}-key-${Math.ceil(Math.random() * 3)}`,
rate_multiplier: 1.0, rate_multiplier: 1.0,
@@ -835,10 +840,17 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
'GET /api/admin/usage/records': async (config) => { 'GET /api/admin/usage/records': async (config) => {
await delay() await delay()
requireAdmin() requireAdmin()
const records = getUsageRecords() let records = getUsageRecords()
const params = config.params || {} const params = config.params || {}
const limit = parseInt(params.limit) || 20 const limit = parseInt(params.limit) || 20
const offset = parseInt(params.offset) || 0 const offset = parseInt(params.offset) || 0
// 用户 API Key 名称筛选(注意:不是 Provider Key
if (typeof params.user_api_key_name === 'string' && params.user_api_key_name.trim()) {
const keyword = params.user_api_key_name.trim().toLowerCase()
records = records.filter(r => (r.api_key?.name || '').toLowerCase().includes(keyword))
}
return createMockResponse({ return createMockResponse({
records: records.slice(offset, offset + limit), records: records.slice(offset, offset + limit),
total: records.length, total: records.length,

View File

@@ -57,6 +57,7 @@
:loading="isLoadingRecords" :loading="isLoadingRecords"
:selected-period="selectedPeriod" :selected-period="selectedPeriod"
:filter-user="filterUser" :filter-user="filterUser"
:filter-key-name="filterKeyName"
:filter-model="filterModel" :filter-model="filterModel"
:filter-provider="filterProvider" :filter-provider="filterProvider"
:filter-status="filterStatus" :filter-status="filterStatus"
@@ -70,6 +71,7 @@
:auto-refresh="globalAutoRefresh" :auto-refresh="globalAutoRefresh"
@update:selected-period="handlePeriodChange" @update:selected-period="handlePeriodChange"
@update:filter-user="handleFilterUserChange" @update:filter-user="handleFilterUserChange"
@update:filter-key-name="handleFilterKeyNameChange"
@update:filter-model="handleFilterModelChange" @update:filter-model="handleFilterModelChange"
@update:filter-provider="handleFilterProviderChange" @update:filter-provider="handleFilterProviderChange"
@update:filter-status="handleFilterStatusChange" @update:filter-status="handleFilterStatusChange"
@@ -134,6 +136,7 @@ const pageSizeOptions = [10, 20, 50, 100]
// 筛选状态 // 筛选状态
const filterUser = ref('__all__') const filterUser = ref('__all__')
const filterKeyName = ref('')
const filterModel = ref('__all__') const filterModel = ref('__all__')
const filterProvider = ref('__all__') const filterProvider = ref('__all__')
const filterStatus = ref<FilterStatusValue>('__all__') const filterStatus = ref<FilterStatusValue>('__all__')
@@ -439,6 +442,7 @@ async function handlePageSizeChange(size: number) {
function getCurrentFilters() { function getCurrentFilters() {
return { return {
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined, user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
user_api_key_name: filterKeyName.value.trim() ? filterKeyName.value.trim() : undefined,
model: filterModel.value !== '__all__' ? filterModel.value : undefined, model: filterModel.value !== '__all__' ? filterModel.value : undefined,
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined, provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined status: filterStatus.value !== '__all__' ? filterStatus.value : undefined
@@ -455,6 +459,15 @@ async function handleFilterUserChange(value: string) {
} }
} }
async function handleFilterKeyNameChange(value: string) {
filterKeyName.value = value
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
}
async function handleFilterModelChange(value: string) { async function handleFilterModelChange(value: string) {
filterModel.value = value filterModel.value = value
currentPage.value = 1 // 重置到第一页 currentPage.value = 1 // 重置到第一页

View File

@@ -94,6 +94,7 @@ async def get_usage_records(
end_date: Optional[datetime] = None, end_date: Optional[datetime] = None,
user_id: Optional[str] = None, user_id: Optional[str] = None,
username: Optional[str] = None, username: Optional[str] = None,
user_api_key_name: Optional[str] = None,
model: Optional[str] = None, model: Optional[str] = None,
provider: Optional[str] = None, provider: Optional[str] = None,
status: Optional[str] = None, # stream, standard, error status: Optional[str] = None, # stream, standard, error
@@ -106,6 +107,7 @@ async def get_usage_records(
end_date=end_date, end_date=end_date,
user_id=user_id, user_id=user_id,
username=username, username=username,
user_api_key_name=user_api_key_name,
model=model, model=model,
provider=provider, provider=provider,
status=status, status=status,
@@ -502,6 +504,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
end_date: Optional[datetime], end_date: Optional[datetime],
user_id: Optional[str], user_id: Optional[str],
username: Optional[str], username: Optional[str],
user_api_key_name: Optional[str],
model: Optional[str], model: Optional[str],
provider: Optional[str], provider: Optional[str],
status: Optional[str], status: Optional[str],
@@ -512,6 +515,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
self.end_date = end_date self.end_date = end_date
self.user_id = user_id self.user_id = user_id
self.username = username self.username = username
self.user_api_key_name = user_api_key_name
self.model = model self.model = model
self.provider = provider self.provider = provider
self.status = status self.status = status
@@ -521,16 +525,20 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
db = context.db db = context.db
query = ( query = (
db.query(Usage, User, ProviderEndpoint, ProviderAPIKey) db.query(Usage, User, ProviderEndpoint, ProviderAPIKey, ApiKey)
.outerjoin(User, Usage.user_id == User.id) .outerjoin(User, Usage.user_id == User.id)
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id) .outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
.outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id) .outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id)
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
) )
if self.user_id: if self.user_id:
query = query.filter(Usage.user_id == self.user_id) query = query.filter(Usage.user_id == self.user_id)
if self.username: if self.username:
# 支持用户名模糊搜索 # 支持用户名模糊搜索
query = query.filter(User.username.ilike(f"%{self.username}%")) query = query.filter(User.username.ilike(f"%{self.username}%"))
if self.user_api_key_name:
# 支持用户 API Key 名称模糊搜索(注意:不是 Provider Key
query = query.filter(ApiKey.name.ilike(f"%{self.user_api_key_name}%"))
if self.model: if self.model:
# 支持模型名模糊搜索 # 支持模型名模糊搜索
query = query.filter(Usage.model.ilike(f"%{self.model}%")) query = query.filter(Usage.model.ilike(f"%{self.model}%"))
@@ -575,7 +583,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all() query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
) )
request_ids = [usage.request_id for usage, _, _, _ in records if usage.request_id] request_ids = [usage.request_id for usage, _, _, _, _ in records if usage.request_id]
fallback_map = {} fallback_map = {}
if request_ids: if request_ids:
# 只统计实际执行的候选success 或 failed不包括 skipped/pending/available # 只统计实际执行的候选success 或 failed不包括 skipped/pending/available
@@ -597,6 +605,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
end_date=self.end_date.isoformat() if self.end_date else None, end_date=self.end_date.isoformat() if self.end_date else None,
user_id=self.user_id, user_id=self.user_id,
username=self.username, username=self.username,
user_api_key_name=self.user_api_key_name,
model=self.model, model=self.model,
provider=self.provider, provider=self.provider,
status=self.status, status=self.status,
@@ -606,7 +615,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
) )
# 构建 provider_id -> Provider 名称的映射,避免 N+1 查询 # 构建 provider_id -> Provider 名称的映射,避免 N+1 查询
provider_ids = [usage.provider_id for usage, _, _, _ in records if usage.provider_id] provider_ids = [usage.provider_id for usage, _, _, _, _ in records if usage.provider_id]
provider_map = {} provider_map = {}
if provider_ids: if provider_ids:
providers_data = ( providers_data = (
@@ -615,7 +624,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
provider_map = {str(p.id): p.name for p in providers_data} provider_map = {str(p.id): p.name for p in providers_data}
data = [] data = []
for usage, user, endpoint, api_key in records: for usage, user, endpoint, provider_api_key, user_api_key in records:
actual_cost = ( actual_cost = (
float(usage.actual_total_cost_usd) float(usage.actual_total_cost_usd)
if usage.actual_total_cost_usd is not None if usage.actual_total_cost_usd is not None
@@ -636,6 +645,15 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
"user_id": user.id if user else None, "user_id": user.id if user else None,
"user_email": user.email if user else "已删除用户", "user_email": user.email if user else "已删除用户",
"username": user.username if user else "已删除用户", "username": user.username if user else "已删除用户",
"api_key": (
{
"id": user_api_key.id,
"name": user_api_key.name,
"display": user_api_key.get_display_key(),
}
if user_api_key
else None
),
"provider": provider_name, "provider": provider_name,
"model": usage.model, "model": usage.model,
"target_model": usage.target_model, # 映射后的目标模型名 "target_model": usage.target_model, # 映射后的目标模型名
@@ -661,7 +679,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
"has_fallback": fallback_map.get(usage.request_id, False), "has_fallback": fallback_map.get(usage.request_id, False),
"api_format": usage.api_format "api_format": usage.api_format
or (endpoint.api_format if endpoint and endpoint.api_format else None), or (endpoint.api_format if endpoint and endpoint.api_format else None),
"api_key_name": api_key.name if api_key else None, "api_key_name": provider_api_key.name if provider_api_key else None,
"request_metadata": usage.request_metadata, # Provider 响应元数据 "request_metadata": usage.request_metadata, # Provider 响应元数据
} }
) )