Files
claude-relay-service/src/routes/admin.js
shaw f962083752 feat: 为API Key添加模型限制功能
- 前端:在API Key创建和编辑表单中添加模型限制开关和标签输入
- 前端:支持动态添加/删除限制的模型列表
- 后端:更新API Key数据结构,新增enableModelRestriction和restrictedModels字段
- 后端:在中转请求时检查模型访问权限
- 修复:Enter键提交表单问题,使用@keydown.enter.prevent
- 优化:限制模型数据持久化,关闭开关时不清空数据

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-19 20:54:26 +08:00

1343 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express');
const apiKeyService = require('../services/apiKeyService');
const claudeAccountService = require('../services/claudeAccountService');
const redis = require('../models/redis');
const { authenticateAdmin } = require('../middleware/auth');
const logger = require('../utils/logger');
const oauthHelper = require('../utils/oauthHelper');
const CostCalculator = require('../utils/costCalculator');
const pricingService = require('../services/pricingService');
const router = express.Router();
// 🔑 API Keys 管理
// 获取所有API Keys
router.get('/api-keys', authenticateAdmin, async (req, res) => {
try {
const apiKeys = await apiKeyService.getAllApiKeys();
res.json({ success: true, data: apiKeys });
} catch (error) {
logger.error('❌ Failed to get API keys:', error);
res.status(500).json({ error: 'Failed to get API keys', message: error.message });
}
});
// 创建新的API Key
router.post('/api-keys', authenticateAdmin, async (req, res) => {
try {
const {
name,
description,
tokenLimit,
expiresAt,
claudeAccountId,
concurrencyLimit,
enableModelRestriction,
restrictedModels
} = req.body;
// 输入验证
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ error: 'Name is required and must be a non-empty string' });
}
if (name.length > 100) {
return res.status(400).json({ error: 'Name must be less than 100 characters' });
}
if (description && (typeof description !== 'string' || description.length > 500)) {
return res.status(400).json({ error: 'Description must be a string with less than 500 characters' });
}
if (tokenLimit && (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0)) {
return res.status(400).json({ error: 'Token limit must be a non-negative integer' });
}
if (concurrencyLimit !== undefined && concurrencyLimit !== null && concurrencyLimit !== '' && (!Number.isInteger(Number(concurrencyLimit)) || Number(concurrencyLimit) < 0)) {
return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' });
}
// 验证模型限制字段
if (enableModelRestriction !== undefined && typeof enableModelRestriction !== 'boolean') {
return res.status(400).json({ error: 'Enable model restriction must be a boolean' });
}
if (restrictedModels !== undefined && !Array.isArray(restrictedModels)) {
return res.status(400).json({ error: 'Restricted models must be an array' });
}
const newKey = await apiKeyService.generateApiKey({
name,
description,
tokenLimit,
expiresAt,
claudeAccountId,
concurrencyLimit,
enableModelRestriction,
restrictedModels
});
logger.success(`🔑 Admin created new API key: ${name}`);
res.json({ success: true, data: newKey });
} catch (error) {
logger.error('❌ Failed to create API key:', error);
res.status(500).json({ error: 'Failed to create API key', message: error.message });
}
});
// 更新API Key
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params;
const { tokenLimit, concurrencyLimit, claudeAccountId, enableModelRestriction, restrictedModels } = req.body;
// 只允许更新指定字段
const updates = {};
if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') {
if (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0) {
return res.status(400).json({ error: 'Token limit must be a non-negative integer' });
}
updates.tokenLimit = Number(tokenLimit);
}
if (concurrencyLimit !== undefined && concurrencyLimit !== null && concurrencyLimit !== '') {
if (!Number.isInteger(Number(concurrencyLimit)) || Number(concurrencyLimit) < 0) {
return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' });
}
updates.concurrencyLimit = Number(concurrencyLimit);
}
if (claudeAccountId !== undefined) {
// 空字符串表示解绑null或空字符串都设置为空字符串
updates.claudeAccountId = claudeAccountId || '';
}
// 处理模型限制字段
if (enableModelRestriction !== undefined) {
if (typeof enableModelRestriction !== 'boolean') {
return res.status(400).json({ error: 'Enable model restriction must be a boolean' });
}
updates.enableModelRestriction = enableModelRestriction;
}
if (restrictedModels !== undefined) {
if (!Array.isArray(restrictedModels)) {
return res.status(400).json({ error: 'Restricted models must be an array' });
}
updates.restrictedModels = restrictedModels;
}
await apiKeyService.updateApiKey(keyId, updates);
logger.success(`📝 Admin updated API key: ${keyId}`);
res.json({ success: true, message: 'API key updated successfully' });
} catch (error) {
logger.error('❌ Failed to update API key:', error);
res.status(500).json({ error: 'Failed to update API key', message: error.message });
}
});
// 删除API Key
router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params;
await apiKeyService.deleteApiKey(keyId);
logger.success(`🗑️ Admin deleted API key: ${keyId}`);
res.json({ success: true, message: 'API key deleted successfully' });
} catch (error) {
logger.error('❌ Failed to delete API key:', error);
res.status(500).json({ error: 'Failed to delete API key', message: error.message });
}
});
// 🏢 Claude 账户管理
// 生成OAuth授权URL
router.post('/claude-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
try {
const { proxy } = req.body; // 接收代理配置
const oauthParams = await oauthHelper.generateOAuthParams();
// 将codeVerifier和state临时存储到Redis用于后续验证
const sessionId = require('crypto').randomUUID();
await redis.setOAuthSession(sessionId, {
codeVerifier: oauthParams.codeVerifier,
state: oauthParams.state,
codeChallenge: oauthParams.codeChallenge,
proxy: proxy || null, // 存储代理配置
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期
});
logger.success('🔗 Generated OAuth authorization URL with proxy support');
res.json({
success: true,
data: {
authUrl: oauthParams.authUrl,
sessionId: sessionId,
instructions: [
'1. 复制上面的链接到浏览器中打开',
'2. 登录您的 Anthropic 账户',
'3. 同意应用权限',
'4. 复制浏览器地址栏中的完整 URL',
'5. 在添加账户表单中粘贴完整的回调 URL 和授权码'
]
}
});
} catch (error) {
logger.error('❌ Failed to generate OAuth URL:', error);
res.status(500).json({ error: 'Failed to generate OAuth URL', message: error.message });
}
});
// 验证授权码并获取token
router.post('/claude-accounts/exchange-code', authenticateAdmin, async (req, res) => {
try {
const { sessionId, authorizationCode, callbackUrl } = req.body;
if (!sessionId || (!authorizationCode && !callbackUrl)) {
return res.status(400).json({ error: 'Session ID and authorization code (or callback URL) are required' });
}
// 从Redis获取OAuth会话信息
const oauthSession = await redis.getOAuthSession(sessionId);
if (!oauthSession) {
return res.status(400).json({ error: 'Invalid or expired OAuth session' });
}
// 检查会话是否过期
if (new Date() > new Date(oauthSession.expiresAt)) {
await redis.deleteOAuthSession(sessionId);
return res.status(400).json({ error: 'OAuth session has expired, please generate a new authorization URL' });
}
// 统一处理授权码输入可能是直接的code或完整的回调URL
let finalAuthCode;
const inputValue = callbackUrl || authorizationCode;
try {
finalAuthCode = oauthHelper.parseCallbackUrl(inputValue);
} catch (parseError) {
return res.status(400).json({ error: 'Failed to parse authorization input', message: parseError.message });
}
// 交换访问令牌
const tokenData = await oauthHelper.exchangeCodeForTokens(
finalAuthCode,
oauthSession.codeVerifier,
oauthSession.state,
oauthSession.proxy // 传递代理配置
);
// 清理OAuth会话
await redis.deleteOAuthSession(sessionId);
logger.success('🎉 Successfully exchanged authorization code for tokens');
res.json({
success: true,
data: {
claudeAiOauth: tokenData
}
});
} catch (error) {
logger.error('❌ Failed to exchange authorization code:', {
error: error.message,
sessionId: req.body.sessionId,
// 不记录完整的授权码,只记录长度和前几个字符
codeLength: req.body.callbackUrl ? req.body.callbackUrl.length : (req.body.authorizationCode ? req.body.authorizationCode.length : 0),
codePrefix: req.body.callbackUrl ? req.body.callbackUrl.substring(0, 10) + '...' : (req.body.authorizationCode ? req.body.authorizationCode.substring(0, 10) + '...' : 'N/A')
});
res.status(500).json({ error: 'Failed to exchange authorization code', message: error.message });
}
});
// 获取所有Claude账户
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
try {
const accounts = await claudeAccountService.getAllAccounts();
res.json({ success: true, data: accounts });
} catch (error) {
logger.error('❌ Failed to get Claude accounts:', error);
res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message });
}
});
// 创建新的Claude账户
router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
try {
const {
name,
description,
email,
password,
refreshToken,
claudeAiOauth,
proxy,
accountType
} = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
// 验证accountType的有效性
if (accountType && !['shared', 'dedicated'].includes(accountType)) {
return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' });
}
const newAccount = await claudeAccountService.createAccount({
name,
description,
email,
password,
refreshToken,
claudeAiOauth,
proxy,
accountType: accountType || 'shared' // 默认为共享类型
});
logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`);
res.json({ success: true, data: newAccount });
} catch (error) {
logger.error('❌ Failed to create Claude account:', error);
res.status(500).json({ error: 'Failed to create Claude account', message: error.message });
}
});
// 更新Claude账户
router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params;
const updates = req.body;
await claudeAccountService.updateAccount(accountId, updates);
logger.success(`📝 Admin updated Claude account: ${accountId}`);
res.json({ success: true, message: 'Claude account updated successfully' });
} catch (error) {
logger.error('❌ Failed to update Claude account:', error);
res.status(500).json({ error: 'Failed to update Claude account', message: error.message });
}
});
// 删除Claude账户
router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params;
await claudeAccountService.deleteAccount(accountId);
logger.success(`🗑️ Admin deleted Claude account: ${accountId}`);
res.json({ success: true, message: 'Claude account deleted successfully' });
} catch (error) {
logger.error('❌ Failed to delete Claude account:', error);
res.status(500).json({ error: 'Failed to delete Claude account', message: error.message });
}
});
// 刷新Claude账户token
router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params;
const result = await claudeAccountService.refreshAccountToken(accountId);
logger.success(`🔄 Admin refreshed token for Claude account: ${accountId}`);
res.json({ success: true, data: result });
} catch (error) {
logger.error('❌ Failed to refresh Claude account token:', error);
res.status(500).json({ error: 'Failed to refresh token', message: error.message });
}
});
// 📊 系统统计
// 获取系统概览
router.get('/dashboard', authenticateAdmin, async (req, res) => {
try {
const [, apiKeys, accounts, todayStats, systemAverages] = await Promise.all([
redis.getSystemStats(),
apiKeyService.getAllApiKeys(),
claudeAccountService.getAllAccounts(),
redis.getTodayStats(),
redis.getSystemAverages()
]);
// 计算使用统计包含cache tokens
const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0);
const totalRequestsUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0);
const totalInputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.inputTokens || 0), 0);
const totalOutputTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.outputTokens || 0), 0);
const totalCacheCreateTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.cacheCreateTokens || 0), 0);
const totalCacheReadTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.cacheReadTokens || 0), 0);
const totalAllTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.allTokens || 0), 0);
const activeApiKeys = apiKeys.filter(key => key.isActive).length;
const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active').length;
const rateLimitedAccounts = accounts.filter(acc => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited).length;
const dashboard = {
overview: {
totalApiKeys: apiKeys.length,
activeApiKeys,
totalClaudeAccounts: accounts.length,
activeClaudeAccounts: activeAccounts,
rateLimitedClaudeAccounts: rateLimitedAccounts,
totalTokensUsed,
totalRequestsUsed,
totalInputTokensUsed,
totalOutputTokensUsed,
totalCacheCreateTokensUsed,
totalCacheReadTokensUsed,
totalAllTokensUsed
},
recentActivity: {
apiKeysCreatedToday: todayStats.apiKeysCreatedToday,
requestsToday: todayStats.requestsToday,
tokensToday: todayStats.tokensToday,
inputTokensToday: todayStats.inputTokensToday,
outputTokensToday: todayStats.outputTokensToday,
cacheCreateTokensToday: todayStats.cacheCreateTokensToday || 0,
cacheReadTokensToday: todayStats.cacheReadTokensToday || 0
},
systemAverages: {
rpm: systemAverages.systemRPM,
tpm: systemAverages.systemTPM
},
systemHealth: {
redisConnected: redis.isConnected,
claudeAccountsHealthy: activeAccounts > 0,
uptime: process.uptime()
}
};
res.json({ success: true, data: dashboard });
} catch (error) {
logger.error('❌ Failed to get dashboard data:', error);
res.status(500).json({ error: 'Failed to get dashboard data', message: error.message });
}
});
// 获取使用统计
router.get('/usage-stats', authenticateAdmin, async (req, res) => {
try {
const { period = 'daily' } = req.query; // daily, monthly
// 获取基础API Key统计
const apiKeys = await apiKeyService.getAllApiKeys();
const stats = apiKeys.map(key => ({
keyId: key.id,
keyName: key.name,
usage: key.usage
}));
res.json({ success: true, data: { period, stats } });
} catch (error) {
logger.error('❌ Failed to get usage stats:', error);
res.status(500).json({ error: 'Failed to get usage stats', message: error.message });
}
});
// 获取按模型的使用统计和费用
router.get('/model-stats', authenticateAdmin, async (req, res) => {
try {
const { period = 'daily' } = req.query; // daily, monthly
const today = new Date().toISOString().split('T')[0];
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
logger.info(`📊 Getting global model stats, period: ${period}, today: ${today}, currentMonth: ${currentMonth}`);
const client = redis.getClientSafe();
// 获取所有模型的统计数据
const pattern = period === 'daily' ? `usage:model:daily:*:${today}` : `usage:model:monthly:*:${currentMonth}`;
logger.info(`📊 Searching pattern: ${pattern}`);
const keys = await client.keys(pattern);
logger.info(`📊 Found ${keys.length} matching keys:`, keys);
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) {
logger.warn(`📊 Pattern mismatch for key: ${key}`);
continue;
}
const model = match[1];
const data = await client.hgetall(key);
logger.info(`📊 Model ${model} data:`, data);
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,
period,
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,
usage: {
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,
totalTokens: usage.input_tokens + usage.output_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens
},
costs: costData.costs,
formatted: costData.formatted,
pricing: costData.pricing
});
}
}
// 按总费用排序
modelStats.sort((a, b) => b.costs.total - a.costs.total);
logger.info(`📊 Returning ${modelStats.length} global model stats for period ${period}:`, modelStats);
res.json({ success: true, data: modelStats });
} catch (error) {
logger.error('❌ Failed to get model stats:', error);
res.status(500).json({ error: 'Failed to get model stats', message: error.message });
}
});
// 🔧 系统管理
// 清理过期数据
router.post('/cleanup', authenticateAdmin, async (req, res) => {
try {
const [expiredKeys, errorAccounts] = await Promise.all([
apiKeyService.cleanupExpiredKeys(),
claudeAccountService.cleanupErrorAccounts()
]);
await redis.cleanup();
logger.success(`🧹 Admin triggered cleanup: ${expiredKeys} expired keys, ${errorAccounts} error accounts`);
res.json({
success: true,
message: 'Cleanup completed',
data: {
expiredKeysRemoved: expiredKeys,
errorAccountsReset: errorAccounts
}
});
} catch (error) {
logger.error('❌ Cleanup failed:', error);
res.status(500).json({ error: 'Cleanup failed', message: error.message });
}
});
// 获取使用趋势数据
router.get('/usage-trend', authenticateAdmin, async (req, res) => {
try {
const { days = 7, granularity = 'day', startDate, endDate } = req.query;
const client = redis.getClientSafe();
const trendData = [];
if (granularity === 'hour') {
// 小时粒度统计
let startTime, endTime;
if (startDate && endDate) {
// 使用自定义时间范围
startTime = new Date(startDate);
endTime = new Date(endDate);
} else {
// 默认最近24小时
endTime = new Date();
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000);
}
// 确保时间范围不超过24小时
const timeDiff = endTime - startTime;
if (timeDiff > 24 * 60 * 60 * 1000) {
return res.status(400).json({
error: '小时粒度查询时间范围不能超过24小时'
});
}
// 按小时遍历
const currentHour = new Date(startTime);
currentHour.setMinutes(0, 0, 0);
while (currentHour <= endTime) {
const dateStr = currentHour.toISOString().split('T')[0];
const hour = String(currentHour.getHours()).padStart(2, '0');
const hourKey = `${dateStr}:${hour}`;
// 获取当前小时的模型统计数据
const modelPattern = `usage:model:hourly:*:${hourKey}`;
const modelKeys = await client.keys(modelPattern);
let hourInputTokens = 0;
let hourOutputTokens = 0;
let hourRequests = 0;
let hourCacheCreateTokens = 0;
let hourCacheReadTokens = 0;
let hourCost = 0;
for (const modelKey of modelKeys) {
const modelMatch = modelKey.match(/usage:model:hourly:(.+):\d{4}-\d{2}-\d{2}:\d{2}$/);
if (!modelMatch) continue;
const model = modelMatch[1];
const data = await client.hgetall(modelKey);
if (data && Object.keys(data).length > 0) {
const modelInputTokens = parseInt(data.inputTokens) || 0;
const modelOutputTokens = parseInt(data.outputTokens) || 0;
const modelCacheCreateTokens = parseInt(data.cacheCreateTokens) || 0;
const modelCacheReadTokens = parseInt(data.cacheReadTokens) || 0;
const modelRequests = parseInt(data.requests) || 0;
hourInputTokens += modelInputTokens;
hourOutputTokens += modelOutputTokens;
hourCacheCreateTokens += modelCacheCreateTokens;
hourCacheReadTokens += modelCacheReadTokens;
hourRequests += modelRequests;
const modelUsage = {
input_tokens: modelInputTokens,
output_tokens: modelOutputTokens,
cache_creation_input_tokens: modelCacheCreateTokens,
cache_read_input_tokens: modelCacheReadTokens
};
const modelCostResult = CostCalculator.calculateCost(modelUsage, model);
hourCost += modelCostResult.costs.total;
}
}
// 如果没有模型级别的数据尝试API Key级别的数据
if (modelKeys.length === 0) {
const pattern = `usage:hourly:*:${hourKey}`;
const keys = await client.keys(pattern);
for (const key of keys) {
const data = await client.hgetall(key);
if (data) {
hourInputTokens += parseInt(data.inputTokens) || 0;
hourOutputTokens += parseInt(data.outputTokens) || 0;
hourRequests += parseInt(data.requests) || 0;
hourCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0;
hourCacheReadTokens += parseInt(data.cacheReadTokens) || 0;
}
}
const usage = {
input_tokens: hourInputTokens,
output_tokens: hourOutputTokens,
cache_creation_input_tokens: hourCacheCreateTokens,
cache_read_input_tokens: hourCacheReadTokens
};
const costResult = CostCalculator.calculateCost(usage, 'unknown');
hourCost = costResult.costs.total;
}
trendData.push({
date: hourKey,
hour: currentHour.toISOString(),
inputTokens: hourInputTokens,
outputTokens: hourOutputTokens,
requests: hourRequests,
cacheCreateTokens: hourCacheCreateTokens,
cacheReadTokens: hourCacheReadTokens,
totalTokens: hourInputTokens + hourOutputTokens + hourCacheCreateTokens + hourCacheReadTokens,
cost: hourCost
});
// 移到下一个小时
currentHour.setHours(currentHour.getHours() + 1);
}
} else {
// 天粒度统计(保持原有逻辑)
const daysCount = parseInt(days) || 7;
const today = new Date();
// 获取过去N天的数据
for (let i = 0; i < daysCount; i++) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
// 汇总当天所有API Key的使用数据
const pattern = `usage:daily:*:${dateStr}`;
const keys = await client.keys(pattern);
let dayInputTokens = 0;
let dayOutputTokens = 0;
let dayRequests = 0;
let dayCacheCreateTokens = 0;
let dayCacheReadTokens = 0;
let dayCost = 0;
// 按模型统计使用量
// const modelUsageMap = new Map();
// 获取当天所有模型的使用数据
const modelPattern = `usage:model:daily:*:${dateStr}`;
const modelKeys = await client.keys(modelPattern);
for (const modelKey of modelKeys) {
// 解析模型名称
const modelMatch = modelKey.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/);
if (!modelMatch) continue;
const model = modelMatch[1];
const data = await client.hgetall(modelKey);
if (data && Object.keys(data).length > 0) {
const modelInputTokens = parseInt(data.inputTokens) || 0;
const modelOutputTokens = parseInt(data.outputTokens) || 0;
const modelCacheCreateTokens = parseInt(data.cacheCreateTokens) || 0;
const modelCacheReadTokens = parseInt(data.cacheReadTokens) || 0;
const modelRequests = parseInt(data.requests) || 0;
// 累加总数
dayInputTokens += modelInputTokens;
dayOutputTokens += modelOutputTokens;
dayCacheCreateTokens += modelCacheCreateTokens;
dayCacheReadTokens += modelCacheReadTokens;
dayRequests += modelRequests;
// 按模型计算费用
const modelUsage = {
input_tokens: modelInputTokens,
output_tokens: modelOutputTokens,
cache_creation_input_tokens: modelCacheCreateTokens,
cache_read_input_tokens: modelCacheReadTokens
};
const modelCostResult = CostCalculator.calculateCost(modelUsage, model);
dayCost += modelCostResult.costs.total;
}
}
// 如果没有模型级别的数据,回退到原始方法
if (modelKeys.length === 0 && keys.length > 0) {
for (const key of keys) {
const data = await client.hgetall(key);
if (data) {
dayInputTokens += parseInt(data.inputTokens) || 0;
dayOutputTokens += parseInt(data.outputTokens) || 0;
dayRequests += parseInt(data.requests) || 0;
dayCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0;
dayCacheReadTokens += parseInt(data.cacheReadTokens) || 0;
}
}
// 使用默认模型价格计算
const usage = {
input_tokens: dayInputTokens,
output_tokens: dayOutputTokens,
cache_creation_input_tokens: dayCacheCreateTokens,
cache_read_input_tokens: dayCacheReadTokens
};
const costResult = CostCalculator.calculateCost(usage, 'unknown');
dayCost = costResult.costs.total;
}
trendData.push({
date: dateStr,
inputTokens: dayInputTokens,
outputTokens: dayOutputTokens,
requests: dayRequests,
cacheCreateTokens: dayCacheCreateTokens,
cacheReadTokens: dayCacheReadTokens,
totalTokens: dayInputTokens + dayOutputTokens + dayCacheCreateTokens + dayCacheReadTokens,
cost: dayCost,
formattedCost: CostCalculator.formatCost(dayCost)
});
}
}
// 按日期正序排列
if (granularity === 'hour') {
trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour));
} else {
trendData.sort((a, b) => new Date(a.date) - new Date(b.date));
}
res.json({ success: true, data: trendData, granularity });
} catch (error) {
logger.error('❌ Failed to get usage trend:', error);
res.status(500).json({ error: 'Failed to get usage trend', message: error.message });
}
});
// 获取单个API Key的模型统计
router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params;
const { period = 'monthly', startDate, endDate } = req.query;
logger.info(`📊 Getting model stats for API key: ${keyId}, period: ${period}, startDate: ${startDate}, endDate: ${endDate}`);
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')}`;
let searchPatterns = [];
if (period === 'custom' && startDate && endDate) {
// 自定义日期范围,生成多个日期的搜索模式
const start = new Date(startDate);
const end = new Date(endDate);
// 确保日期范围有效
if (start > end) {
return res.status(400).json({ error: 'Start date must be before or equal to end date' });
}
// 限制最大范围为31天
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;
if (daysDiff > 31) {
return res.status(400).json({ error: 'Date range cannot exceed 31 days' });
}
// 生成日期范围内所有日期的搜索模式
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
searchPatterns.push(`usage:${keyId}:model:daily:*:${dateStr}`);
}
logger.info(`📊 Custom date range patterns: ${searchPatterns.length} days from ${startDate} to ${endDate}`);
} else {
// 原有的预设期间逻辑
const pattern = period === 'daily' ?
`usage:${keyId}:model:daily:*:${today}` :
`usage:${keyId}:model:monthly:*:${currentMonth}`;
searchPatterns = [pattern];
logger.info(`📊 Preset period pattern: ${pattern}`);
}
// 汇总所有匹配的数据
const modelStatsMap = new Map();
const modelStats = []; // 定义结果数组
for (const pattern of searchPatterns) {
const keys = await client.keys(pattern);
logger.info(`📊 Pattern ${pattern} found ${keys.length} keys`);
for (const key of keys) {
const match = key.match(/usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) ||
key.match(/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/);
if (!match) {
logger.warn(`📊 Pattern mismatch for key: ${key}`);
continue;
}
const model = match[1];
const data = await client.hgetall(key);
if (data && Object.keys(data).length > 0) {
// 累加同一模型的数据
if (!modelStatsMap.has(model)) {
modelStatsMap.set(model, {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0
});
}
const stats = modelStatsMap.get(model);
stats.requests += parseInt(data.requests) || 0;
stats.inputTokens += parseInt(data.inputTokens) || 0;
stats.outputTokens += parseInt(data.outputTokens) || 0;
stats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0;
stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0;
stats.allTokens += parseInt(data.allTokens) || 0;
}
}
}
// 将汇总的数据转换为最终结果
for (const [model, stats] of modelStatsMap) {
logger.info(`📊 Model ${model} aggregated data:`, stats);
const usage = {
input_tokens: stats.inputTokens,
output_tokens: stats.outputTokens,
cache_creation_input_tokens: stats.cacheCreateTokens,
cache_read_input_tokens: stats.cacheReadTokens
};
// 使用CostCalculator计算费用
const costData = CostCalculator.calculateCost(usage, model);
modelStats.push({
model,
requests: stats.requests,
inputTokens: stats.inputTokens,
outputTokens: stats.outputTokens,
cacheCreateTokens: stats.cacheCreateTokens,
cacheReadTokens: stats.cacheReadTokens,
allTokens: stats.allTokens,
// 添加费用信息
costs: costData.costs,
formatted: costData.formatted,
pricing: costData.pricing,
usingDynamicPricing: costData.usingDynamicPricing
});
}
// 如果没有找到模型级别的详细数据,尝试从汇总数据中生成展示
if (modelStats.length === 0) {
logger.info(`📊 No detailed model stats found, trying to get aggregate data for API key ${keyId}`);
// 尝试从API Keys列表中获取usage数据作为备选方案
try {
const apiKeys = await apiKeyService.getAllApiKeys();
const targetApiKey = apiKeys.find(key => key.id === keyId);
if (targetApiKey && targetApiKey.usage) {
logger.info(`📊 Found API key usage data from getAllApiKeys for ${keyId}:`, targetApiKey.usage);
// 从汇总数据创建展示条目
let usageData;
if (period === 'custom' || period === 'daily') {
// 对于自定义或日统计使用daily数据或total数据
usageData = targetApiKey.usage.daily || targetApiKey.usage.total;
} else {
// 对于月统计使用monthly数据或total数据
usageData = targetApiKey.usage.monthly || targetApiKey.usage.total;
}
if (usageData && 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,
usingDynamicPricing: costData.usingDynamicPricing
});
logger.info('📊 Generated display data from API key usage stats');
} else {
logger.info(`📊 No usage data found for period ${period} in API key data`);
}
} else {
logger.info(`📊 API key ${keyId} not found or has no usage data`);
}
} catch (error) {
logger.error('❌ Error fetching API key usage data:', error);
}
}
// 按总token数降序排列
modelStats.sort((a, b) => b.allTokens - a.allTokens);
logger.info(`📊 Returning ${modelStats.length} model stats for API key ${keyId}:`, modelStats);
res.json({ success: true, data: modelStats });
} catch (error) {
logger.error('❌ Failed to get API key model stats:', error);
res.status(500).json({ error: 'Failed to get API key model stats', message: error.message });
}
});
// 获取按API Key分组的使用趋势
router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
try {
const { granularity = 'day', days = 7, startDate, endDate } = req.query;
logger.info(`📊 Getting API keys usage trend, granularity: ${granularity}, days: ${days}`);
const client = redis.getClientSafe();
const trendData = [];
// 获取所有API Keys
const apiKeys = await apiKeyService.getAllApiKeys();
const apiKeyMap = new Map(apiKeys.map(key => [key.id, key]));
if (granularity === 'hour') {
// 小时粒度统计
let endTime, startTime;
if (startDate && endDate) {
// 自定义时间范围
startTime = new Date(startDate);
endTime = new Date(endDate);
} else {
// 默认近24小时
endTime = new Date();
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000);
}
// 按小时遍历
const currentHour = new Date(startTime);
currentHour.setMinutes(0, 0, 0);
while (currentHour <= endTime) {
const hourKey = currentHour.toISOString().split(':')[0].replace('T', ':');
// 获取这个小时所有API Key的数据
const pattern = `usage:hourly:*:${hourKey}`;
const keys = await client.keys(pattern);
const hourData = {
hour: currentHour.toISOString(),
apiKeys: {}
};
for (const key of keys) {
const match = key.match(/usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/);
if (!match) continue;
const apiKeyId = match[1];
const data = await client.hgetall(key);
if (data && apiKeyMap.has(apiKeyId)) {
const totalTokens = (parseInt(data.inputTokens) || 0) +
(parseInt(data.outputTokens) || 0) +
(parseInt(data.cacheCreateTokens) || 0) +
(parseInt(data.cacheReadTokens) || 0);
hourData.apiKeys[apiKeyId] = {
name: apiKeyMap.get(apiKeyId).name,
tokens: totalTokens
};
}
}
trendData.push(hourData);
currentHour.setHours(currentHour.getHours() + 1);
}
} else {
// 天粒度统计
const daysCount = parseInt(days) || 7;
const today = new Date();
// 获取过去N天的数据
for (let i = 0; i < daysCount; i++) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
// 获取这一天所有API Key的数据
const pattern = `usage:daily:*:${dateStr}`;
const keys = await client.keys(pattern);
const dayData = {
date: dateStr,
apiKeys: {}
};
for (const key of keys) {
const match = key.match(/usage:daily:(.+?):\d{4}-\d{2}-\d{2}/);
if (!match) continue;
const apiKeyId = match[1];
const data = await client.hgetall(key);
if (data && apiKeyMap.has(apiKeyId)) {
const totalTokens = (parseInt(data.inputTokens) || 0) +
(parseInt(data.outputTokens) || 0) +
(parseInt(data.cacheCreateTokens) || 0) +
(parseInt(data.cacheReadTokens) || 0);
dayData.apiKeys[apiKeyId] = {
name: apiKeyMap.get(apiKeyId).name,
tokens: totalTokens
};
}
}
trendData.push(dayData);
}
}
// 按时间正序排列
if (granularity === 'hour') {
trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour));
} else {
trendData.sort((a, b) => new Date(a.date) - new Date(b.date));
}
// 计算每个API Key的总token数用于排序
const apiKeyTotals = new Map();
for (const point of trendData) {
for (const [apiKeyId, data] of Object.entries(point.apiKeys)) {
apiKeyTotals.set(apiKeyId, (apiKeyTotals.get(apiKeyId) || 0) + data.tokens);
}
}
// 获取前10个使用量最多的API Key
const topApiKeys = Array.from(apiKeyTotals.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([apiKeyId]) => apiKeyId);
res.json({
success: true,
data: trendData,
granularity,
topApiKeys,
totalApiKeys: apiKeyTotals.size
});
} catch (error) {
logger.error('❌ Failed to get API keys usage trend:', error);
res.status(500).json({ error: 'Failed to get API keys usage trend', message: error.message });
}
});
// 计算总体使用费用
router.get('/usage-costs', authenticateAdmin, async (req, res) => {
try {
const { period = 'all' } = req.query; // all, today, monthly
logger.info(`💰 Calculating usage costs for period: ${period}`);
// 获取所有API Keys的使用统计
const apiKeys = await apiKeyService.getAllApiKeys();
let totalCosts = {
inputCost: 0,
outputCost: 0,
cacheCreateCost: 0,
cacheReadCost: 0,
totalCost: 0
};
let modelCosts = {};
// 按模型统计费用
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')}`;
let pattern;
if (period === 'today') {
pattern = `usage:model:daily:*:${today}`;
} else if (period === 'monthly') {
pattern = `usage:model:monthly:*:${currentMonth}`;
} else {
// 全部时间先尝试从Redis获取所有历史模型统计数据
const allModelKeys = await client.keys('usage:model:*:*');
logger.info(`💰 Total period calculation: found ${allModelKeys.length} model keys`);
if (allModelKeys.length > 0) {
// 如果有详细的模型统计数据,使用模型级别的计算
const modelUsageMap = new Map();
for (const key of allModelKeys) {
// 解析模型名称
let modelMatch = key.match(/usage:model:(?:daily|monthly):(.+):\d{4}-\d{2}(?:-\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;
}
}
// 使用模型级别的数据计算费用
logger.info(`💰 Processing ${modelUsageMap.size} unique models for total cost calculation`);
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);
totalCosts.inputCost += costResult.costs.input;
totalCosts.outputCost += costResult.costs.output;
totalCosts.cacheCreateCost += costResult.costs.cacheWrite;
totalCosts.cacheReadCost += costResult.costs.cacheRead;
totalCosts.totalCost += costResult.costs.total;
logger.info(`💰 Model ${model}: ${usage.inputTokens + usage.outputTokens + usage.cacheCreateTokens + usage.cacheReadTokens} tokens, cost: ${costResult.formatted.total}`);
// 记录模型费用
modelCosts[model] = {
model,
requests: 0, // 历史汇总数据没有请求数
usage: usageData,
costs: costResult.costs,
formatted: costResult.formatted,
usingDynamicPricing: costResult.usingDynamicPricing
};
}
} else {
// 如果没有详细的模型统计数据回退到API Key汇总数据
logger.warn('No detailed model statistics found, falling back to API Key aggregated data');
for (const apiKey of apiKeys) {
if (apiKey.usage && apiKey.usage.total) {
const usage = {
input_tokens: apiKey.usage.total.inputTokens || 0,
output_tokens: apiKey.usage.total.outputTokens || 0,
cache_creation_input_tokens: apiKey.usage.total.cacheCreateTokens || 0,
cache_read_input_tokens: apiKey.usage.total.cacheReadTokens || 0
};
// 使用加权平均价格计算(基于当前活跃模型的价格分布)
const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022');
totalCosts.inputCost += costResult.costs.input;
totalCosts.outputCost += costResult.costs.output;
totalCosts.cacheCreateCost += costResult.costs.cacheWrite;
totalCosts.cacheReadCost += costResult.costs.cacheRead;
totalCosts.totalCost += costResult.costs.total;
}
}
}
res.json({
success: true,
data: {
period,
totalCosts: {
...totalCosts,
formatted: {
inputCost: CostCalculator.formatCost(totalCosts.inputCost),
outputCost: CostCalculator.formatCost(totalCosts.outputCost),
cacheCreateCost: CostCalculator.formatCost(totalCosts.cacheCreateCost),
cacheReadCost: CostCalculator.formatCost(totalCosts.cacheReadCost),
totalCost: CostCalculator.formatCost(totalCosts.totalCost)
}
},
modelCosts: Object.values(modelCosts).sort((a, b) => b.costs.total - a.costs.total),
pricingServiceStatus: pricingService.getStatus()
}
});
return;
}
// 对于今日或本月从Redis获取详细的模型统计
const keys = await client.keys(pattern);
for (const key of keys) {
const match = key.match(period === 'today' ?
/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 costResult = CostCalculator.calculateCost(usage, model);
// 累加总费用
totalCosts.inputCost += costResult.costs.input;
totalCosts.outputCost += costResult.costs.output;
totalCosts.cacheCreateCost += costResult.costs.cacheWrite;
totalCosts.cacheReadCost += costResult.costs.cacheRead;
totalCosts.totalCost += costResult.costs.total;
// 记录模型费用
modelCosts[model] = {
model,
requests: parseInt(data.requests) || 0,
usage,
costs: costResult.costs,
formatted: costResult.formatted,
usingDynamicPricing: costResult.usingDynamicPricing
};
}
}
res.json({
success: true,
data: {
period,
totalCosts: {
...totalCosts,
formatted: {
inputCost: CostCalculator.formatCost(totalCosts.inputCost),
outputCost: CostCalculator.formatCost(totalCosts.outputCost),
cacheCreateCost: CostCalculator.formatCost(totalCosts.cacheCreateCost),
cacheReadCost: CostCalculator.formatCost(totalCosts.cacheReadCost),
totalCost: CostCalculator.formatCost(totalCosts.totalCost)
}
},
modelCosts: Object.values(modelCosts).sort((a, b) => b.costs.total - a.costs.total),
pricingServiceStatus: pricingService.getStatus()
}
});
} catch (error) {
logger.error('❌ Failed to calculate usage costs:', error);
res.status(500).json({ error: 'Failed to calculate usage costs', message: error.message });
}
});
module.exports = router;