mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge remote-tracking branch 'origin/main' into dev
This commit is contained in:
16
src/app.js
16
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');
|
||||
@@ -51,6 +52,16 @@ class Application {
|
||||
logger.info('🔄 Initializing admin credentials...');
|
||||
await this.initializeAdmin();
|
||||
|
||||
// 💰 初始化费用数据
|
||||
logger.info('💰 Checking cost data initialization...');
|
||||
const costInitService = require('./services/costInitService');
|
||||
const needsInit = await costInitService.needsInitialization();
|
||||
if (needsInit) {
|
||||
logger.info('💰 Initializing cost data for all API Keys...');
|
||||
const result = await costInitService.initializeAllCosts();
|
||||
logger.info(`💰 Cost initialization completed: ${result.processed} processed, ${result.errors} errors`);
|
||||
}
|
||||
|
||||
// 🛡️ 安全中间件
|
||||
this.app.use(helmet({
|
||||
contentSecurityPolicy: false, // 允许内联样式和脚本
|
||||
@@ -110,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');
|
||||
});
|
||||
|
||||
// 🏥 增强的健康检查端点
|
||||
|
||||
32
src/cli/initCosts.js
Normal file
32
src/cli/initCosts.js
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const costInitService = require('../services/costInitService');
|
||||
const logger = require('../utils/logger');
|
||||
const redis = require('../models/redis');
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// 连接Redis
|
||||
await redis.connect();
|
||||
|
||||
console.log('💰 Starting cost data initialization...\n');
|
||||
|
||||
// 执行初始化
|
||||
const result = await costInitService.initializeAllCosts();
|
||||
|
||||
console.log('\n✅ Cost initialization completed!');
|
||||
console.log(` Processed: ${result.processed} API Keys`);
|
||||
console.log(` Errors: ${result.errors}`);
|
||||
|
||||
// 断开连接
|
||||
await redis.disconnect();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n❌ Cost initialization failed:', error.message);
|
||||
logger.error('Cost initialization failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行主函数
|
||||
main();
|
||||
@@ -2,6 +2,7 @@ const apiKeyService = require('../services/apiKeyService');
|
||||
const logger = require('../utils/logger');
|
||||
const redis = require('../models/redis');
|
||||
const { RateLimiterRedis } = require('rate-limiter-flexible');
|
||||
const config = require('../../config/config');
|
||||
|
||||
// 🔑 API Key验证中间件(优化版)
|
||||
const authenticateApiKey = async (req, res, next) => {
|
||||
@@ -42,6 +43,52 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 🔒 检查客户端限制
|
||||
if (validation.keyData.enableClientRestriction && validation.keyData.allowedClients?.length > 0) {
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown';
|
||||
|
||||
// 记录客户端限制检查开始
|
||||
logger.api(`🔍 Checking client restriction for key: ${validation.keyData.id} (${validation.keyData.name})`);
|
||||
logger.api(` User-Agent: "${userAgent}"`);
|
||||
logger.api(` Allowed clients: ${validation.keyData.allowedClients.join(', ')}`);
|
||||
|
||||
let clientAllowed = false;
|
||||
let matchedClient = null;
|
||||
|
||||
// 遍历允许的客户端列表
|
||||
for (const allowedClientId of validation.keyData.allowedClients) {
|
||||
// 在预定义客户端列表中查找
|
||||
const predefinedClient = config.clientRestrictions.predefinedClients.find(
|
||||
client => client.id === allowedClientId
|
||||
);
|
||||
|
||||
if (predefinedClient) {
|
||||
// 使用预定义的正则表达式匹配 User-Agent
|
||||
if (predefinedClient.userAgentPattern.test(userAgent)) {
|
||||
clientAllowed = true;
|
||||
matchedClient = predefinedClient.name;
|
||||
break;
|
||||
}
|
||||
} else if (config.clientRestrictions.allowCustomClients) {
|
||||
// 如果允许自定义客户端,这里可以添加自定义客户端的验证逻辑
|
||||
// 目前暂时跳过自定义客户端
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clientAllowed) {
|
||||
logger.security(`🚫 Client restriction failed for key: ${validation.keyData.id} (${validation.keyData.name}) from ${clientIP}, User-Agent: ${userAgent}`);
|
||||
return res.status(403).json({
|
||||
error: 'Client not allowed',
|
||||
message: 'Your client is not authorized to use this API key',
|
||||
allowedClients: validation.keyData.allowedClients
|
||||
});
|
||||
}
|
||||
|
||||
logger.api(`✅ Client validated: ${matchedClient} for key: ${validation.keyData.id} (${validation.keyData.name})`);
|
||||
logger.api(` Matched client: ${matchedClient} with User-Agent: "${userAgent}"`);
|
||||
}
|
||||
|
||||
// 检查并发限制
|
||||
const concurrencyLimit = validation.keyData.concurrencyLimit || 0;
|
||||
@@ -192,6 +239,27 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
};
|
||||
}
|
||||
|
||||
// 检查每日费用限制
|
||||
const dailyCostLimit = validation.keyData.dailyCostLimit || 0;
|
||||
if (dailyCostLimit > 0) {
|
||||
const dailyCost = validation.keyData.dailyCost || 0;
|
||||
|
||||
if (dailyCost >= dailyCostLimit) {
|
||||
logger.security(`💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`);
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Daily cost limit exceeded',
|
||||
message: `已达到每日费用限制 ($${dailyCostLimit})`,
|
||||
currentCost: dailyCost,
|
||||
costLimit: dailyCostLimit,
|
||||
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() // 明天0点重置
|
||||
});
|
||||
}
|
||||
|
||||
// 记录当前费用使用情况
|
||||
logger.api(`💰 Cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`);
|
||||
}
|
||||
|
||||
// 将验证信息添加到请求对象(只包含必要信息)
|
||||
req.apiKey = {
|
||||
id: validation.keyData.id,
|
||||
@@ -205,12 +273,18 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
rateLimitRequests: validation.keyData.rateLimitRequests,
|
||||
enableModelRestriction: validation.keyData.enableModelRestriction,
|
||||
restrictedModels: validation.keyData.restrictedModels,
|
||||
enableClientRestriction: validation.keyData.enableClientRestriction,
|
||||
allowedClients: validation.keyData.allowedClients,
|
||||
dailyCostLimit: validation.keyData.dailyCostLimit,
|
||||
dailyCost: validation.keyData.dailyCost,
|
||||
usage: validation.keyData.usage
|
||||
};
|
||||
req.usage = validation.keyData.usage;
|
||||
|
||||
const authDuration = Date.now() - startTime;
|
||||
const userAgent = req.headers['user-agent'] || 'No User-Agent';
|
||||
logger.api(`🔓 Authenticated request from key: ${validation.keyData.name} (${validation.keyData.id}) in ${authDuration}ms`);
|
||||
logger.api(` User-Agent: "${userAgent}"`);
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
|
||||
@@ -282,6 +282,104 @@ class RedisClient {
|
||||
]);
|
||||
}
|
||||
|
||||
// 📊 记录账户级别的使用统计
|
||||
async incrementAccountUsage(accountId, totalTokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
|
||||
const now = new Date();
|
||||
const today = getDateStringInTimezone(now);
|
||||
const tzDate = getDateInTimezone(now);
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}`;
|
||||
|
||||
// 账户级别统计的键
|
||||
const accountKey = `account_usage:${accountId}`;
|
||||
const accountDaily = `account_usage:daily:${accountId}:${today}`;
|
||||
const accountMonthly = `account_usage:monthly:${accountId}:${currentMonth}`;
|
||||
const accountHourly = `account_usage:hourly:${accountId}:${currentHour}`;
|
||||
|
||||
// 账户按模型统计的键
|
||||
const accountModelDaily = `account_usage:model:daily:${accountId}:${model}:${today}`;
|
||||
const accountModelMonthly = `account_usage:model:monthly:${accountId}:${model}:${currentMonth}`;
|
||||
const accountModelHourly = `account_usage:model:hourly:${accountId}:${model}:${currentHour}`;
|
||||
|
||||
// 处理token分配
|
||||
const finalInputTokens = inputTokens || 0;
|
||||
const finalOutputTokens = outputTokens || 0;
|
||||
const finalCacheCreateTokens = cacheCreateTokens || 0;
|
||||
const finalCacheReadTokens = cacheReadTokens || 0;
|
||||
const actualTotalTokens = finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens;
|
||||
const coreTokens = finalInputTokens + finalOutputTokens;
|
||||
|
||||
await Promise.all([
|
||||
// 账户总体统计
|
||||
this.client.hincrby(accountKey, 'totalTokens', coreTokens),
|
||||
this.client.hincrby(accountKey, 'totalInputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountKey, 'totalOutputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountKey, 'totalCacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(accountKey, 'totalCacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(accountKey, 'totalAllTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountKey, 'totalRequests', 1),
|
||||
|
||||
// 账户每日统计
|
||||
this.client.hincrby(accountDaily, 'tokens', coreTokens),
|
||||
this.client.hincrby(accountDaily, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountDaily, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountDaily, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(accountDaily, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(accountDaily, 'allTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountDaily, 'requests', 1),
|
||||
|
||||
// 账户每月统计
|
||||
this.client.hincrby(accountMonthly, 'tokens', coreTokens),
|
||||
this.client.hincrby(accountMonthly, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountMonthly, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountMonthly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(accountMonthly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(accountMonthly, 'allTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountMonthly, 'requests', 1),
|
||||
|
||||
// 账户每小时统计
|
||||
this.client.hincrby(accountHourly, 'tokens', coreTokens),
|
||||
this.client.hincrby(accountHourly, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountHourly, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountHourly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(accountHourly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(accountHourly, 'allTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountHourly, 'requests', 1),
|
||||
|
||||
// 账户按模型统计 - 每日
|
||||
this.client.hincrby(accountModelDaily, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountModelDaily, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountModelDaily, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(accountModelDaily, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(accountModelDaily, 'allTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountModelDaily, 'requests', 1),
|
||||
|
||||
// 账户按模型统计 - 每月
|
||||
this.client.hincrby(accountModelMonthly, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountModelMonthly, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(accountModelMonthly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(accountModelMonthly, 'allTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountModelMonthly, 'requests', 1),
|
||||
|
||||
// 账户按模型统计 - 每小时
|
||||
this.client.hincrby(accountModelHourly, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(accountModelHourly, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(accountModelHourly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(accountModelHourly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(accountModelHourly, 'allTokens', actualTotalTokens),
|
||||
this.client.hincrby(accountModelHourly, 'requests', 1),
|
||||
|
||||
// 设置过期时间
|
||||
this.client.expire(accountDaily, 86400 * 32), // 32天过期
|
||||
this.client.expire(accountMonthly, 86400 * 365), // 1年过期
|
||||
this.client.expire(accountHourly, 86400 * 7), // 7天过期
|
||||
this.client.expire(accountModelDaily, 86400 * 32), // 32天过期
|
||||
this.client.expire(accountModelMonthly, 86400 * 365), // 1年过期
|
||||
this.client.expire(accountModelHourly, 86400 * 7) // 7天过期
|
||||
]);
|
||||
}
|
||||
|
||||
async getUsageStats(keyId) {
|
||||
const totalKey = `usage:${keyId}`;
|
||||
const today = getDateStringInTimezone();
|
||||
@@ -324,11 +422,13 @@ class RedisClient {
|
||||
const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0;
|
||||
|
||||
const totalFromSeparate = inputTokens + outputTokens;
|
||||
// 计算实际的总tokens(包含所有类型)
|
||||
const actualAllTokens = allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens);
|
||||
|
||||
if (totalFromSeparate === 0 && tokens > 0) {
|
||||
// 旧数据:没有输入输出分离
|
||||
return {
|
||||
tokens,
|
||||
tokens: tokens, // 保持兼容性,但统一使用allTokens
|
||||
inputTokens: Math.round(tokens * 0.3), // 假设30%为输入
|
||||
outputTokens: Math.round(tokens * 0.7), // 假设70%为输出
|
||||
cacheCreateTokens: 0, // 旧数据没有缓存token
|
||||
@@ -337,14 +437,14 @@ class RedisClient {
|
||||
requests
|
||||
};
|
||||
} else {
|
||||
// 新数据或无数据
|
||||
// 新数据或无数据 - 统一使用allTokens作为tokens的值
|
||||
return {
|
||||
tokens,
|
||||
tokens: actualAllTokens, // 统一使用allTokens作为总数
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
allTokens: allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens), // 计算或使用存储的值
|
||||
allTokens: actualAllTokens,
|
||||
requests
|
||||
};
|
||||
}
|
||||
@@ -367,6 +467,170 @@ class RedisClient {
|
||||
};
|
||||
}
|
||||
|
||||
// 💰 获取当日费用
|
||||
async getDailyCost(keyId) {
|
||||
const today = getDateStringInTimezone();
|
||||
const costKey = `usage:cost:daily:${keyId}:${today}`;
|
||||
const cost = await this.client.get(costKey);
|
||||
const result = parseFloat(cost || 0);
|
||||
logger.debug(`💰 Getting daily cost for ${keyId}, date: ${today}, key: ${costKey}, value: ${cost}, result: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 💰 增加当日费用
|
||||
async incrementDailyCost(keyId, amount) {
|
||||
const today = getDateStringInTimezone();
|
||||
const tzDate = getDateInTimezone();
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const currentHour = `${today}:${String(getHourInTimezone()).padStart(2, '0')}`;
|
||||
|
||||
const dailyKey = `usage:cost:daily:${keyId}:${today}`;
|
||||
const monthlyKey = `usage:cost:monthly:${keyId}:${currentMonth}`;
|
||||
const hourlyKey = `usage:cost:hourly:${keyId}:${currentHour}`;
|
||||
const totalKey = `usage:cost:total:${keyId}`;
|
||||
|
||||
logger.debug(`💰 Incrementing cost for ${keyId}, amount: $${amount}, date: ${today}, dailyKey: ${dailyKey}`);
|
||||
|
||||
const results = await Promise.all([
|
||||
this.client.incrbyfloat(dailyKey, amount),
|
||||
this.client.incrbyfloat(monthlyKey, amount),
|
||||
this.client.incrbyfloat(hourlyKey, amount),
|
||||
this.client.incrbyfloat(totalKey, amount),
|
||||
// 设置过期时间
|
||||
this.client.expire(dailyKey, 86400 * 30), // 30天
|
||||
this.client.expire(monthlyKey, 86400 * 90), // 90天
|
||||
this.client.expire(hourlyKey, 86400 * 7) // 7天
|
||||
]);
|
||||
|
||||
logger.debug(`💰 Cost incremented successfully, new daily total: $${results[0]}`);
|
||||
}
|
||||
|
||||
// 💰 获取费用统计
|
||||
async getCostStats(keyId) {
|
||||
const today = getDateStringInTimezone();
|
||||
const tzDate = getDateInTimezone();
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const currentHour = `${today}:${String(getHourInTimezone()).padStart(2, '0')}`;
|
||||
|
||||
const [daily, monthly, hourly, total] = await Promise.all([
|
||||
this.client.get(`usage:cost:daily:${keyId}:${today}`),
|
||||
this.client.get(`usage:cost:monthly:${keyId}:${currentMonth}`),
|
||||
this.client.get(`usage:cost:hourly:${keyId}:${currentHour}`),
|
||||
this.client.get(`usage:cost:total:${keyId}`)
|
||||
]);
|
||||
|
||||
return {
|
||||
daily: parseFloat(daily || 0),
|
||||
monthly: parseFloat(monthly || 0),
|
||||
hourly: parseFloat(hourly || 0),
|
||||
total: parseFloat(total || 0)
|
||||
};
|
||||
}
|
||||
|
||||
// 📊 获取账户使用统计
|
||||
async getAccountUsageStats(accountId) {
|
||||
const accountKey = `account_usage:${accountId}`;
|
||||
const today = getDateStringInTimezone();
|
||||
const accountDailyKey = `account_usage:daily:${accountId}:${today}`;
|
||||
const tzDate = getDateInTimezone();
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const accountMonthlyKey = `account_usage:monthly:${accountId}:${currentMonth}`;
|
||||
|
||||
const [total, daily, monthly] = await Promise.all([
|
||||
this.client.hgetall(accountKey),
|
||||
this.client.hgetall(accountDailyKey),
|
||||
this.client.hgetall(accountMonthlyKey)
|
||||
]);
|
||||
|
||||
// 获取账户创建时间来计算平均值
|
||||
const accountData = await this.client.hgetall(`claude_account:${accountId}`);
|
||||
const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date();
|
||||
const now = new Date();
|
||||
const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24)));
|
||||
|
||||
const totalTokens = parseInt(total.totalTokens) || 0;
|
||||
const totalRequests = parseInt(total.totalRequests) || 0;
|
||||
|
||||
// 计算平均RPM和TPM
|
||||
const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60);
|
||||
const avgRPM = totalRequests / totalMinutes;
|
||||
const avgTPM = totalTokens / totalMinutes;
|
||||
|
||||
// 处理账户统计数据
|
||||
const handleAccountData = (data) => {
|
||||
const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0;
|
||||
const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
|
||||
const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
|
||||
const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0;
|
||||
const cacheCreateTokens = parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
|
||||
const cacheReadTokens = parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
|
||||
const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0;
|
||||
|
||||
const actualAllTokens = allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens);
|
||||
|
||||
return {
|
||||
tokens: tokens,
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cacheCreateTokens: cacheCreateTokens,
|
||||
cacheReadTokens: cacheReadTokens,
|
||||
allTokens: actualAllTokens,
|
||||
requests: requests
|
||||
};
|
||||
};
|
||||
|
||||
const totalData = handleAccountData(total);
|
||||
const dailyData = handleAccountData(daily);
|
||||
const monthlyData = handleAccountData(monthly);
|
||||
|
||||
return {
|
||||
accountId: accountId,
|
||||
total: totalData,
|
||||
daily: dailyData,
|
||||
monthly: monthlyData,
|
||||
averages: {
|
||||
rpm: Math.round(avgRPM * 100) / 100,
|
||||
tpm: Math.round(avgTPM * 100) / 100,
|
||||
dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100,
|
||||
dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 📈 获取所有账户的使用统计
|
||||
async getAllAccountsUsageStats() {
|
||||
try {
|
||||
// 获取所有Claude账户
|
||||
const accountKeys = await this.client.keys('claude_account:*');
|
||||
const accountStats = [];
|
||||
|
||||
for (const accountKey of accountKeys) {
|
||||
const accountId = accountKey.replace('claude_account:', '');
|
||||
const accountData = await this.client.hgetall(accountKey);
|
||||
|
||||
if (accountData.name) {
|
||||
const stats = await this.getAccountUsageStats(accountId);
|
||||
accountStats.push({
|
||||
id: accountId,
|
||||
name: accountData.name,
|
||||
email: accountData.email || '',
|
||||
status: accountData.status || 'unknown',
|
||||
isActive: accountData.isActive === 'true',
|
||||
...stats
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 按当日token使用量排序
|
||||
accountStats.sort((a, b) => (b.daily.allTokens || 0) - (a.daily.allTokens || 0));
|
||||
|
||||
return accountStats;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get all accounts usage stats:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 🧹 清空所有API Key的使用统计数据
|
||||
async resetAllUsageStats() {
|
||||
const client = this.getClientSafe();
|
||||
@@ -819,4 +1083,11 @@ class RedisClient {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RedisClient();
|
||||
const redisClient = new RedisClient();
|
||||
|
||||
// 导出时区辅助函数
|
||||
redisClient.getDateInTimezone = getDateInTimezone;
|
||||
redisClient.getDateStringInTimezone = getDateStringInTimezone;
|
||||
redisClient.getHourInTimezone = getHourInTimezone;
|
||||
|
||||
module.exports = redisClient;
|
||||
@@ -12,15 +12,274 @@ const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('../../config/config');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 🔑 API Keys 管理
|
||||
|
||||
// 调试:获取API Key费用详情
|
||||
router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const costStats = await redis.getCostStats(keyId);
|
||||
const dailyCost = await redis.getDailyCost(keyId);
|
||||
const today = redis.getDateStringInTimezone();
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
// 获取所有相关的Redis键
|
||||
const costKeys = await client.keys(`usage:cost:*:${keyId}:*`);
|
||||
const keyValues = {};
|
||||
|
||||
for (const key of costKeys) {
|
||||
keyValues[key] = await client.get(key);
|
||||
}
|
||||
|
||||
res.json({
|
||||
keyId,
|
||||
today,
|
||||
dailyCost,
|
||||
costStats,
|
||||
redisKeys: keyValues,
|
||||
timezone: config.system.timezoneOffset || 8
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get cost debug info:', error);
|
||||
res.status(500).json({ error: 'Failed to get cost debug info', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取所有API Keys
|
||||
router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { timeRange = 'all' } = req.query; // all, 7days, monthly
|
||||
const apiKeys = await apiKeyService.getAllApiKeys();
|
||||
|
||||
// 根据时间范围计算查询模式
|
||||
const now = new Date();
|
||||
let searchPatterns = [];
|
||||
|
||||
if (timeRange === 'today') {
|
||||
// 今日 - 使用时区日期
|
||||
const redis = require('../models/redis');
|
||||
const tzDate = redis.getDateInTimezone(now);
|
||||
const dateStr = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`;
|
||||
searchPatterns.push(`usage:daily:*:${dateStr}`);
|
||||
} else if (timeRange === '7days') {
|
||||
// 最近7天
|
||||
const redis = require('../models/redis');
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - i);
|
||||
const tzDate = redis.getDateInTimezone(date);
|
||||
const dateStr = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`;
|
||||
searchPatterns.push(`usage:daily:*:${dateStr}`);
|
||||
}
|
||||
} else if (timeRange === 'monthly') {
|
||||
// 本月
|
||||
const redis = require('../models/redis');
|
||||
const tzDate = redis.getDateInTimezone(now);
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
searchPatterns.push(`usage:monthly:*:${currentMonth}`);
|
||||
}
|
||||
|
||||
// 为每个API Key计算准确的费用和统计数据
|
||||
for (const apiKey of apiKeys) {
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
if (timeRange === 'all') {
|
||||
// 全部时间:保持原有逻辑
|
||||
if (apiKey.usage && apiKey.usage.total) {
|
||||
// 使用与展开模型统计相同的数据源
|
||||
// 获取所有时间的模型统计数据
|
||||
const monthlyKeys = await client.keys(`usage:${apiKey.id}:model:monthly:*:*`);
|
||||
const modelStatsMap = new Map();
|
||||
|
||||
// 汇总所有月份的数据
|
||||
for (const key of monthlyKeys) {
|
||||
const match = key.match(/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) {
|
||||
if (!modelStatsMap.has(model)) {
|
||||
modelStatsMap.set(model, {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0
|
||||
});
|
||||
}
|
||||
|
||||
const stats = modelStatsMap.get(model);
|
||||
stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
|
||||
stats.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
|
||||
stats.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
|
||||
stats.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
let totalCost = 0;
|
||||
|
||||
// 计算每个模型的费用
|
||||
for (const [model, stats] of modelStatsMap) {
|
||||
const usage = {
|
||||
input_tokens: stats.inputTokens,
|
||||
output_tokens: stats.outputTokens,
|
||||
cache_creation_input_tokens: stats.cacheCreateTokens,
|
||||
cache_read_input_tokens: stats.cacheReadTokens
|
||||
};
|
||||
|
||||
const costResult = CostCalculator.calculateCost(usage, model);
|
||||
totalCost += costResult.costs.total;
|
||||
}
|
||||
|
||||
// 如果没有详细的模型数据,使用总量数据和默认模型计算
|
||||
if (modelStatsMap.size === 0) {
|
||||
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');
|
||||
totalCost = costResult.costs.total;
|
||||
}
|
||||
|
||||
// 添加格式化的费用到响应数据
|
||||
apiKey.usage.total.cost = totalCost;
|
||||
apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost);
|
||||
}
|
||||
} else {
|
||||
// 7天或本月:重新计算统计数据
|
||||
const tempUsage = {
|
||||
requests: 0,
|
||||
tokens: 0,
|
||||
allTokens: 0, // 添加allTokens字段
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0
|
||||
};
|
||||
|
||||
// 获取指定时间范围的统计数据
|
||||
for (const pattern of searchPatterns) {
|
||||
const keys = await client.keys(pattern.replace('*', apiKey.id));
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await client.hgetall(key);
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
// 使用与 redis.js incrementTokenUsage 中相同的字段名
|
||||
tempUsage.requests += parseInt(data.totalRequests) || parseInt(data.requests) || 0;
|
||||
tempUsage.tokens += parseInt(data.totalTokens) || parseInt(data.tokens) || 0;
|
||||
tempUsage.allTokens += parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0; // 读取包含所有Token的字段
|
||||
tempUsage.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
|
||||
tempUsage.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
|
||||
tempUsage.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
|
||||
tempUsage.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算指定时间范围的费用
|
||||
let totalCost = 0;
|
||||
const redis = require('../models/redis');
|
||||
const tzToday = redis.getDateStringInTimezone(now);
|
||||
const tzDate = redis.getDateInTimezone(now);
|
||||
const tzMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
const modelKeys = timeRange === 'today'
|
||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`)
|
||||
: timeRange === '7days'
|
||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:*`)
|
||||
: await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`);
|
||||
|
||||
const modelStatsMap = new Map();
|
||||
|
||||
// 过滤和汇总相应时间范围的模型数据
|
||||
for (const key of modelKeys) {
|
||||
if (timeRange === '7days') {
|
||||
// 检查是否在最近7天内
|
||||
const dateMatch = key.match(/\d{4}-\d{2}-\d{2}$/);
|
||||
if (dateMatch) {
|
||||
const keyDate = new Date(dateMatch[0]);
|
||||
const daysDiff = Math.floor((now - keyDate) / (1000 * 60 * 60 * 24));
|
||||
if (daysDiff > 6) continue;
|
||||
}
|
||||
} else if (timeRange === 'today') {
|
||||
// today选项已经在查询时过滤了,不需要额外处理
|
||||
}
|
||||
|
||||
const 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 (!modelStatsMap.has(model)) {
|
||||
modelStatsMap.set(model, {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0
|
||||
});
|
||||
}
|
||||
|
||||
const stats = modelStatsMap.get(model);
|
||||
stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0;
|
||||
stats.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0;
|
||||
stats.cacheCreateTokens += parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0;
|
||||
stats.cacheReadTokens += parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算费用
|
||||
for (const [model, stats] of modelStatsMap) {
|
||||
const usage = {
|
||||
input_tokens: stats.inputTokens,
|
||||
output_tokens: stats.outputTokens,
|
||||
cache_creation_input_tokens: stats.cacheCreateTokens,
|
||||
cache_read_input_tokens: stats.cacheReadTokens
|
||||
};
|
||||
|
||||
const costResult = CostCalculator.calculateCost(usage, model);
|
||||
totalCost += costResult.costs.total;
|
||||
}
|
||||
|
||||
// 如果没有模型数据,使用临时统计数据计算
|
||||
if (modelStatsMap.size === 0 && tempUsage.tokens > 0) {
|
||||
const usage = {
|
||||
input_tokens: tempUsage.inputTokens,
|
||||
output_tokens: tempUsage.outputTokens,
|
||||
cache_creation_input_tokens: tempUsage.cacheCreateTokens,
|
||||
cache_read_input_tokens: tempUsage.cacheReadTokens
|
||||
};
|
||||
|
||||
const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022');
|
||||
totalCost = costResult.costs.total;
|
||||
}
|
||||
|
||||
// 使用从Redis读取的allTokens,如果没有则计算
|
||||
const allTokens = tempUsage.allTokens || (tempUsage.inputTokens + tempUsage.outputTokens + tempUsage.cacheCreateTokens + tempUsage.cacheReadTokens);
|
||||
|
||||
// 更新API Key的usage数据为指定时间范围的数据
|
||||
apiKey.usage[timeRange] = {
|
||||
...tempUsage,
|
||||
tokens: allTokens, // 使用包含所有Token的总数
|
||||
allTokens: allTokens,
|
||||
cost: totalCost,
|
||||
formattedCost: CostCalculator.formatCost(totalCost)
|
||||
};
|
||||
|
||||
// 为了保持兼容性,也更新total字段
|
||||
apiKey.usage.total = apiKey.usage[timeRange];
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: apiKeys });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get API keys:', error);
|
||||
@@ -28,6 +287,21 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 获取支持的客户端列表
|
||||
router.get('/supported-clients', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const clients = config.clientRestrictions.predefinedClients.map(client => ({
|
||||
id: client.id,
|
||||
name: client.name,
|
||||
description: client.description
|
||||
}));
|
||||
res.json({ success: true, data: clients });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get supported clients:', error);
|
||||
res.status(500).json({ error: 'Failed to get supported clients', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 创建新的API Key
|
||||
router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
@@ -43,7 +317,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
rateLimitWindow,
|
||||
rateLimitRequests,
|
||||
enableModelRestriction,
|
||||
restrictedModels
|
||||
restrictedModels,
|
||||
enableClientRestriction,
|
||||
allowedClients,
|
||||
dailyCostLimit
|
||||
} = req.body;
|
||||
|
||||
// 输入验证
|
||||
@@ -85,6 +362,15 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Restricted models must be an array' });
|
||||
}
|
||||
|
||||
// 验证客户端限制字段
|
||||
if (enableClientRestriction !== undefined && typeof enableClientRestriction !== 'boolean') {
|
||||
return res.status(400).json({ error: 'Enable client restriction must be a boolean' });
|
||||
}
|
||||
|
||||
if (allowedClients !== undefined && !Array.isArray(allowedClients)) {
|
||||
return res.status(400).json({ error: 'Allowed clients must be an array' });
|
||||
}
|
||||
|
||||
const newKey = await apiKeyService.generateApiKey({
|
||||
name,
|
||||
description,
|
||||
@@ -97,7 +383,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
rateLimitWindow,
|
||||
rateLimitRequests,
|
||||
enableModelRestriction,
|
||||
restrictedModels
|
||||
restrictedModels,
|
||||
enableClientRestriction,
|
||||
allowedClients,
|
||||
dailyCostLimit
|
||||
});
|
||||
|
||||
logger.success(`🔑 Admin created new API key: ${name}`);
|
||||
@@ -112,7 +401,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels } = req.body;
|
||||
const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt, dailyCostLimit } = req.body;
|
||||
|
||||
// 只允许更新指定字段
|
||||
const updates = {};
|
||||
@@ -178,6 +467,45 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
updates.restrictedModels = restrictedModels;
|
||||
}
|
||||
|
||||
// 处理客户端限制字段
|
||||
if (enableClientRestriction !== undefined) {
|
||||
if (typeof enableClientRestriction !== 'boolean') {
|
||||
return res.status(400).json({ error: 'Enable client restriction must be a boolean' });
|
||||
}
|
||||
updates.enableClientRestriction = enableClientRestriction;
|
||||
}
|
||||
|
||||
if (allowedClients !== undefined) {
|
||||
if (!Array.isArray(allowedClients)) {
|
||||
return res.status(400).json({ error: 'Allowed clients must be an array' });
|
||||
}
|
||||
updates.allowedClients = allowedClients;
|
||||
}
|
||||
|
||||
// 处理过期时间字段
|
||||
if (expiresAt !== undefined) {
|
||||
if (expiresAt === null) {
|
||||
// null 表示永不过期
|
||||
updates.expiresAt = null;
|
||||
} else {
|
||||
// 验证日期格式
|
||||
const expireDate = new Date(expiresAt);
|
||||
if (isNaN(expireDate.getTime())) {
|
||||
return res.status(400).json({ error: 'Invalid expiration date format' });
|
||||
}
|
||||
updates.expiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理每日费用限制
|
||||
if (dailyCostLimit !== undefined && dailyCostLimit !== null && dailyCostLimit !== '') {
|
||||
const costLimit = Number(dailyCostLimit);
|
||||
if (isNaN(costLimit) || costLimit < 0) {
|
||||
return res.status(400).json({ error: 'Daily cost limit must be a non-negative number' });
|
||||
}
|
||||
updates.dailyCostLimit = costLimit;
|
||||
}
|
||||
|
||||
await apiKeyService.updateApiKey(keyId, updates);
|
||||
|
||||
logger.success(`📝 Admin updated API key: ${keyId}`);
|
||||
@@ -308,7 +636,34 @@ router.post('/claude-accounts/exchange-code', authenticateAdmin, async (req, res
|
||||
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accounts = await claudeAccountService.getAllAccounts();
|
||||
res.json({ success: true, data: accounts });
|
||||
|
||||
// 为每个账户添加使用统计信息
|
||||
const accountsWithStats = await Promise.all(accounts.map(async (account) => {
|
||||
try {
|
||||
const usageStats = await redis.getAccountUsageStats(account.id);
|
||||
return {
|
||||
...account,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
averages: usageStats.averages
|
||||
}
|
||||
};
|
||||
} catch (statsError) {
|
||||
logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message);
|
||||
// 如果获取统计失败,返回空统计
|
||||
return {
|
||||
...account,
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
res.json({ success: true, data: accountsWithStats });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude accounts:', error);
|
||||
res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message });
|
||||
@@ -495,7 +850,18 @@ router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res
|
||||
router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accounts = await geminiAccountService.getAllAccounts();
|
||||
res.json({ success: true, data: accounts });
|
||||
|
||||
// 为Gemini账户添加空的使用统计(暂时)
|
||||
const accountsWithStats = accounts.map(account => ({
|
||||
...account,
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}));
|
||||
|
||||
res.json({ success: true, data: accountsWithStats });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Gemini accounts:', error);
|
||||
res.status(500).json({ error: 'Failed to get accounts', message: error.message });
|
||||
@@ -568,6 +934,73 @@ router.post('/gemini-accounts/:accountId/refresh', authenticateAdmin, async (req
|
||||
}
|
||||
});
|
||||
|
||||
// 📊 账户使用统计
|
||||
|
||||
// 获取所有账户的使用统计
|
||||
router.get('/accounts/usage-stats', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accountsStats = await redis.getAllAccountsUsageStats();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: accountsStats,
|
||||
summary: {
|
||||
totalAccounts: accountsStats.length,
|
||||
activeToday: accountsStats.filter(account => account.daily.requests > 0).length,
|
||||
totalDailyTokens: accountsStats.reduce((sum, account) => sum + (account.daily.allTokens || 0), 0),
|
||||
totalDailyRequests: accountsStats.reduce((sum, account) => sum + (account.daily.requests || 0), 0)
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get accounts usage stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get accounts usage stats',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取单个账户的使用统计
|
||||
router.get('/accounts/:accountId/usage-stats', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const accountStats = await redis.getAccountUsageStats(accountId);
|
||||
|
||||
// 获取账户基本信息
|
||||
const accountData = await claudeAccountService.getAccount(accountId);
|
||||
if (!accountData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Account not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...accountStats,
|
||||
accountInfo: {
|
||||
name: accountData.name,
|
||||
email: accountData.email,
|
||||
status: accountData.status,
|
||||
isActive: accountData.isActive,
|
||||
createdAt: accountData.createdAt
|
||||
}
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get account usage stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get account usage stats',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 📊 系统统计
|
||||
|
||||
// 获取系统概览
|
||||
@@ -582,8 +1015,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
redis.getSystemAverages()
|
||||
]);
|
||||
|
||||
// 计算使用统计(包含cache tokens)
|
||||
const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0);
|
||||
// 计算使用统计(统一使用allTokens)
|
||||
const totalTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.allTokens || 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);
|
||||
@@ -1794,4 +2227,91 @@ function compareVersions(current, latest) {
|
||||
return currentV.patch - latestV.patch;
|
||||
}
|
||||
|
||||
// 🎨 OEM设置管理
|
||||
|
||||
// 获取OEM设置(公开接口,用于显示)
|
||||
router.get('/oem-settings', async (req, res) => {
|
||||
try {
|
||||
const client = redis.getClient();
|
||||
const oemSettings = await client.get('oem:settings');
|
||||
|
||||
// 默认设置
|
||||
const defaultSettings = {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '', // Base64编码的图标数据
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
let settings = defaultSettings;
|
||||
if (oemSettings) {
|
||||
try {
|
||||
settings = { ...defaultSettings, ...JSON.parse(oemSettings) };
|
||||
} catch (err) {
|
||||
logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get OEM settings:', error);
|
||||
res.status(500).json({ error: 'Failed to get OEM settings', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新OEM设置
|
||||
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { siteName, siteIcon, siteIconData } = req.body;
|
||||
|
||||
// 验证输入
|
||||
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'Site name is required' });
|
||||
}
|
||||
|
||||
if (siteName.length > 100) {
|
||||
return res.status(400).json({ error: 'Site name must be less than 100 characters' });
|
||||
}
|
||||
|
||||
// 验证图标数据大小(如果是base64)
|
||||
if (siteIconData && siteIconData.length > 500000) { // 约375KB
|
||||
return res.status(400).json({ error: 'Icon file must be less than 350KB' });
|
||||
}
|
||||
|
||||
// 验证图标URL(如果提供)
|
||||
if (siteIcon && !siteIconData) {
|
||||
// 简单验证URL格式
|
||||
try {
|
||||
new URL(siteIcon);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: 'Invalid icon URL format' });
|
||||
}
|
||||
}
|
||||
|
||||
const settings = {
|
||||
siteName: siteName.trim(),
|
||||
siteIcon: (siteIcon || '').trim(),
|
||||
siteIconData: (siteIconData || '').trim(), // Base64数据
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
const client = redis.getClient();
|
||||
await client.set('oem:settings', JSON.stringify(settings));
|
||||
|
||||
logger.info(`✅ OEM settings updated: ${siteName}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'OEM settings updated successfully',
|
||||
data: settings
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update OEM settings:', error);
|
||||
res.status(500).json({ error: 'Failed to update OEM settings', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -68,8 +68,9 @@ async function handleMessagesRequest(req, res) {
|
||||
const cacheReadTokens = usageData.cache_read_input_tokens || 0;
|
||||
const model = usageData.model || 'unknown';
|
||||
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token)
|
||||
apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model).catch(error => {
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||
const accountId = usageData.accountId;
|
||||
apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, accountId).catch(error => {
|
||||
logger.error('❌ Failed to record stream usage:', error);
|
||||
});
|
||||
|
||||
@@ -135,8 +136,9 @@ async function handleMessagesRequest(req, res) {
|
||||
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0;
|
||||
const model = jsonData.model || req.body.model || 'unknown';
|
||||
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token)
|
||||
await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||
const accountId = response.accountId;
|
||||
await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, accountId);
|
||||
|
||||
// 更新时间窗口内的token计数
|
||||
if (req.rateLimitInfo) {
|
||||
|
||||
518
src/routes/apiStats.js
Normal file
518
src/routes/apiStats.js
Normal file
@@ -0,0 +1,518 @@
|
||||
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 对应的 ID
|
||||
router.post('/api/get-key-id', async (req, res) => {
|
||||
try {
|
||||
const { apiKey } = req.body;
|
||||
|
||||
if (!apiKey) {
|
||||
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) {
|
||||
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 get-key-id: ${validation.error} from ${clientIP}`);
|
||||
return res.status(401).json({
|
||||
error: 'Invalid API key',
|
||||
message: validation.error
|
||||
});
|
||||
}
|
||||
|
||||
const keyData = validation.keyData;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: keyData.id
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get API key ID:', error);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve API key ID'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 📊 用户API Key统计查询接口 - 安全的自查询接口
|
||||
router.post('/api/user-stats', async (req, res) => {
|
||||
try {
|
||||
const { apiKey, apiId } = req.body;
|
||||
|
||||
let keyData;
|
||||
let keyId;
|
||||
|
||||
if (apiId) {
|
||||
// 通过 apiId 查询
|
||||
if (typeof apiId !== 'string' || !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API ID format',
|
||||
message: 'API ID must be a valid UUID'
|
||||
});
|
||||
}
|
||||
|
||||
// 直接通过 ID 获取 API Key 数据
|
||||
keyData = await redis.getApiKey(apiId);
|
||||
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`);
|
||||
return res.status(404).json({
|
||||
error: 'API key not found',
|
||||
message: 'The specified API key does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否激活
|
||||
if (keyData.isActive !== 'true') {
|
||||
return res.status(403).json({
|
||||
error: 'API key is disabled',
|
||||
message: 'This API key has been disabled'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
|
||||
return res.status(403).json({
|
||||
error: 'API key has expired',
|
||||
message: 'This API key has expired'
|
||||
});
|
||||
}
|
||||
|
||||
keyId = apiId;
|
||||
|
||||
// 获取使用统计
|
||||
const usage = await redis.getUsageStats(keyId);
|
||||
|
||||
// 获取当日费用统计
|
||||
const dailyCost = await redis.getDailyCost(keyId);
|
||||
|
||||
// 处理数据格式,与 validateApiKey 返回的格式保持一致
|
||||
// 解析限制模型数据
|
||||
let restrictedModels = [];
|
||||
try {
|
||||
restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : [];
|
||||
} catch (e) {
|
||||
restrictedModels = [];
|
||||
}
|
||||
|
||||
// 解析允许的客户端数据
|
||||
let allowedClients = [];
|
||||
try {
|
||||
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [];
|
||||
} catch (e) {
|
||||
allowedClients = [];
|
||||
}
|
||||
|
||||
// 格式化 keyData
|
||||
keyData = {
|
||||
...keyData,
|
||||
tokenLimit: parseInt(keyData.tokenLimit) || 0,
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit) || 0,
|
||||
rateLimitWindow: parseInt(keyData.rateLimitWindow) || 0,
|
||||
rateLimitRequests: parseInt(keyData.rateLimitRequests) || 0,
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit) || 0,
|
||||
dailyCost: dailyCost || 0,
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels: restrictedModels,
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients: allowedClients,
|
||||
permissions: keyData.permissions || 'all',
|
||||
usage: usage // 使用完整的 usage 数据,而不是只有 total
|
||||
};
|
||||
|
||||
} else if (apiKey) {
|
||||
// 通过 apiKey 查询(保持向后兼容)
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
keyData = validation.keyData;
|
||||
keyId = keyData.id;
|
||||
|
||||
} else {
|
||||
logger.security(`🔒 Missing API key or ID in user stats query from ${req.ip || 'unknown'}`);
|
||||
return res.status(400).json({
|
||||
error: 'API Key or ID is required',
|
||||
message: 'Please provide your API Key or API ID'
|
||||
});
|
||||
}
|
||||
|
||||
// 记录合法查询
|
||||
logger.api(`📊 User stats query from key: ${keyData.name} (${keyId}) from ${req.ip || 'unknown'}`);
|
||||
|
||||
// 获取验证结果中的完整keyData(包含isActive状态和cost信息)
|
||||
const fullKeyData = keyData;
|
||||
|
||||
// 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算)
|
||||
let totalCost = 0;
|
||||
let formattedCost = '$0.000000';
|
||||
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
// 获取所有月度模型统计(与model-stats接口相同的逻辑)
|
||||
const allModelKeys = await client.keys(`usage:${keyId}: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 ${keyId}:`, 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: keyId,
|
||||
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, apiId, period = 'monthly' } = req.body;
|
||||
|
||||
let keyData;
|
||||
let keyId;
|
||||
|
||||
if (apiId) {
|
||||
// 通过 apiId 查询
|
||||
if (typeof apiId !== 'string' || !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API ID format',
|
||||
message: 'API ID must be a valid UUID'
|
||||
});
|
||||
}
|
||||
|
||||
// 直接通过 ID 获取 API Key 数据
|
||||
keyData = await redis.getApiKey(apiId);
|
||||
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`);
|
||||
return res.status(404).json({
|
||||
error: 'API key not found',
|
||||
message: 'The specified API key does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否激活
|
||||
if (keyData.isActive !== 'true') {
|
||||
return res.status(403).json({
|
||||
error: 'API key is disabled',
|
||||
message: 'This API key has been disabled'
|
||||
});
|
||||
}
|
||||
|
||||
keyId = apiId;
|
||||
|
||||
// 获取使用统计
|
||||
const usage = await redis.getUsageStats(keyId);
|
||||
keyData.usage = { total: usage.total };
|
||||
|
||||
} else if (apiKey) {
|
||||
// 通过 apiKey 查询(保持向后兼容)
|
||||
// 验证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
|
||||
});
|
||||
}
|
||||
|
||||
keyData = validation.keyData;
|
||||
keyId = keyData.id;
|
||||
|
||||
} else {
|
||||
logger.security(`🔒 Missing API key or ID in user model stats query from ${req.ip || 'unknown'}`);
|
||||
return res.status(400).json({
|
||||
error: 'API Key or ID is required',
|
||||
message: 'Please provide your API Key or API ID'
|
||||
});
|
||||
}
|
||||
|
||||
logger.api(`📊 User model stats query from key: ${keyData.name} (${keyId}) for period: ${period}`);
|
||||
|
||||
// 重用管理后台的模型统计逻辑,但只返回该API Key的数据
|
||||
const client = redis.getClientSafe();
|
||||
// 使用与管理页面相同的时区处理逻辑
|
||||
const tzDate = redis.getDateInTimezone();
|
||||
const today = redis.getDateStringInTimezone();
|
||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
const pattern = period === 'daily' ?
|
||||
`usage:${keyId}:model:daily:*:${today}` :
|
||||
`usage:${keyId}: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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有详细的模型数据,不显示历史数据以避免混淆
|
||||
// 只有在查询特定时间段时返回空数组,表示该时间段确实没有数据
|
||||
if (modelStats.length === 0) {
|
||||
logger.info(`📊 No model stats found for key ${keyId} in period ${period}`);
|
||||
}
|
||||
|
||||
// 按总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;
|
||||
@@ -258,7 +258,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model
|
||||
model,
|
||||
accountId
|
||||
).catch(error => {
|
||||
logger.error('❌ Failed to record usage:', error);
|
||||
});
|
||||
@@ -327,7 +328,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
usage.output_tokens || 0,
|
||||
usage.cache_creation_input_tokens || 0,
|
||||
usage.cache_read_input_tokens || 0,
|
||||
claudeRequest.model
|
||||
claudeRequest.model,
|
||||
accountId
|
||||
).catch(error => {
|
||||
logger.error('❌ Failed to record usage:', error);
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ const ALLOWED_FILES = {
|
||||
'style.css': {
|
||||
path: path.join(__dirname, '../../web/admin/style.css'),
|
||||
contentType: 'text/css; charset=utf-8'
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 🛡️ 安全文件服务函数
|
||||
@@ -400,6 +400,9 @@ router.get('/style.css', (req, res) => {
|
||||
serveWhitelistedFile(req, res, 'style.css');
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// 🔑 Gemini OAuth 回调页面
|
||||
|
||||
module.exports = router;
|
||||
@@ -24,7 +24,10 @@ class ApiKeyService {
|
||||
rateLimitWindow = null,
|
||||
rateLimitRequests = null,
|
||||
enableModelRestriction = false,
|
||||
restrictedModels = []
|
||||
restrictedModels = [],
|
||||
enableClientRestriction = false,
|
||||
allowedClients = [],
|
||||
dailyCostLimit = 0
|
||||
} = options;
|
||||
|
||||
// 生成简单的API Key (64字符十六进制)
|
||||
@@ -47,6 +50,9 @@ class ApiKeyService {
|
||||
permissions: permissions || 'all',
|
||||
enableModelRestriction: String(enableModelRestriction),
|
||||
restrictedModels: JSON.stringify(restrictedModels || []),
|
||||
enableClientRestriction: String(enableClientRestriction || false),
|
||||
allowedClients: JSON.stringify(allowedClients || []),
|
||||
dailyCostLimit: String(dailyCostLimit || 0),
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: '',
|
||||
expiresAt: expiresAt || '',
|
||||
@@ -73,6 +79,9 @@ class ApiKeyService {
|
||||
permissions: keyData.permissions,
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels: JSON.parse(keyData.restrictedModels),
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients: JSON.parse(keyData.allowedClients || '[]'),
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
createdBy: keyData.createdBy
|
||||
@@ -108,6 +117,9 @@ class ApiKeyService {
|
||||
|
||||
// 获取使用统计(供返回数据使用)
|
||||
const usage = await redis.getUsageStats(keyData.id);
|
||||
|
||||
// 获取当日费用统计
|
||||
const dailyCost = await redis.getDailyCost(keyData.id);
|
||||
|
||||
// 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时)
|
||||
// 注意:lastUsedAt的更新已移至recordUsage方法中
|
||||
@@ -122,11 +134,22 @@ class ApiKeyService {
|
||||
restrictedModels = [];
|
||||
}
|
||||
|
||||
// 解析允许的客户端
|
||||
let allowedClients = [];
|
||||
try {
|
||||
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [];
|
||||
} catch (e) {
|
||||
allowedClients = [];
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
keyData: {
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
description: keyData.description,
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
claudeAccountId: keyData.claudeAccountId,
|
||||
geminiAccountId: keyData.geminiAccountId,
|
||||
permissions: keyData.permissions || 'all',
|
||||
@@ -136,6 +159,10 @@ class ApiKeyService {
|
||||
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels: restrictedModels,
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients: allowedClients,
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
dailyCost: dailyCost || 0,
|
||||
usage
|
||||
}
|
||||
};
|
||||
@@ -160,12 +187,20 @@ class ApiKeyService {
|
||||
key.currentConcurrency = await redis.getConcurrency(key.id);
|
||||
key.isActive = key.isActive === 'true';
|
||||
key.enableModelRestriction = key.enableModelRestriction === 'true';
|
||||
key.enableClientRestriction = key.enableClientRestriction === 'true';
|
||||
key.permissions = key.permissions || 'all'; // 兼容旧数据
|
||||
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0);
|
||||
key.dailyCost = await redis.getDailyCost(key.id) || 0;
|
||||
try {
|
||||
key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : [];
|
||||
} catch (e) {
|
||||
key.restrictedModels = [];
|
||||
}
|
||||
try {
|
||||
key.allowedClients = key.allowedClients ? JSON.parse(key.allowedClients) : [];
|
||||
} catch (e) {
|
||||
key.allowedClients = [];
|
||||
}
|
||||
delete key.apiKey; // 不返回哈希后的key
|
||||
}
|
||||
|
||||
@@ -185,15 +220,15 @@ class ApiKeyService {
|
||||
}
|
||||
|
||||
// 允许更新的字段
|
||||
const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels'];
|
||||
const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients', 'dailyCostLimit'];
|
||||
const updatedData = { ...keyData };
|
||||
|
||||
for (const [field, value] of Object.entries(updates)) {
|
||||
if (allowedUpdates.includes(field)) {
|
||||
if (field === 'restrictedModels') {
|
||||
// 特殊处理 restrictedModels 数组
|
||||
if (field === 'restrictedModels' || field === 'allowedClients') {
|
||||
// 特殊处理数组字段
|
||||
updatedData[field] = JSON.stringify(value || []);
|
||||
} else if (field === 'enableModelRestriction') {
|
||||
} else if (field === 'enableModelRestriction' || field === 'enableClientRestriction') {
|
||||
// 布尔值转字符串
|
||||
updatedData[field] = String(value);
|
||||
} else {
|
||||
@@ -234,18 +269,45 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 记录使用情况(支持缓存token)
|
||||
async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
|
||||
// 📊 记录使用情况(支持缓存token和账户级别统计)
|
||||
async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown', accountId = null) {
|
||||
try {
|
||||
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
|
||||
|
||||
// 计算费用
|
||||
const CostCalculator = require('../utils/costCalculator');
|
||||
const costInfo = CostCalculator.calculateCost({
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_creation_input_tokens: cacheCreateTokens,
|
||||
cache_read_input_tokens: cacheReadTokens
|
||||
}, model);
|
||||
|
||||
// 记录API Key级别的使用统计
|
||||
await redis.incrementTokenUsage(keyId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
|
||||
|
||||
// 更新最后使用时间(性能优化:只在实际使用时更新)
|
||||
// 记录费用统计
|
||||
if (costInfo.costs.total > 0) {
|
||||
await redis.incrementDailyCost(keyId, costInfo.costs.total);
|
||||
logger.database(`💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}`);
|
||||
} else {
|
||||
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`);
|
||||
}
|
||||
|
||||
// 获取API Key数据以确定关联的账户
|
||||
const keyData = await redis.getApiKey(keyId);
|
||||
if (keyData && Object.keys(keyData).length > 0) {
|
||||
// 更新最后使用时间
|
||||
keyData.lastUsedAt = new Date().toISOString();
|
||||
// 使用记录时不需要重新建立哈希映射
|
||||
await redis.setApiKey(keyId, keyData);
|
||||
|
||||
// 记录账户级别的使用统计(只统计实际处理请求的账户)
|
||||
if (accountId) {
|
||||
await redis.incrementAccountUsage(accountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
|
||||
logger.database(`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`);
|
||||
} else {
|
||||
logger.debug('⚠️ No accountId provided for usage recording, skipping account-level statistics');
|
||||
}
|
||||
}
|
||||
|
||||
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`];
|
||||
@@ -274,6 +336,16 @@ class ApiKeyService {
|
||||
return await redis.getUsageStats(keyId);
|
||||
}
|
||||
|
||||
// 📊 获取账户使用统计
|
||||
async getAccountUsageStats(accountId) {
|
||||
return await redis.getAccountUsageStats(accountId);
|
||||
}
|
||||
|
||||
// 📈 获取所有账户使用统计
|
||||
async getAllAccountsUsageStats() {
|
||||
return await redis.getAllAccountsUsageStats();
|
||||
}
|
||||
|
||||
|
||||
// 🧹 清理过期的API Keys
|
||||
async cleanupExpiredKeys() {
|
||||
@@ -283,14 +355,17 @@ class ApiKeyService {
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const key of apiKeys) {
|
||||
if (key.expiresAt && new Date(key.expiresAt) < now) {
|
||||
await redis.deleteApiKey(key.id);
|
||||
// 检查是否已过期且仍处于激活状态
|
||||
if (key.expiresAt && new Date(key.expiresAt) < now && key.isActive === 'true') {
|
||||
// 将过期的 API Key 标记为禁用状态,而不是直接删除
|
||||
await this.updateApiKey(key.id, { isActive: false });
|
||||
logger.info(`🔒 API Key ${key.id} (${key.name}) has expired and been disabled`);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
logger.success(`🧹 Cleaned up ${cleanedCount} expired API keys`);
|
||||
logger.success(`🧹 Disabled ${cleanedCount} expired API keys`);
|
||||
}
|
||||
|
||||
return cleanedCount;
|
||||
|
||||
@@ -444,11 +444,11 @@ class ClaudeAccountService {
|
||||
}
|
||||
|
||||
// 如果没有映射或映射无效,选择新账户
|
||||
// 优先选择最近刷新过token的账户
|
||||
// 优先选择最久未使用的账户(负载均衡)
|
||||
const sortedAccounts = activeAccounts.sort((a, b) => {
|
||||
const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime();
|
||||
const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime();
|
||||
return bLastRefresh - aLastRefresh;
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime();
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime();
|
||||
return aLastUsed - bLastUsed; // 最久未使用的优先
|
||||
});
|
||||
|
||||
const selectedAccountId = sortedAccounts[0].id;
|
||||
@@ -544,11 +544,11 @@ class ClaudeAccountService {
|
||||
return aRateLimitedAt - bRateLimitedAt; // 最早限流的优先
|
||||
});
|
||||
} else {
|
||||
// 非限流账户按最近刷新时间排序
|
||||
// 非限流账户按最后使用时间排序(最久未使用的优先)
|
||||
candidateAccounts = candidateAccounts.sort((a, b) => {
|
||||
const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime();
|
||||
const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime();
|
||||
return bLastRefresh - aLastRefresh;
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime();
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime();
|
||||
return aLastUsed - bLastUsed; // 最久未使用的优先
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,8 @@ class ClaudeRelayService {
|
||||
|
||||
logger.info(`✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens, Output: ~${Math.round(outputTokens)} tokens`);
|
||||
|
||||
// 在响应中添加accountId,以便调用方记录账户级别统计
|
||||
response.accountId = accountId;
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error(`❌ Claude relay request failed for key: ${apiKeyData.name || apiKeyData.id}:`, error.message);
|
||||
@@ -619,7 +621,10 @@ class ClaudeRelayService {
|
||||
const proxyAgent = await this._getProxyAgent(accountId);
|
||||
|
||||
// 发送流式请求并捕获usage数据
|
||||
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash, streamTransformer, options);
|
||||
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, (usageData) => {
|
||||
// 在usageCallback中添加accountId
|
||||
usageCallback({ ...usageData, accountId });
|
||||
}, accountId, sessionHash, streamTransformer, options);
|
||||
} catch (error) {
|
||||
logger.error('❌ Claude stream relay with usage capture failed:', error);
|
||||
throw error;
|
||||
|
||||
182
src/services/costInitService.js
Normal file
182
src/services/costInitService.js
Normal file
@@ -0,0 +1,182 @@
|
||||
const redis = require('../models/redis');
|
||||
const apiKeyService = require('./apiKeyService');
|
||||
const CostCalculator = require('../utils/costCalculator');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
class CostInitService {
|
||||
/**
|
||||
* 初始化所有API Key的费用数据
|
||||
* 扫描历史使用记录并计算费用
|
||||
*/
|
||||
async initializeAllCosts() {
|
||||
try {
|
||||
logger.info('💰 Starting cost initialization for all API Keys...');
|
||||
|
||||
const apiKeys = await apiKeyService.getAllApiKeys();
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
let processedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const apiKey of apiKeys) {
|
||||
try {
|
||||
await this.initializeApiKeyCosts(apiKey.id, client);
|
||||
processedCount++;
|
||||
|
||||
if (processedCount % 10 === 0) {
|
||||
logger.info(`💰 Processed ${processedCount} API Keys...`);
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
logger.error(`❌ Failed to initialize costs for API Key ${apiKey.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}`);
|
||||
return { processed: processedCount, errors: errorCount };
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize costs:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化单个API Key的费用数据
|
||||
*/
|
||||
async initializeApiKeyCosts(apiKeyId, client) {
|
||||
// 获取所有时间的模型使用统计
|
||||
const modelKeys = await client.keys(`usage:${apiKeyId}:model:*:*:*`);
|
||||
|
||||
// 按日期分组统计
|
||||
const dailyCosts = new Map(); // date -> cost
|
||||
const monthlyCosts = new Map(); // month -> cost
|
||||
const hourlyCosts = new Map(); // hour -> cost
|
||||
|
||||
for (const key of modelKeys) {
|
||||
// 解析key格式: usage:{keyId}:model:{period}:{model}:{date}
|
||||
const match = key.match(/usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const [, , period, model, dateStr] = match;
|
||||
|
||||
// 获取使用数据
|
||||
const data = await client.hgetall(key);
|
||||
if (!data || Object.keys(data).length === 0) continue;
|
||||
|
||||
// 计算费用
|
||||
const usage = {
|
||||
input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0,
|
||||
output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0,
|
||||
cache_creation_input_tokens: parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0,
|
||||
cache_read_input_tokens: parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0
|
||||
};
|
||||
|
||||
const costResult = CostCalculator.calculateCost(usage, model);
|
||||
const cost = costResult.costs.total;
|
||||
|
||||
// 根据period分组累加费用
|
||||
if (period === 'daily') {
|
||||
const currentCost = dailyCosts.get(dateStr) || 0;
|
||||
dailyCosts.set(dateStr, currentCost + cost);
|
||||
} else if (period === 'monthly') {
|
||||
const currentCost = monthlyCosts.get(dateStr) || 0;
|
||||
monthlyCosts.set(dateStr, currentCost + cost);
|
||||
} else if (period === 'hourly') {
|
||||
const currentCost = hourlyCosts.get(dateStr) || 0;
|
||||
hourlyCosts.set(dateStr, currentCost + cost);
|
||||
}
|
||||
}
|
||||
|
||||
// 将计算出的费用写入Redis
|
||||
const promises = [];
|
||||
|
||||
// 写入每日费用
|
||||
for (const [date, cost] of dailyCosts) {
|
||||
const key = `usage:cost:daily:${apiKeyId}:${date}`;
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 30) // 30天过期
|
||||
);
|
||||
}
|
||||
|
||||
// 写入每月费用
|
||||
for (const [month, cost] of monthlyCosts) {
|
||||
const key = `usage:cost:monthly:${apiKeyId}:${month}`;
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 90) // 90天过期
|
||||
);
|
||||
}
|
||||
|
||||
// 写入每小时费用
|
||||
for (const [hour, cost] of hourlyCosts) {
|
||||
const key = `usage:cost:hourly:${apiKeyId}:${hour}`;
|
||||
promises.push(
|
||||
client.set(key, cost.toString()),
|
||||
client.expire(key, 86400 * 7) // 7天过期
|
||||
);
|
||||
}
|
||||
|
||||
// 计算总费用
|
||||
let totalCost = 0;
|
||||
for (const cost of dailyCosts.values()) {
|
||||
totalCost += cost;
|
||||
}
|
||||
|
||||
// 写入总费用
|
||||
if (totalCost > 0) {
|
||||
const totalKey = `usage:cost:total:${apiKeyId}`;
|
||||
promises.push(client.set(totalKey, totalCost.toString()));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
logger.debug(`💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否需要初始化费用数据
|
||||
*/
|
||||
async needsInitialization() {
|
||||
try {
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
// 检查是否有任何费用数据
|
||||
const costKeys = await client.keys('usage:cost:*');
|
||||
|
||||
// 如果没有费用数据,需要初始化
|
||||
if (costKeys.length === 0) {
|
||||
logger.info('💰 No cost data found, initialization needed');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否有使用数据但没有对应的费用数据
|
||||
const sampleKeys = await client.keys('usage:*:model:daily:*:*');
|
||||
if (sampleKeys.length > 10) {
|
||||
// 抽样检查
|
||||
const sampleSize = Math.min(10, sampleKeys.length);
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)];
|
||||
const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/);
|
||||
if (match) {
|
||||
const [, keyId, , date] = match;
|
||||
const costKey = `usage:cost:daily:${keyId}:${date}`;
|
||||
const hasCost = await client.exists(costKey);
|
||||
if (!hasCost) {
|
||||
logger.info(`💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('💰 Cost data appears to be up to date');
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to check initialization status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CostInitService();
|
||||
@@ -107,7 +107,7 @@ const securityLogger = winston.createLogger({
|
||||
|
||||
// 🌟 增强的 Winston logger
|
||||
const logger = winston.createLogger({
|
||||
level: config.logging.level,
|
||||
level: process.env.LOG_LEVEL || config.logging.level,
|
||||
format: logFormat,
|
||||
transports: [
|
||||
// 📄 文件输出
|
||||
@@ -282,10 +282,11 @@ logger.healthCheck = () => {
|
||||
|
||||
// 🎬 启动日志记录系统
|
||||
logger.start('Logger initialized', {
|
||||
level: config.logging.level,
|
||||
level: process.env.LOG_LEVEL || config.logging.level,
|
||||
directory: config.logging.dirname,
|
||||
maxSize: config.logging.maxSize,
|
||||
maxFiles: config.logging.maxFiles
|
||||
maxFiles: config.logging.maxFiles,
|
||||
envOverride: process.env.LOG_LEVEL ? true : false
|
||||
});
|
||||
|
||||
module.exports = logger;
|
||||
Reference in New Issue
Block a user