diff --git a/src/app.js b/src/app.js index 73670861..c26665ce 100644 --- a/src/app.js +++ b/src/app.js @@ -16,6 +16,7 @@ const pricingService = require('./services/pricingService'); const apiRoutes = require('./routes/api'); const adminRoutes = require('./routes/admin'); const webRoutes = require('./routes/web'); +const apiStatsRoutes = require('./routes/apiStats'); const geminiRoutes = require('./routes/geminiRoutes'); const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes'); const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes'); @@ -120,13 +121,14 @@ class Application { this.app.use('/claude', apiRoutes); // /claude 路由别名,与 /api 功能相同 this.app.use('/admin', adminRoutes); this.app.use('/web', webRoutes); + this.app.use('/apiStats', apiStatsRoutes); this.app.use('/gemini', geminiRoutes); this.app.use('/openai/gemini', openaiGeminiRoutes); this.app.use('/openai/claude', openaiClaudeRoutes); - // 🏠 根路径重定向到管理界面 + // 🏠 根路径重定向到API统计页面 this.app.get('/', (req, res) => { - res.redirect('/web'); + res.redirect('/apiStats'); }); // 🏥 增强的健康检查端点 diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js new file mode 100644 index 00000000..e496b9ab --- /dev/null +++ b/src/routes/apiStats.js @@ -0,0 +1,366 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const redis = require('../models/redis'); +const logger = require('../utils/logger'); +const apiKeyService = require('../services/apiKeyService'); +const CostCalculator = require('../utils/costCalculator'); + +const router = express.Router(); + +// 🛡️ 安全文件服务函数 +function serveStaticFile(req, res, filename, contentType) { + const filePath = path.join(__dirname, '../../web/apiStats', filename); + + try { + // 检查文件是否存在 + if (!fs.existsSync(filePath)) { + logger.error(`❌ API Stats file not found: ${filePath}`); + return res.status(404).json({ error: 'File not found' }); + } + + // 读取并返回文件内容 + const content = fs.readFileSync(filePath, 'utf8'); + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + res.send(content); + + logger.info(`📄 Served API Stats file: ${filename}`); + } catch (error) { + logger.error(`❌ Error serving API Stats file ${filename}:`, error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +// 🏠 API Stats 主页面 +router.get('/', (req, res) => { + serveStaticFile(req, res, 'index.html', 'text/html; charset=utf-8'); +}); + +// 📱 JavaScript 文件 +router.get('/app.js', (req, res) => { + serveStaticFile(req, res, 'app.js', 'application/javascript; charset=utf-8'); +}); + +// 🎨 CSS 文件 +router.get('/style.css', (req, res) => { + serveStaticFile(req, res, 'style.css', 'text/css; charset=utf-8'); +}); + +// 📊 用户API Key统计查询接口 - 安全的自查询接口 +router.post('/api/user-stats', async (req, res) => { + try { + const { apiKey } = req.body; + + if (!apiKey) { + logger.security(`🔒 Missing API key in user stats query from ${req.ip || 'unknown'}`); + return res.status(400).json({ + error: 'API Key is required', + message: 'Please provide your API Key' + }); + } + + // 基本API Key格式验证 + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + logger.security(`🔒 Invalid API key format in user stats query from ${req.ip || 'unknown'}`); + return res.status(400).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }); + } + + // 验证API Key(重用现有的验证逻辑) + const validation = await apiKeyService.validateApiKey(apiKey); + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; + logger.security(`🔒 Invalid API key in user stats query: ${validation.error} from ${clientIP}`); + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }); + } + + const keyData = validation.keyData; + + // 记录合法查询 + logger.api(`📊 User stats query from key: ${keyData.name} (${keyData.id}) from ${req.ip || 'unknown'}`); + + // 获取验证结果中的完整keyData(包含isActive状态和cost信息) + const fullKeyData = validation.keyData; + + // 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算) + let totalCost = 0; + let formattedCost = '$0.000000'; + + try { + const client = redis.getClientSafe(); + const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; + + // 获取所有月度模型统计(与model-stats接口相同的逻辑) + const allModelKeys = await client.keys(`usage:${fullKeyData.id}:model:monthly:*:*`); + const modelUsageMap = new Map(); + + for (const key of allModelKeys) { + const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/); + if (!modelMatch) continue; + + const model = modelMatch[1]; + const data = await client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + if (!modelUsageMap.has(model)) { + modelUsageMap.set(model, { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }); + } + + const modelUsage = modelUsageMap.get(model); + modelUsage.inputTokens += parseInt(data.inputTokens) || 0; + modelUsage.outputTokens += parseInt(data.outputTokens) || 0; + modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0; + modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0; + } + } + + // 按模型计算费用并汇总 + for (const [model, usage] of modelUsageMap) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + }; + + const costResult = CostCalculator.calculateCost(usageData, model); + totalCost += costResult.costs.total; + } + + // 如果没有模型级别的详细数据,回退到总体数据计算 + if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) { + const usage = fullKeyData.usage.total; + const costUsage = { + input_tokens: usage.inputTokens || 0, + output_tokens: usage.outputTokens || 0, + cache_creation_input_tokens: usage.cacheCreateTokens || 0, + cache_read_input_tokens: usage.cacheReadTokens || 0 + }; + + const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022'); + totalCost = costResult.costs.total; + } + + formattedCost = CostCalculator.formatCost(totalCost); + + } catch (error) { + logger.warn(`Failed to calculate detailed cost for key ${fullKeyData.id}:`, error); + // 回退到简单计算 + if (fullKeyData.usage?.total?.allTokens > 0) { + const usage = fullKeyData.usage.total; + const costUsage = { + input_tokens: usage.inputTokens || 0, + output_tokens: usage.outputTokens || 0, + cache_creation_input_tokens: usage.cacheCreateTokens || 0, + cache_read_input_tokens: usage.cacheReadTokens || 0 + }; + + const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022'); + totalCost = costResult.costs.total; + formattedCost = costResult.formatted.total; + } + } + + // 构建响应数据(只返回该API Key自己的信息,确保不泄露其他信息) + const responseData = { + id: fullKeyData.id, + name: fullKeyData.name, + description: keyData.description || '', + isActive: true, // 如果能通过validateApiKey验证,说明一定是激活的 + createdAt: keyData.createdAt, + expiresAt: keyData.expiresAt, + permissions: fullKeyData.permissions, + + // 使用统计(使用验证结果中的完整数据) + usage: { + total: { + ...(fullKeyData.usage?.total || { + requests: 0, + tokens: 0, + allTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }), + cost: totalCost, + formattedCost: formattedCost + } + }, + + // 限制信息(只显示配置,不显示当前使用量) + limits: { + tokenLimit: fullKeyData.tokenLimit || 0, + concurrencyLimit: fullKeyData.concurrencyLimit || 0, + rateLimitWindow: fullKeyData.rateLimitWindow || 0, + rateLimitRequests: fullKeyData.rateLimitRequests || 0, + dailyCostLimit: fullKeyData.dailyCostLimit || 0 + }, + + // 绑定的账户信息(只显示ID,不显示敏感信息) + accounts: { + claudeAccountId: fullKeyData.claudeAccountId && fullKeyData.claudeAccountId !== '' ? fullKeyData.claudeAccountId : null, + geminiAccountId: fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== '' ? fullKeyData.geminiAccountId : null + }, + + // 模型和客户端限制信息 + restrictions: { + enableModelRestriction: fullKeyData.enableModelRestriction || false, + restrictedModels: fullKeyData.restrictedModels || [], + enableClientRestriction: fullKeyData.enableClientRestriction || false, + allowedClients: fullKeyData.allowedClients || [] + } + }; + + res.json({ + success: true, + data: responseData + }); + + } catch (error) { + logger.error('❌ Failed to process user stats query:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve API key statistics' + }); + } +}); + +// 📊 用户模型统计查询接口 - 安全的自查询接口 +router.post('/api/user-model-stats', async (req, res) => { + try { + const { apiKey, period = 'monthly' } = req.body; + + if (!apiKey) { + logger.security(`🔒 Missing API key in user model stats query from ${req.ip || 'unknown'}`); + return res.status(400).json({ + error: 'API Key is required', + message: 'Please provide your API Key' + }); + } + + // 验证API Key + const validation = await apiKeyService.validateApiKey(apiKey); + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; + logger.security(`🔒 Invalid API key in user model stats query: ${validation.error} from ${clientIP}`); + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }); + } + + const keyData = validation.keyData; + logger.api(`📊 User model stats query from key: ${keyData.name} (${keyData.id}) for period: ${period}`); + + // 重用管理后台的模型统计逻辑,但只返回该API Key的数据 + const client = redis.getClientSafe(); + const today = new Date().toISOString().split('T')[0]; + const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; + + const pattern = period === 'daily' ? + `usage:${keyData.id}:model:daily:*:${today}` : + `usage:${keyData.id}:model:monthly:*:${currentMonth}`; + + const keys = await client.keys(pattern); + const modelStats = []; + + for (const key of keys) { + const match = key.match(period === 'daily' ? + /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ : + /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ + ); + + if (!match) continue; + + const model = match[1]; + const data = await client.hgetall(key); + + if (data && Object.keys(data).length > 0) { + const usage = { + input_tokens: parseInt(data.inputTokens) || 0, + output_tokens: parseInt(data.outputTokens) || 0, + cache_creation_input_tokens: parseInt(data.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0 + }; + + const costData = CostCalculator.calculateCost(usage, model); + + modelStats.push({ + model, + requests: parseInt(data.requests) || 0, + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + allTokens: parseInt(data.allTokens) || 0, + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing + }); + } + } + + // 如果没有详细的模型数据,尝试从总体usage中生成 + if (modelStats.length === 0 && keyData.usage?.total) { + const usageData = keyData.usage.total; + + if (usageData.allTokens > 0) { + const usage = { + input_tokens: usageData.inputTokens || 0, + output_tokens: usageData.outputTokens || 0, + cache_creation_input_tokens: usageData.cacheCreateTokens || 0, + cache_read_input_tokens: usageData.cacheReadTokens || 0 + }; + + const costData = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022'); + + modelStats.push({ + model: '总体使用 (历史数据)', + requests: usageData.requests || 0, + inputTokens: usageData.inputTokens || 0, + outputTokens: usageData.outputTokens || 0, + cacheCreateTokens: usageData.cacheCreateTokens || 0, + cacheReadTokens: usageData.cacheReadTokens || 0, + allTokens: usageData.allTokens || 0, + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing + }); + } + } + + // 按总token数降序排列 + modelStats.sort((a, b) => b.allTokens - a.allTokens); + + res.json({ + success: true, + data: modelStats, + period: period + }); + + } catch (error) { + logger.error('❌ Failed to process user model stats query:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve model statistics' + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/web.js b/src/routes/web.js index be7d31ba..eac99038 100644 --- a/src/routes/web.js +++ b/src/routes/web.js @@ -28,18 +28,6 @@ const ALLOWED_FILES = { path: path.join(__dirname, '../../web/admin/style.css'), contentType: 'text/css; charset=utf-8' }, - 'userStats.html': { - path: path.join(__dirname, '../../web/userStats/index.html'), - contentType: 'text/html; charset=utf-8' - }, - 'userStats.js': { - path: path.join(__dirname, '../../web/userStats/app.js'), - contentType: 'application/javascript; charset=utf-8' - }, - 'userStats.css': { - path: path.join(__dirname, '../../web/userStats/style.css'), - contentType: 'text/css; charset=utf-8' - } }; // 🛡️ 安全文件服务函数 @@ -414,332 +402,7 @@ router.get('/style.css', (req, res) => { serveWhitelistedFile(req, res, 'style.css'); }); -// 📊 用户统计页面路由 -router.get('/userStats', (req, res) => { - serveWhitelistedFile(req, res, 'userStats.html'); -}); -router.get('/userStats.js', (req, res) => { - serveWhitelistedFile(req, res, 'userStats.js'); -}); - -router.get('/userStats.css', (req, res) => { - serveWhitelistedFile(req, res, 'userStats.css'); -}); - -// 📊 用户API Key统计查询接口 - 安全的自查询接口 -router.post('/api/user-stats', async (req, res) => { - try { - const { apiKey } = req.body; - - if (!apiKey) { - logger.security(`🔒 Missing API key in user stats query from ${req.ip || 'unknown'}`); - return res.status(400).json({ - error: 'API Key is required', - message: 'Please provide your API Key' - }); - } - - // 基本API Key格式验证 - if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { - logger.security(`🔒 Invalid API key format in user stats query from ${req.ip || 'unknown'}`); - return res.status(400).json({ - error: 'Invalid API key format', - message: 'API key format is invalid' - }); - } - - // 验证API Key(重用现有的验证逻辑) - const validation = await apiKeyService.validateApiKey(apiKey); - - if (!validation.valid) { - const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; - logger.security(`🔒 Invalid API key in user stats query: ${validation.error} from ${clientIP}`); - return res.status(401).json({ - error: 'Invalid API key', - message: validation.error - }); - } - - const keyData = validation.keyData; - - // 记录合法查询 - logger.api(`📊 User stats query from key: ${keyData.name} (${keyData.id}) from ${req.ip || 'unknown'}`); - - // 获取验证结果中的完整keyData(包含isActive状态和cost信息) - const fullKeyData = validation.keyData; - - // 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算) - let totalCost = 0; - let formattedCost = '$0.000000'; - - try { - const client = redis.getClientSafe(); - const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; - - // 获取所有月度模型统计(与model-stats接口相同的逻辑) - const allModelKeys = await client.keys(`usage:${fullKeyData.id}:model:monthly:*:*`); - const modelUsageMap = new Map(); - - for (const key of allModelKeys) { - const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/); - if (!modelMatch) continue; - - const model = modelMatch[1]; - const data = await client.hgetall(key); - - if (data && Object.keys(data).length > 0) { - if (!modelUsageMap.has(model)) { - modelUsageMap.set(model, { - inputTokens: 0, - outputTokens: 0, - cacheCreateTokens: 0, - cacheReadTokens: 0 - }); - } - - const modelUsage = modelUsageMap.get(model); - modelUsage.inputTokens += parseInt(data.inputTokens) || 0; - modelUsage.outputTokens += parseInt(data.outputTokens) || 0; - modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0; - modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0; - } - } - - // 按模型计算费用并汇总 - for (const [model, usage] of modelUsageMap) { - const usageData = { - input_tokens: usage.inputTokens, - output_tokens: usage.outputTokens, - cache_creation_input_tokens: usage.cacheCreateTokens, - cache_read_input_tokens: usage.cacheReadTokens - }; - - const costResult = CostCalculator.calculateCost(usageData, model); - totalCost += costResult.costs.total; - } - - // 如果没有模型级别的详细数据,回退到总体数据计算 - if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) { - const usage = fullKeyData.usage.total; - const costUsage = { - input_tokens: usage.inputTokens || 0, - output_tokens: usage.outputTokens || 0, - cache_creation_input_tokens: usage.cacheCreateTokens || 0, - cache_read_input_tokens: usage.cacheReadTokens || 0 - }; - - const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022'); - totalCost = costResult.costs.total; - } - - formattedCost = CostCalculator.formatCost(totalCost); - - } catch (error) { - logger.warn(`Failed to calculate detailed cost for key ${fullKeyData.id}:`, error); - // 回退到简单计算 - if (fullKeyData.usage?.total?.allTokens > 0) { - const usage = fullKeyData.usage.total; - const costUsage = { - input_tokens: usage.inputTokens || 0, - output_tokens: usage.outputTokens || 0, - cache_creation_input_tokens: usage.cacheCreateTokens || 0, - cache_read_input_tokens: usage.cacheReadTokens || 0 - }; - - const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022'); - totalCost = costResult.costs.total; - formattedCost = costResult.formatted.total; - } - } - - // 构建响应数据(只返回该API Key自己的信息,确保不泄露其他信息) - const responseData = { - id: fullKeyData.id, - name: fullKeyData.name, - description: keyData.description || '', - isActive: true, // 如果能通过validateApiKey验证,说明一定是激活的 - createdAt: keyData.createdAt, - expiresAt: keyData.expiresAt, - permissions: fullKeyData.permissions, - - // 使用统计(使用验证结果中的完整数据) - usage: { - total: { - ...(fullKeyData.usage?.total || { - requests: 0, - tokens: 0, - allTokens: 0, - inputTokens: 0, - outputTokens: 0, - cacheCreateTokens: 0, - cacheReadTokens: 0 - }), - cost: totalCost, - formattedCost: formattedCost - } - }, - - // 限制信息(只显示配置,不显示当前使用量) - limits: { - tokenLimit: fullKeyData.tokenLimit || 0, - concurrencyLimit: fullKeyData.concurrencyLimit || 0, - rateLimitWindow: fullKeyData.rateLimitWindow || 0, - rateLimitRequests: fullKeyData.rateLimitRequests || 0, - dailyCostLimit: fullKeyData.dailyCostLimit || 0 - }, - - // 绑定的账户信息(只显示ID,不显示敏感信息) - accounts: { - claudeAccountId: fullKeyData.claudeAccountId && fullKeyData.claudeAccountId !== '' ? fullKeyData.claudeAccountId : null, - geminiAccountId: fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== '' ? fullKeyData.geminiAccountId : null - }, - - // 模型和客户端限制信息 - restrictions: { - enableModelRestriction: fullKeyData.enableModelRestriction || false, - restrictedModels: fullKeyData.restrictedModels || [], - enableClientRestriction: fullKeyData.enableClientRestriction || false, - allowedClients: fullKeyData.allowedClients || [] - } - }; - - res.json({ - success: true, - data: responseData - }); - - } catch (error) { - logger.error('❌ Failed to process user stats query:', error); - res.status(500).json({ - error: 'Internal server error', - message: 'Failed to retrieve API key statistics' - }); - } -}); - -// 📊 用户模型统计查询接口 - 安全的自查询接口 -router.post('/api/user-model-stats', async (req, res) => { - try { - const { apiKey, period = 'monthly' } = req.body; - - if (!apiKey) { - logger.security(`🔒 Missing API key in user model stats query from ${req.ip || 'unknown'}`); - return res.status(400).json({ - error: 'API Key is required', - message: 'Please provide your API Key' - }); - } - - // 验证API Key - const validation = await apiKeyService.validateApiKey(apiKey); - - if (!validation.valid) { - const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; - logger.security(`🔒 Invalid API key in user model stats query: ${validation.error} from ${clientIP}`); - return res.status(401).json({ - error: 'Invalid API key', - message: validation.error - }); - } - - const keyData = validation.keyData; - logger.api(`📊 User model stats query from key: ${keyData.name} (${keyData.id}) for period: ${period}`); - - // 重用管理后台的模型统计逻辑,但只返回该API Key的数据 - const client = redis.getClientSafe(); - const today = new Date().toISOString().split('T')[0]; - const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; - - const pattern = period === 'daily' ? - `usage:${keyData.id}:model:daily:*:${today}` : - `usage:${keyData.id}:model:monthly:*:${currentMonth}`; - - const keys = await client.keys(pattern); - const modelStats = []; - - for (const key of keys) { - const match = key.match(period === 'daily' ? - /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ : - /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ - ); - - if (!match) continue; - - const model = match[1]; - const data = await client.hgetall(key); - - if (data && Object.keys(data).length > 0) { - const usage = { - input_tokens: parseInt(data.inputTokens) || 0, - output_tokens: parseInt(data.outputTokens) || 0, - cache_creation_input_tokens: parseInt(data.cacheCreateTokens) || 0, - cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0 - }; - - const costData = CostCalculator.calculateCost(usage, model); - - modelStats.push({ - model, - requests: parseInt(data.requests) || 0, - inputTokens: usage.input_tokens, - outputTokens: usage.output_tokens, - cacheCreateTokens: usage.cache_creation_input_tokens, - cacheReadTokens: usage.cache_read_input_tokens, - allTokens: parseInt(data.allTokens) || 0, - costs: costData.costs, - formatted: costData.formatted, - pricing: costData.pricing - }); - } - } - - // 如果没有详细的模型数据,尝试从总体usage中生成 - if (modelStats.length === 0 && keyData.usage?.total) { - const usageData = keyData.usage.total; - - if (usageData.allTokens > 0) { - const usage = { - input_tokens: usageData.inputTokens || 0, - output_tokens: usageData.outputTokens || 0, - cache_creation_input_tokens: usageData.cacheCreateTokens || 0, - cache_read_input_tokens: usageData.cacheReadTokens || 0 - }; - - const costData = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022'); - - modelStats.push({ - model: '总体使用 (历史数据)', - requests: usageData.requests || 0, - inputTokens: usageData.inputTokens || 0, - outputTokens: usageData.outputTokens || 0, - cacheCreateTokens: usageData.cacheCreateTokens || 0, - cacheReadTokens: usageData.cacheReadTokens || 0, - allTokens: usageData.allTokens || 0, - costs: costData.costs, - formatted: costData.formatted, - pricing: costData.pricing - }); - } - } - - // 按总token数降序排列 - modelStats.sort((a, b) => b.allTokens - a.allTokens); - - res.json({ - success: true, - data: modelStats, - period: period - }); - - } catch (error) { - logger.error('❌ Failed to process user model stats query:', error); - res.status(500).json({ - error: 'Internal server error', - message: 'Failed to retrieve model statistics' - }); - } -}); // 🔑 Gemini OAuth 回调页面 diff --git a/web/userStats/app.js b/web/apiStats/app.js similarity index 98% rename from web/userStats/app.js rename to web/apiStats/app.js index 1898c706..134cd8bb 100644 --- a/web/userStats/app.js +++ b/web/apiStats/app.js @@ -43,7 +43,7 @@ const app = createApp({ this.modelStats = []; try { - const response = await fetch('/web/api/user-stats', { + const response = await fetch('/apiStats/api/user-stats', { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -100,7 +100,7 @@ const app = createApp({ // 📊 加载指定时间段的统计数据 async loadPeriodStats(period) { try { - const response = await fetch('/web/api/user-model-stats', { + const response = await fetch('/apiStats/api/user-model-stats', { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -163,7 +163,7 @@ const app = createApp({ this.modelStatsLoading = true; try { - const response = await fetch('/web/api/user-model-stats', { + const response = await fetch('/apiStats/api/user-model-stats', { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/web/apiStats/index.html b/web/apiStats/index.html new file mode 100644 index 00000000..acce07cf --- /dev/null +++ b/web/apiStats/index.html @@ -0,0 +1,333 @@ + + + + + + API Key 使用统计 - Claude Relay Service + + + + + + + + + + + + + + + + + + + +
+ +
+
+

+ + API Key 使用统计 +

+

查询您的 API Key 使用情况和统计数据

+
+ + +
+
+
+ +
+ + +
+
+ + +
+ + 您的 API Key 仅用于查询自己的统计数据,不会被存储或用于其他用途 +
+
+
+ + +
+
+
+
+ + 统计时间范围 +
+
+ + +
+
+
+
+ + +
+
+ + {{ error }} +
+
+ + +
+ +
+ +
+ +
+

+ + API Key 信息 +

+
+
+ 名称 + {{ statsData.name }} +
+
+ 状态 + + + {{ statsData.isActive ? '活跃' : '已停用' }} + +
+
+ 权限 + {{ formatPermissions(statsData.permissions) }} +
+
+ 创建时间 + {{ formatDate(statsData.createdAt) }} +
+
+ 过期时间 + {{ formatDate(statsData.expiresAt) }} +
+
+
+ + +
+

+ + 使用统计概览 ({{ statsPeriod === 'daily' ? '今日' : '本月' }}) +

+
+
+
{{ formatNumber(currentPeriodData.requests) }}
+
{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数
+
+
+
{{ formatNumber(currentPeriodData.allTokens) }}
+
{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数
+
+
+
{{ currentPeriodData.formattedCost || '$0.000000' }}
+
{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用
+
+
+
{{ formatNumber(currentPeriodData.inputTokens) }}
+
{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token
+
+
+
+
+ + +
+ +
+

+ + Token 使用分布 ({{ statsPeriod === 'daily' ? '今日' : '本月' }}) +

+
+
+ + + 输入 Token + + {{ formatNumber(currentPeriodData.inputTokens) }} +
+
+ + + 输出 Token + + {{ formatNumber(currentPeriodData.outputTokens) }} +
+
+ + + 缓存创建 Token + + {{ formatNumber(currentPeriodData.cacheCreateTokens) }} +
+
+ + + 缓存读取 Token + + {{ formatNumber(currentPeriodData.cacheReadTokens) }} +
+
+
+
+ {{ statsPeriod === 'daily' ? '今日' : '本月' }}总计 + {{ formatNumber(currentPeriodData.allTokens) }} +
+
+
+ + +
+

+ + 限制配置 +

+
+
+ Token 限制 + {{ statsData.limits.tokenLimit > 0 ? formatNumber(statsData.limits.tokenLimit) : '无限制' }} +
+
+ 并发限制 + {{ statsData.limits.concurrencyLimit > 0 ? statsData.limits.concurrencyLimit : '无限制' }} +
+
+ 速率限制 + + {{ statsData.limits.rateLimitRequests > 0 && statsData.limits.rateLimitWindow > 0 + ? `${statsData.limits.rateLimitRequests}次/${statsData.limits.rateLimitWindow}分钟` + : '无限制' }} + +
+
+ 每日费用限制 + {{ statsData.limits.dailyCostLimit > 0 ? '$' + statsData.limits.dailyCostLimit : '无限制' }} +
+
+
+
+ + +
+
+

+ + 模型使用统计 ({{ statsPeriod === 'daily' ? '今日' : '本月' }}) +

+
+ + +
+ +

加载模型统计数据中...

+
+ + +
+
+
+
+

{{ model.model }}

+

{{ model.requests }} 次请求

+
+
+
{{ model.formatted?.total || '$0.000000' }}
+
总费用
+
+
+ +
+
+
输入 Token
+
{{ formatNumber(model.inputTokens) }}
+
+
+
输出 Token
+
{{ formatNumber(model.outputTokens) }}
+
+
+
缓存创建
+
{{ formatNumber(model.cacheCreateTokens) }}
+
+
+
缓存读取
+
{{ formatNumber(model.cacheReadTokens) }}
+
+
+
+
+ + +
+ +

暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据

+
+
+
+
+ + + +
+
+ + + + + \ No newline at end of file diff --git a/web/userStats/style.css b/web/apiStats/style.css similarity index 54% rename from web/userStats/style.css rename to web/apiStats/style.css index dc47bbad..63586aa9 100644 --- a/web/userStats/style.css +++ b/web/apiStats/style.css @@ -1,4 +1,19 @@ -/* 🎨 用户统计页面自定义样式 */ +/* 🎨 用户统计页面自定义样式 - 与管理页面保持一致 */ + +/* CSS 变量 - 与管理页面保持一致 */ +:root { + --primary-color: #667eea; + --secondary-color: #764ba2; + --accent-color: #f093fb; + --success-color: #10b981; + --warning-color: #f59e0b; + --error-color: #ef4444; + --surface-color: rgba(255, 255, 255, 0.95); + --glass-color: rgba(255, 255, 255, 0.1); + --text-primary: #1f2937; + --text-secondary: #6b7280; + --border-color: rgba(255, 255, 255, 0.2); +} /* 📱 响应式布局优化 */ @media (max-width: 768px) { @@ -83,93 +98,242 @@ } } -/* 🌈 渐变背景增强 */ +/* 🌈 渐变背景 - 与管理页面一致 */ +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 50%, var(--accent-color) 100%); + background-attachment: fixed; + min-height: 100vh; + margin: 0; + overflow-x: hidden; +} + +body::before { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%), + radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%); + pointer-events: none; + z-index: -1; +} + .gradient-bg { - background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); - background-size: 200% 200%; - animation: gradientShift 15s ease infinite; + /* 移除原有的渐变,使用body的背景 */ } -@keyframes gradientShift { - 0% { background-position: 0% 50%; } - 50% { background-position: 100% 50%; } - 100% { background-position: 0% 50%; } -} - -/* ✨ 卡片样式增强 */ -.card { - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +/* ✨ 卡片样式 - 与管理页面一致 */ +.glass { + background: var(--glass-color); backdrop-filter: blur(20px); - border: 1px solid rgba(255, 255, 255, 0.1); - box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37); + border: 1px solid var(--border-color); + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.glass-strong { + background: var(--surface-color); + backdrop-filter: blur(25px); + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: + 0 25px 50px -12px rgba(0, 0, 0, 0.25), + 0 0 0 1px rgba(255, 255, 255, 0.05), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.card { + background: var(--surface-color); + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + overflow: hidden; + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent); } .card:hover { - transform: translateY(-4px) scale(1.02); - box-shadow: 0 20px 40px rgba(31, 38, 135, 0.5); - border-color: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.15), + 0 10px 10px -5px rgba(0, 0, 0, 0.08); } -/* 🎯 统计卡片样式 */ +/* 🎯 统计卡片样式 - 与管理页面一致 */ .stat-card { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%); - border: 1px solid rgba(255, 255, 255, 0.2); - transition: all 0.3s ease; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%); + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 24px; position: relative; overflow: hidden; + transition: all 0.3s ease; } .stat-card::before { content: ''; position: absolute; - top: 0; - left: -100%; - width: 100%; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); - transition: left 0.5s; -} - -.stat-card:hover::before { - left: 100%; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%); + opacity: 0; + transition: opacity 0.3s ease; } .stat-card:hover { - background: linear-gradient(135deg, rgba(255, 255, 255, 0.25) 0%, rgba(255, 255, 255, 0.15) 100%); - transform: translateY(-2px); - box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1); + transform: translateY(-4px); + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04); } -/* 🔍 输入框增强 */ -.input-field { - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); +.stat-card:hover::before { + opacity: 1; +} + +/* 🔍 输入框样式 - 与管理页面一致 */ +.form-input { + background: rgba(255, 255, 255, 0.9); + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 12px; + padding: 16px; + font-size: 16px; transition: all 0.3s ease; backdrop-filter: blur(10px); + color: var(--text-primary); +} + +.form-input::placeholder { + color: var(--text-secondary); +} + +.form-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: + 0 0 0 3px rgba(102, 126, 234, 0.1), + 0 10px 15px -3px rgba(0, 0, 0, 0.1); + background: rgba(255, 255, 255, 0.95); +} + +/* 兼容旧的 input-field 类名 */ +.input-field { + background: rgba(255, 255, 255, 0.9); + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 12px; + padding: 16px; + font-size: 16px; + transition: all 0.3s ease; + backdrop-filter: blur(10px); + color: var(--text-primary); } .input-field::placeholder { - color: rgba(255, 255, 255, 0.6); + color: var(--text-secondary); } .input-field:focus { - background: rgba(255, 255, 255, 0.2); - border-color: rgba(255, 255, 255, 0.5); outline: none; - box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1); - transform: translateY(-1px); + border-color: var(--primary-color); + box-shadow: + 0 0 0 3px rgba(102, 126, 234, 0.1), + 0 10px 15px -3px rgba(0, 0, 0, 0.1); + background: rgba(255, 255, 255, 0.95); } -/* 🎨 按钮增强 */ -.btn-primary { - background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 50%, #ec4899 100%); - background-size: 200% 200%; +/* 🎨 按钮样式 - 与管理页面一致 */ +.btn { + font-weight: 500; + border-radius: 12px; + border: none; + cursor: pointer; transition: all 0.3s ease; position: relative; overflow: hidden; + letter-spacing: 0.025em; } -.btn-primary::before { +.btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: width 0.3s ease, height 0.3s ease; +} + +.btn:active::before { + width: 300px; + height: 300px; +} + +.btn-primary { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(102, 126, 234, 0.3), + 0 4px 6px -2px rgba(102, 126, 234, 0.05); +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(102, 126, 234, 0.3), + 0 10px 10px -5px rgba(102, 126, 234, 0.1); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +/* 🎯 修复时间范围按钮样式 */ +.btn-primary { + border-radius: 12px !important; +} + +.btn { + border-radius: 12px !important; +} + +/* 🎯 时间范围按钮 - 与管理页面 tab-btn 样式一致 */ +.period-btn { + position: relative; + overflow: hidden; + border-radius: 12px; + font-weight: 500; + letter-spacing: 0.025em; + transition: all 0.3s ease; + border: none; + cursor: pointer; +} + +.period-btn::before { content: ''; position: absolute; top: 0; @@ -180,25 +344,36 @@ transition: left 0.5s; } -.btn-primary:hover::before { +.period-btn:hover::before { left: 100%; } -.btn-primary:hover { - background-position: 100% 0; - transform: translateY(-2px); - box-shadow: 0 15px 30px rgba(79, 70, 229, 0.4); +.period-btn.active { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(102, 126, 234, 0.3), + 0 4px 6px -2px rgba(102, 126, 234, 0.05); + transform: translateY(-1px); } -.btn-primary:active { - transform: translateY(0); - box-shadow: 0 5px 15px rgba(79, 70, 229, 0.3); +.period-btn:not(.active) { + color: #374151; + background: transparent; } -/* 📊 模型使用项增强 */ +.period-btn:not(.active):hover { + background: rgba(255, 255, 255, 0.1); + color: #1f2937; +} + +/* 📊 模型使用项样式 - 与管理页面保持一致 */ .model-usage-item { + background: rgba(255, 255, 255, 0.95); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 12px; + padding: 16px; transition: all 0.3s ease; - border: 1px solid rgba(255, 255, 255, 0.1); position: relative; overflow: hidden; } @@ -207,22 +382,18 @@ content: ''; position: absolute; top: 0; - left: -100%; - width: 100%; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent); - transition: left 0.6s; -} - -.model-usage-item:hover::before { - left: 100%; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent); } .model-usage-item:hover { - transform: translateX(8px) translateY(-2px); - background: rgba(255, 255, 255, 0.08); - border-color: rgba(255, 255, 255, 0.2); - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + border-color: rgba(255, 255, 255, 0.3); } /* 🔄 加载动画增强 */ diff --git a/web/userStats/index.html b/web/userStats/index.html deleted file mode 100644 index 83e99700..00000000 --- a/web/userStats/index.html +++ /dev/null @@ -1,428 +0,0 @@ - - - - - - API Key 使用统计 - Claude Relay Service - - - - - - - - - - - - - - - - - - - - - -
- -
-
-

- - API Key 使用统计 -

-

查询您的 API Key 使用情况和统计数据

-
- - -
-
-
- -
- - -
-
- - -
- - 您的 API Key 仅用于查询自己的统计数据,不会被存储或用于其他用途 -
-
-
- - -
-
-
-
- - 统计时间范围 -
-
- - -
-
-
-
- - -
-
- - {{ error }} -
-
- - -
- -
- -
-

- - API Key 信息 -

-
-
- 名称 - {{ statsData.name }} -
-
- 状态 - - - {{ statsData.isActive ? '活跃' : '已停用' }} - -
-
- 权限 - {{ formatPermissions(statsData.permissions) }} -
-
- 创建时间 - {{ formatDate(statsData.createdAt) }} -
-
- 过期时间 - {{ formatDate(statsData.expiresAt) }} -
-
-
- - -
-

- - 使用统计概览 ({{ statsPeriod === 'daily' ? '今日' : '本月' }}) -

-
-
-
{{ formatNumber(currentPeriodData.requests) }}
-
{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数
-
-
-
{{ formatNumber(currentPeriodData.allTokens) }}
-
{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数
-
-
-
{{ currentPeriodData.formattedCost || '$0.000000' }}
-
{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用
-
-
-
{{ formatNumber(currentPeriodData.inputTokens) }}
-
{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token
-
-
-
-
- - -
- -
-

- - Token 使用分布 ({{ statsPeriod === 'daily' ? '今日' : '本月' }}) -

-
-
- - - 输入 Token - - {{ formatNumber(currentPeriodData.inputTokens) }} -
-
- - - 输出 Token - - {{ formatNumber(currentPeriodData.outputTokens) }} -
-
- - - 缓存创建 Token - - {{ formatNumber(currentPeriodData.cacheCreateTokens) }} -
-
- - - 缓存读取 Token - - {{ formatNumber(currentPeriodData.cacheReadTokens) }} -
-
-
-
- {{ statsPeriod === 'daily' ? '今日' : '本月' }}总计 - {{ formatNumber(currentPeriodData.allTokens) }} -
-
-
- - -
-

- - 限制配置 -

-
-
- Token 限制 - {{ statsData.limits.tokenLimit > 0 ? formatNumber(statsData.limits.tokenLimit) : '无限制' }} -
-
- 并发限制 - {{ statsData.limits.concurrencyLimit > 0 ? statsData.limits.concurrencyLimit : '无限制' }} -
-
- 速率限制 - - {{ statsData.limits.rateLimitRequests > 0 && statsData.limits.rateLimitWindow > 0 - ? `${statsData.limits.rateLimitRequests}次/${statsData.limits.rateLimitWindow}分钟` - : '无限制' }} - -
-
- 每日费用限制 - {{ statsData.limits.dailyCostLimit > 0 ? '$' + statsData.limits.dailyCostLimit : '无限制' }} -
-
-
-
- - -
-
-

- - 模型使用统计 ({{ statsPeriod === 'daily' ? '今日' : '本月' }}) -

-
- - -
- -

加载模型统计数据中...

-
- - -
-
-
-
-

{{ model.model }}

-

{{ model.requests }} 次请求

-
-
-
{{ model.formatted?.total || '$0.000000' }}
-
总费用
-
-
- -
-
-
输入 Token
-
{{ formatNumber(model.inputTokens) }}
-
-
-
输出 Token
-
{{ formatNumber(model.outputTokens) }}
-
-
-
缓存创建
-
{{ formatNumber(model.cacheCreateTokens) }}
-
-
-
缓存读取
-
{{ formatNumber(model.cacheReadTokens) }}
-
-
-
-
- - -
- -

暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据

-
-
-
- - - -
-
- - - - - \ No newline at end of file