From ff30bfab825f94a18349c4f84ea9097d895092b3 Mon Sep 17 00:00:00 2001 From: atoz03 Date: Fri, 5 Dec 2025 14:23:25 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E8=B4=A6=E6=88=B7=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF=E8=AF=A6=E6=83=85=E9=A1=B5=E4=B8=8E=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=AE=8C=E5=96=84=20=20=20-=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20/admin/accounts/:accountId/usage-records?= =?UTF-8?q?=20=E6=8E=A5=E5=8F=A3=EF=BC=8C=E6=94=AF=E6=8C=81=E6=8C=89?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E8=81=9A=E5=90=88=E5=A4=9A=20Key=20=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=B9=B6=E5=88=86=E9=A1=B5=E7=AD=9B=E9=80=89=E3=80=81?= =?UTF-8?q?=E6=B1=87=E6=80=BB=E7=BB=9F=E8=AE=A1=20=20=20-=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20API=20Key=20=E6=97=B6=E9=97=B4=E7=BA=BF=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E7=AD=9B=E9=80=89=E8=B7=B3=E8=BF=87=E5=B7=B2=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E8=B4=A6=E5=8F=B7=EF=BC=8C=E8=A1=A5=E5=85=85=E8=B4=A6?= =?UTF-8?q?=E6=88=B7/Key=20=E8=BE=85=E5=8A=A9=E8=A7=A3=E6=9E=90=20=20=20-?= =?UTF-8?q?=20=E5=89=8D=E7=AB=AF=E6=96=B0=E5=A2=9E=20AccountUsageRecordsVi?= =?UTF-8?q?ew=E3=80=81=E8=B7=AF=E7=94=B1=E5=8F=8A=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E2=80=9C=E6=97=B6=E9=97=B4=E7=BA=BF=E2=80=9D?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=8C=E6=94=AF=E6=8C=81=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?/API=20Key=20=E7=AD=9B=E9=80=89=E4=B8=8E=20CSV=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=20=20=20-=20=E8=A1=A5=E8=A3=85=20prettier-plugin-tail?= =?UTF-8?q?windcss=20=E5=B9=B6=E5=AE=8C=E6=88=90=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=A0=BC=E5=BC=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 80 +++ package.json | 1 + src/routes/admin/usageStats.js | 371 ++++++++++- web/admin-spa/src/router/index.js | 13 + .../src/views/AccountUsageRecordsView.vue | 574 ++++++++++++++++++ web/admin-spa/src/views/AccountsView.vue | 33 + 6 files changed, 1053 insertions(+), 19 deletions(-) create mode 100644 web/admin-spa/src/views/AccountUsageRecordsView.vue diff --git a/package-lock.json b/package-lock.json index 551062b7..c6dccd11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "jest": "^29.7.0", "nodemon": "^3.0.1", "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.7.2", "supertest": "^6.3.3" }, "engines": { @@ -7605,6 +7606,85 @@ "node": ">=6.0.0" } }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz", diff --git a/package.json b/package.json index 72ea4720..2b7ffa25 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "jest": "^29.7.0", "nodemon": "^3.0.1", "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.7.2", "supertest": "^6.3.3" }, "engines": { diff --git a/src/routes/admin/usageStats.js b/src/routes/admin/usageStats.js index 18d6b436..25b8c260 100644 --- a/src/routes/admin/usageStats.js +++ b/src/routes/admin/usageStats.js @@ -16,6 +16,65 @@ const pricingService = require('../../services/pricingService') const router = express.Router() +const accountTypeNames = { + claude: 'Claude官方', + 'claude-console': 'Claude Console', + ccr: 'Claude Console Relay', + openai: 'OpenAI', + 'openai-responses': 'OpenAI Responses', + gemini: 'Gemini', + 'gemini-api': 'Gemini API', + droid: 'Droid', + unknown: '未知渠道' +} + +const resolveAccountByPlatform = async (accountId, platform) => { + const serviceMap = { + claude: claudeAccountService, + 'claude-console': claudeConsoleAccountService, + gemini: geminiAccountService, + 'gemini-api': geminiApiAccountService, + openai: openaiAccountService, + 'openai-responses': openaiResponsesAccountService, + droid: droidAccountService, + ccr: ccrAccountService + } + + if (platform && serviceMap[platform]) { + try { + const account = await serviceMap[platform].getAccount(accountId) + if (account) { + return { ...account, platform } + } + } catch (error) { + logger.debug(`⚠️ Failed to get account ${accountId} from ${platform}: ${error.message}`) + } + } + + for (const [platformName, service] of Object.entries(serviceMap)) { + try { + const account = await service.getAccount(accountId) + if (account) { + return { ...account, platform: platformName } + } + } catch (error) { + logger.debug(`⚠️ Failed to get account ${accountId} from ${platformName}: ${error.message}`) + } + } + + return null +} + +const getApiKeyName = async (keyId) => { + try { + const keyData = await redis.getApiKey(keyId) + return keyData?.name || keyData?.label || keyId + } catch (error) { + logger.debug(`⚠️ Failed to get API key name for ${keyId}: ${error.message}`) + return keyId + } +} + // 📊 账户使用统计 // 获取所有账户的使用统计 @@ -1861,18 +1920,6 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) const rawRecords = await redis.getUsageRecords(keyId, 5000) - const accountTypeNames = { - claude: 'Claude官方', - 'claude-console': 'Claude Console', - ccr: 'Claude Console Relay', - openai: 'OpenAI', - 'openai-responses': 'OpenAI Responses', - gemini: 'Gemini', - 'gemini-api': 'Gemini API', - droid: 'Droid', - unknown: '未知渠道' - } - const accountServices = [ { type: 'claude', getter: (id) => claudeAccountService.getAccount(id) }, { type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) }, @@ -2088,13 +2135,16 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) const accountOptions = [] for (const option of accountOptionMap.values()) { const info = await resolveAccountInfo(option.id, option.accountType) - const resolvedType = info?.type || option.accountType || 'unknown' - accountOptions.push({ - id: option.id, - name: info?.name || option.id, - accountType: resolvedType, - accountTypeName: accountTypeNames[resolvedType] || '未知渠道' - }) + if (info && info.name) { + accountOptions.push({ + id: option.id, + name: info.name, + accountType: info.type, + accountTypeName: accountTypeNames[info.type] || '未知渠道' + }) + } else { + logger.warn(`⚠️ Skipping deleted/invalid account in filter options: ${option.id}`) + } } return res.json({ @@ -2146,4 +2196,287 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) } }) +// 获取账户的请求记录时间线 +router.get('/accounts/:accountId/usage-records', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { + platform, + page = 1, + pageSize = 50, + startDate, + endDate, + model, + apiKeyId, + sortOrder = 'desc' + } = req.query + + const pageNumber = Math.max(parseInt(page, 10) || 1, 1) + const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200) + const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc' + + const startTime = startDate ? new Date(startDate) : null + const endTime = endDate ? new Date(endDate) : null + + if ( + (startDate && Number.isNaN(startTime?.getTime())) || + (endDate && Number.isNaN(endTime?.getTime())) + ) { + return res.status(400).json({ success: false, error: 'Invalid date range' }) + } + + if (startTime && endTime && startTime > endTime) { + return res + .status(400) + .json({ success: false, error: 'Start date must be before or equal to end date' }) + } + + const accountInfo = await resolveAccountByPlatform(accountId, platform) + if (!accountInfo) { + return res.status(404).json({ success: false, error: 'Account not found' }) + } + + const allApiKeys = await apiKeyService.getAllApiKeys(true) + const apiKeyNameCache = new Map( + allApiKeys.map((key) => [key.id, key.name || key.label || key.id]) + ) + + let keysToUse = apiKeyId ? allApiKeys.filter((key) => key.id === apiKeyId) : allApiKeys + if (apiKeyId && keysToUse.length === 0) { + keysToUse = [{ id: apiKeyId }] + } + + const toUsageObject = (record) => ({ + input_tokens: record.inputTokens || 0, + output_tokens: record.outputTokens || 0, + cache_creation_input_tokens: record.cacheCreateTokens || 0, + cache_read_input_tokens: record.cacheReadTokens || 0, + cache_creation: record.cacheCreation || record.cache_creation || null + }) + + const withinRange = (record) => { + if (!record.timestamp) { + return false + } + const ts = new Date(record.timestamp) + if (Number.isNaN(ts.getTime())) { + return false + } + if (startTime && ts < startTime) { + return false + } + if (endTime && ts > endTime) { + return false + } + return true + } + + const filteredRecords = [] + const modelSet = new Set() + const apiKeyOptionMap = new Map() + let earliestTimestamp = null + let latestTimestamp = null + + const batchSize = 10 + for (let i = 0; i < keysToUse.length; i += batchSize) { + const batch = keysToUse.slice(i, i + batchSize) + const batchResults = await Promise.all( + batch.map(async (key) => { + try { + const records = await redis.getUsageRecords(key.id, 5000) + return { keyId: key.id, records: records || [] } + } catch (error) { + logger.debug(`⚠️ Failed to get usage records for key ${key.id}: ${error.message}`) + return { keyId: key.id, records: [] } + } + }) + ) + + for (const { keyId, records } of batchResults) { + const apiKeyName = apiKeyNameCache.get(keyId) || (await getApiKeyName(keyId)) + for (const record of records) { + if (record.accountId !== accountId) { + continue + } + if (!withinRange(record)) { + continue + } + if (model && record.model !== model) { + continue + } + + const accountType = record.accountType || accountInfo.platform || 'unknown' + const normalizedModel = record.model || 'unknown' + + modelSet.add(normalizedModel) + apiKeyOptionMap.set(keyId, { id: keyId, name: apiKeyName }) + + if (record.timestamp) { + const ts = new Date(record.timestamp) + if (!Number.isNaN(ts.getTime())) { + if (!earliestTimestamp || ts < earliestTimestamp) { + earliestTimestamp = ts + } + if (!latestTimestamp || ts > latestTimestamp) { + latestTimestamp = ts + } + } + } + + filteredRecords.push({ + ...record, + model: normalizedModel, + accountType, + apiKeyId: keyId, + apiKeyName + }) + } + } + } + + filteredRecords.sort((a, b) => { + const aTime = new Date(a.timestamp).getTime() + const bTime = new Date(b.timestamp).getTime() + if (Number.isNaN(aTime) || Number.isNaN(bTime)) { + return 0 + } + return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime + }) + + const summary = { + totalRequests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0 + } + + for (const record of filteredRecords) { + const usage = toUsageObject(record) + const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') + const computedCost = + typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const totalTokens = + record.totalTokens || + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + + summary.totalRequests += 1 + summary.inputTokens += usage.input_tokens + summary.outputTokens += usage.output_tokens + summary.cacheCreateTokens += usage.cache_creation_input_tokens + summary.cacheReadTokens += usage.cache_read_input_tokens + summary.totalTokens += totalTokens + summary.totalCost += computedCost + } + + const totalRecords = filteredRecords.length + const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0 + const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1 + const startIndex = (safePage - 1) * pageSizeNumber + const pageRecords = + totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber) + + const enrichedRecords = [] + for (const record of pageRecords) { + const usage = toUsageObject(record) + const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') + const computedCost = + typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const totalTokens = + record.totalTokens || + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + + enrichedRecords.push({ + timestamp: record.timestamp, + model: record.model || 'unknown', + apiKeyId: record.apiKeyId, + apiKeyName: record.apiKeyName, + accountId, + accountName: accountInfo.name || accountInfo.email || accountId, + accountType: record.accountType, + accountTypeName: accountTypeNames[record.accountType] || '未知渠道', + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + ephemeral5mTokens: record.ephemeral5mTokens || 0, + ephemeral1hTokens: record.ephemeral1hTokens || 0, + totalTokens, + isLongContextRequest: record.isLongContext || record.isLongContextRequest || false, + cost: Number(computedCost.toFixed(6)), + costFormatted: + record.costFormatted || + costData?.formatted?.total || + CostCalculator.formatCost(computedCost), + costBreakdown: record.costBreakdown || { + input: costData?.costs?.input || 0, + output: costData?.costs?.output || 0, + cacheCreate: costData?.costs?.cacheWrite || 0, + cacheRead: costData?.costs?.cacheRead || 0, + total: costData?.costs?.total || computedCost + }, + responseTime: record.responseTime || null + }) + } + + return res.json({ + success: true, + data: { + records: enrichedRecords, + pagination: { + currentPage: safePage, + pageSize: pageSizeNumber, + totalRecords, + totalPages, + hasNextPage: totalPages > 0 && safePage < totalPages, + hasPreviousPage: totalPages > 0 && safePage > 1 + }, + filters: { + startDate: startTime ? startTime.toISOString() : null, + endDate: endTime ? endTime.toISOString() : null, + model: model || null, + apiKeyId: apiKeyId || null, + platform: accountInfo.platform, + sortOrder: normalizedSortOrder + }, + accountInfo: { + id: accountId, + name: accountInfo.name || accountInfo.email || accountId, + platform: accountInfo.platform || platform || 'unknown', + status: accountInfo.status ?? accountInfo.isActive ?? null + }, + summary: { + ...summary, + totalCost: Number(summary.totalCost.toFixed(6)), + avgCost: + summary.totalRequests > 0 + ? Number((summary.totalCost / summary.totalRequests).toFixed(6)) + : 0 + }, + availableFilters: { + models: Array.from(modelSet), + apiKeys: Array.from(apiKeyOptionMap.values()), + dateRange: { + earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null, + latest: latestTimestamp ? latestTimestamp.toISOString() : null + } + } + } + }) + } catch (error) { + logger.error('❌ Failed to get account usage records:', error) + return res + .status(500) + .json({ error: 'Failed to get account usage records', message: error.message }) + } +}) + module.exports = router diff --git a/web/admin-spa/src/router/index.js b/web/admin-spa/src/router/index.js index 3375c906..feb67aa1 100644 --- a/web/admin-spa/src/router/index.js +++ b/web/admin-spa/src/router/index.js @@ -13,6 +13,7 @@ const DashboardView = () => import('@/views/DashboardView.vue') const ApiKeysView = () => import('@/views/ApiKeysView.vue') const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue') const AccountsView = () => import('@/views/AccountsView.vue') +const AccountUsageRecordsView = () => import('@/views/AccountUsageRecordsView.vue') const TutorialView = () => import('@/views/TutorialView.vue') const SettingsView = () => import('@/views/SettingsView.vue') const ApiStatsView = () => import('@/views/ApiStatsView.vue') @@ -110,6 +111,18 @@ const routes = [ } ] }, + { + path: '/accounts/:accountId/usage-records', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'AccountUsageRecords', + component: AccountUsageRecordsView + } + ] + }, { path: '/tutorial', component: MainLayout, diff --git a/web/admin-spa/src/views/AccountUsageRecordsView.vue b/web/admin-spa/src/views/AccountUsageRecordsView.vue new file mode 100644 index 00000000..c813e143 --- /dev/null +++ b/web/admin-spa/src/views/AccountUsageRecordsView.vue @@ -0,0 +1,574 @@ + + + diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index c2a31013..da60e249 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -1199,6 +1199,15 @@ 详情 + + +
+ + +
@@ -325,6 +333,7 @@ diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index da60e249..88aa858a 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -1199,15 +1199,6 @@ 详情 - - -