Download src/routes/admin.js from hequ/cc: direct link, hf CLI and curl.
- Browser
- Download file 314 kB
-
https://huggingface.co/spaces/hequ/cc/resolve/main/src/routes/admin.js
- Command line
-
hf download hf://spaces/hequ/cc/src/routes/admin.js
-
curl -L -o admin.js https://huggingface.co/spaces/hequ/cc/resolve/main/src/routes/admin.js
314 kB
| const express = require('express') | |
| const apiKeyService = require('../services/apiKeyService') | |
| const claudeAccountService = require('../services/claudeAccountService') | |
| const claudeConsoleAccountService = require('../services/claudeConsoleAccountService') | |
| const bedrockAccountService = require('../services/bedrockAccountService') | |
| const ccrAccountService = require('../services/ccrAccountService') | |
| const geminiAccountService = require('../services/geminiAccountService') | |
| const droidAccountService = require('../services/droidAccountService') | |
| const openaiAccountService = require('../services/openaiAccountService') | |
| const openaiResponsesAccountService = require('../services/openaiResponsesAccountService') | |
| const azureOpenaiAccountService = require('../services/azureOpenaiAccountService') | |
| const accountGroupService = require('../services/accountGroupService') | |
| const redis = require('../models/redis') | |
| const { authenticateAdmin } = require('../middleware/auth') | |
| const logger = require('../utils/logger') | |
| const oauthHelper = require('../utils/oauthHelper') | |
| const { | |
| startDeviceAuthorization, | |
| pollDeviceAuthorization, | |
| WorkOSDeviceAuthError | |
| } = require('../utils/workosOAuthHelper') | |
| const CostCalculator = require('../utils/costCalculator') | |
| const pricingService = require('../services/pricingService') | |
| const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') | |
| const webhookNotifier = require('../utils/webhookNotifier') | |
| const axios = require('axios') | |
| const crypto = require('crypto') | |
| const fs = require('fs') | |
| const path = require('path') | |
| const config = require('../../config/config') | |
| const ProxyHelper = require('../utils/proxyHelper') | |
| const router = express.Router() | |
| // 🛠️ 工具函数:处理可为空的时间字段 | |
| function normalizeNullableDate(value) { | |
| if (value === undefined || value === null) { | |
| return null | |
| } | |
| if (typeof value === 'string') { | |
| const trimmed = value.trim() | |
| return trimmed === '' ? null : trimmed | |
| } | |
| return value | |
| } | |
| // 🛠️ 工具函数:映射前端字段名到后端字段名 | |
| /** | |
| * 映射前端的 expiresAt 字段到后端的 subscriptionExpiresAt 字段 | |
| * @param {Object} updates - 更新对象 | |
| * @param {string} accountType - 账户类型 (如 'Claude', 'OpenAI' 等) | |
| * @param {string} accountId - 账户 ID | |
| * @returns {Object} 映射后的更新对象 | |
| */ | |
| function mapExpiryField(updates, accountType, accountId) { | |
| const mappedUpdates = { ...updates } | |
| if ('expiresAt' in mappedUpdates) { | |
| mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt | |
| delete mappedUpdates.expiresAt | |
| logger.info( | |
| `Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}` | |
| ) | |
| } | |
| return mappedUpdates | |
| } | |
| /** | |
| * 格式化账户数据,确保前端获取正确的过期时间字段 | |
| * 将 subscriptionExpiresAt(订阅过期时间)映射到 expiresAt 供前端使用 | |
| * 保留原始的 tokenExpiresAt(OAuth token过期时间)供内部使用 | |
| * @param {Object} account - 账户对象 | |
| * @returns {Object} 格式化后的账户对象 | |
| */ | |
| function formatAccountExpiry(account) { | |
| if (!account || typeof account !== 'object') { | |
| return account | |
| } | |
| const rawSubscription = Object.prototype.hasOwnProperty.call(account, 'subscriptionExpiresAt') | |
| ? account.subscriptionExpiresAt | |
| : null | |
| const rawToken = Object.prototype.hasOwnProperty.call(account, 'tokenExpiresAt') | |
| ? account.tokenExpiresAt | |
| : account.expiresAt | |
| const subscriptionExpiresAt = normalizeNullableDate(rawSubscription) | |
| const tokenExpiresAt = normalizeNullableDate(rawToken) | |
| return { | |
| ...account, | |
| subscriptionExpiresAt, | |
| tokenExpiresAt, | |
| expiresAt: subscriptionExpiresAt | |
| } | |
| } | |
| // 👥 用户管理 | |
| // 获取所有用户列表(用于API Key分配) | |
| router.get('/users', authenticateAdmin, async (req, res) => { | |
| try { | |
| const userService = require('../services/userService') | |
| // Extract query parameters for filtering | |
| const { role, isActive } = req.query | |
| const options = { limit: 1000 } | |
| // Apply role filter if provided | |
| if (role) { | |
| options.role = role | |
| } | |
| // Apply isActive filter if provided, otherwise default to active users only | |
| if (isActive !== undefined) { | |
| options.isActive = isActive === 'true' | |
| } else { | |
| options.isActive = true // Default to active users for backwards compatibility | |
| } | |
| const result = await userService.getAllUsers(options) | |
| // Extract users array from the paginated result | |
| const allUsers = result.users || [] | |
| // Map to the format needed for the dropdown | |
| const activeUsers = allUsers.map((user) => ({ | |
| id: user.id, | |
| username: user.username, | |
| displayName: user.displayName || user.username, | |
| email: user.email, | |
| role: user.role | |
| })) | |
| // 添加Admin选项作为第一个 | |
| const usersWithAdmin = [ | |
| { | |
| id: 'admin', | |
| username: 'admin', | |
| displayName: 'Admin', | |
| email: '', | |
| role: 'admin' | |
| }, | |
| ...activeUsers | |
| ] | |
| return res.json({ | |
| success: true, | |
| data: usersWithAdmin | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get users list:', error) | |
| return res.status(500).json({ | |
| error: 'Failed to get users list', | |
| message: error.message | |
| }) | |
| } | |
| }) | |
| // 🔑 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) | |
| } | |
| return res.json({ | |
| keyId, | |
| today, | |
| dailyCost, | |
| costStats, | |
| redisKeys: keyValues, | |
| timezone: config.system.timezoneOffset || 8 | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get cost debug info:', error) | |
| return 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', startDate, endDate } = req.query // all, 7days, monthly, custom | |
| const apiKeys = await apiKeyService.getAllApiKeys() | |
| // 获取用户服务来补充owner信息 | |
| const userService = require('../services/userService') | |
| // 根据时间范围计算查询模式 | |
| const now = new Date() | |
| const searchPatterns = [] | |
| if (timeRange === 'custom' && startDate && endDate) { | |
| // 自定义日期范围 | |
| const redisClient = require('../models/redis') | |
| 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' }) | |
| } | |
| // 限制最大范围为365天 | |
| const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 | |
| if (daysDiff > 365) { | |
| return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) | |
| } | |
| // 生成日期范围内每天的搜索模式 | |
| const currentDate = new Date(start) | |
| while (currentDate <= end) { | |
| const tzDate = redisClient.getDateInTimezone(currentDate) | |
| const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( | |
| 2, | |
| '0' | |
| )}-${String(tzDate.getUTCDate()).padStart(2, '0')}` | |
| searchPatterns.push(`usage:daily:*:${dateStr}`) | |
| currentDate.setDate(currentDate.getDate() + 1) | |
| } | |
| } else if (timeRange === 'today') { | |
| // 今日 - 使用时区日期 | |
| const redisClient = require('../models/redis') | |
| const tzDate = redisClient.getDateInTimezone(now) | |
| const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( | |
| 2, | |
| '0' | |
| )}-${String(tzDate.getUTCDate()).padStart(2, '0')}` | |
| searchPatterns.push(`usage:daily:*:${dateStr}`) | |
| } else if (timeRange === '7days') { | |
| // 最近7天 | |
| const redisClient = require('../models/redis') | |
| for (let i = 0; i < 7; i++) { | |
| const date = new Date(now) | |
| date.setDate(date.getDate() - i) | |
| const tzDate = redisClient.getDateInTimezone(date) | |
| const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( | |
| 2, | |
| '0' | |
| )}-${String(tzDate.getUTCDate()).padStart(2, '0')}` | |
| searchPatterns.push(`usage:daily:*:${dateStr}`) | |
| } | |
| } else if (timeRange === 'monthly') { | |
| // 本月 | |
| const redisClient = require('../models/redis') | |
| const tzDate = redisClient.getDateInTimezone(now) | |
| const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 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 redisClient = require('../models/redis') | |
| const tzToday = redisClient.getDateStringInTimezone(now) | |
| const tzDate = redisClient.getDateInTimezone(now) | |
| const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( | |
| 2, | |
| '0' | |
| )}` | |
| let modelKeys = [] | |
| if (timeRange === 'custom' && startDate && endDate) { | |
| // 自定义日期范围:获取范围内所有日期的模型统计 | |
| const start = new Date(startDate) | |
| const end = new Date(endDate) | |
| const currentDate = new Date(start) | |
| while (currentDate <= end) { | |
| const tzDateForKey = redisClient.getDateInTimezone(currentDate) | |
| const dateStr = `${tzDateForKey.getUTCFullYear()}-${String( | |
| tzDateForKey.getUTCMonth() + 1 | |
| ).padStart(2, '0')}-${String(tzDateForKey.getUTCDate()).padStart(2, '0')}` | |
| const dayKeys = await client.keys(`usage:${apiKey.id}:model:daily:*:${dateStr}`) | |
| modelKeys = modelKeys.concat(dayKeys) | |
| currentDate.setDate(currentDate.getDate() + 1) | |
| } | |
| } else { | |
| 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' || timeRange === 'custom') { | |
| // today和custom选项已经在查询时过滤了,不需要额外处理 | |
| } | |
| 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, | |
| cost: totalCost, | |
| formattedCost: CostCalculator.formatCost(totalCost) | |
| } | |
| // 为了保持兼容性,也更新total字段 | |
| apiKey.usage.total = apiKey.usage[timeRange] | |
| } | |
| } | |
| // 为每个API Key添加owner的displayName | |
| for (const apiKey of apiKeys) { | |
| // 如果API Key有关联的用户ID,获取用户信息 | |
| if (apiKey.userId) { | |
| try { | |
| const user = await userService.getUserById(apiKey.userId, false) | |
| if (user) { | |
| apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User' | |
| } else { | |
| apiKey.ownerDisplayName = 'Unknown User' | |
| } | |
| } catch (error) { | |
| logger.debug(`无法获取用户 ${apiKey.userId} 的信息:`, error) | |
| apiKey.ownerDisplayName = 'Unknown User' | |
| } | |
| } else { | |
| // 如果没有userId,使用createdBy字段或默认为Admin | |
| apiKey.ownerDisplayName = | |
| apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin' | |
| } | |
| } | |
| return res.json({ success: true, data: apiKeys }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get API keys:', error) | |
| return res.status(500).json({ error: 'Failed to get API keys', message: error.message }) | |
| } | |
| }) | |
| // 获取支持的客户端列表(使用新的验证器) | |
| router.get('/supported-clients', authenticateAdmin, async (req, res) => { | |
| try { | |
| // 使用新的 ClientValidator 获取所有可用客户端 | |
| const ClientValidator = require('../validators/clientValidator') | |
| const availableClients = ClientValidator.getAvailableClients() | |
| // 格式化返回数据 | |
| const clients = availableClients.map((client) => ({ | |
| id: client.id, | |
| name: client.name, | |
| description: client.description, | |
| icon: client.icon | |
| })) | |
| logger.info(`📱 Returning ${clients.length} supported clients`) | |
| return res.json({ success: true, data: clients }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get supported clients:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to get supported clients', message: error.message }) | |
| } | |
| }) | |
| // 获取已存在的标签列表 | |
| router.get('/api-keys/tags', authenticateAdmin, async (req, res) => { | |
| try { | |
| const apiKeys = await apiKeyService.getAllApiKeys() | |
| const tagSet = new Set() | |
| // 收集所有API Keys的标签 | |
| for (const apiKey of apiKeys) { | |
| if (apiKey.tags && Array.isArray(apiKey.tags)) { | |
| apiKey.tags.forEach((tag) => { | |
| if (tag && tag.trim()) { | |
| tagSet.add(tag.trim()) | |
| } | |
| }) | |
| } | |
| } | |
| // 转换为数组并排序 | |
| const tags = Array.from(tagSet).sort() | |
| logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`) | |
| return res.json({ success: true, data: tags }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get API key tags:', error) | |
| return res.status(500).json({ error: 'Failed to get API key tags', message: error.message }) | |
| } | |
| }) | |
| // 创建新的API Key | |
| router.post('/api-keys', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { | |
| name, | |
| description, | |
| tokenLimit, | |
| expiresAt, | |
| claudeAccountId, | |
| claudeConsoleAccountId, | |
| geminiAccountId, | |
| openaiAccountId, | |
| bedrockAccountId, | |
| droidAccountId, | |
| permissions, | |
| concurrencyLimit, | |
| rateLimitWindow, | |
| rateLimitRequests, | |
| rateLimitCost, | |
| enableModelRestriction, | |
| restrictedModels, | |
| enableClientRestriction, | |
| allowedClients, | |
| dailyCostLimit, | |
| totalCostLimit, | |
| weeklyOpusCostLimit, | |
| tags, | |
| activationDays, // 新增:激活后有效天数 | |
| activationUnit, // 新增:激活时间单位 (hours/days) | |
| expirationMode, // 新增:过期模式 | |
| icon // 新增:图标 | |
| } = 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 ( | |
| rateLimitWindow !== undefined && | |
| rateLimitWindow !== null && | |
| rateLimitWindow !== '' && | |
| (!Number.isInteger(Number(rateLimitWindow)) || Number(rateLimitWindow) < 1) | |
| ) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Rate limit window must be a positive integer (minutes)' }) | |
| } | |
| if ( | |
| rateLimitRequests !== undefined && | |
| rateLimitRequests !== null && | |
| rateLimitRequests !== '' && | |
| (!Number.isInteger(Number(rateLimitRequests)) || Number(rateLimitRequests) < 1) | |
| ) { | |
| return res.status(400).json({ error: 'Rate limit requests must be a positive 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' }) | |
| } | |
| // 验证客户端限制字段 | |
| 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' }) | |
| } | |
| // 验证标签字段 | |
| if (tags !== undefined && !Array.isArray(tags)) { | |
| return res.status(400).json({ error: 'Tags must be an array' }) | |
| } | |
| if (tags && tags.some((tag) => typeof tag !== 'string' || tag.trim().length === 0)) { | |
| return res.status(400).json({ error: 'All tags must be non-empty strings' }) | |
| } | |
| if ( | |
| totalCostLimit !== undefined && | |
| totalCostLimit !== null && | |
| totalCostLimit !== '' && | |
| (Number.isNaN(Number(totalCostLimit)) || Number(totalCostLimit) < 0) | |
| ) { | |
| return res.status(400).json({ error: 'Total cost limit must be a non-negative number' }) | |
| } | |
| // 验证激活相关字段 | |
| if (expirationMode && !['fixed', 'activation'].includes(expirationMode)) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Expiration mode must be either "fixed" or "activation"' }) | |
| } | |
| if (expirationMode === 'activation') { | |
| // 验证激活时间单位 | |
| if (!activationUnit || !['hours', 'days'].includes(activationUnit)) { | |
| return res.status(400).json({ | |
| error: 'Activation unit must be either "hours" or "days" when using activation mode' | |
| }) | |
| } | |
| // 验证激活时间数值 | |
| if ( | |
| !activationDays || | |
| !Number.isInteger(Number(activationDays)) || | |
| Number(activationDays) < 1 | |
| ) { | |
| const unitText = activationUnit === 'hours' ? 'hours' : 'days' | |
| return res.status(400).json({ | |
| error: `Activation ${unitText} must be a positive integer when using activation mode` | |
| }) | |
| } | |
| // 激活模式下不应该设置固定过期时间 | |
| if (expiresAt) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Cannot set fixed expiration date when using activation mode' }) | |
| } | |
| } | |
| // 验证服务权限字段 | |
| if ( | |
| permissions !== undefined && | |
| permissions !== null && | |
| permissions !== '' && | |
| !['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions) | |
| ) { | |
| return res.status(400).json({ | |
| error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' | |
| }) | |
| } | |
| const newKey = await apiKeyService.generateApiKey({ | |
| name, | |
| description, | |
| tokenLimit, | |
| expiresAt, | |
| claudeAccountId, | |
| claudeConsoleAccountId, | |
| geminiAccountId, | |
| openaiAccountId, | |
| bedrockAccountId, | |
| droidAccountId, | |
| permissions, | |
| concurrencyLimit, | |
| rateLimitWindow, | |
| rateLimitRequests, | |
| rateLimitCost, | |
| enableModelRestriction, | |
| restrictedModels, | |
| enableClientRestriction, | |
| allowedClients, | |
| dailyCostLimit, | |
| totalCostLimit, | |
| weeklyOpusCostLimit, | |
| tags, | |
| activationDays, | |
| activationUnit, | |
| expirationMode, | |
| icon | |
| }) | |
| logger.success(`🔑 Admin created new API key: ${name}`) | |
| return res.json({ success: true, data: newKey }) | |
| } catch (error) { | |
| logger.error('❌ Failed to create API key:', error) | |
| return res.status(500).json({ error: 'Failed to create API key', message: error.message }) | |
| } | |
| }) | |
| // 批量创建API Keys | |
| router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { | |
| baseName, | |
| count, | |
| description, | |
| tokenLimit, | |
| expiresAt, | |
| claudeAccountId, | |
| claudeConsoleAccountId, | |
| geminiAccountId, | |
| openaiAccountId, | |
| bedrockAccountId, | |
| droidAccountId, | |
| permissions, | |
| concurrencyLimit, | |
| rateLimitWindow, | |
| rateLimitRequests, | |
| rateLimitCost, | |
| enableModelRestriction, | |
| restrictedModels, | |
| enableClientRestriction, | |
| allowedClients, | |
| dailyCostLimit, | |
| totalCostLimit, | |
| weeklyOpusCostLimit, | |
| tags, | |
| activationDays, | |
| activationUnit, | |
| expirationMode, | |
| icon | |
| } = req.body | |
| // 输入验证 | |
| if (!baseName || typeof baseName !== 'string' || baseName.trim().length === 0) { | |
| return res.status(400).json({ error: 'Base name is required and must be a non-empty string' }) | |
| } | |
| if (!count || !Number.isInteger(count) || count < 2 || count > 500) { | |
| return res.status(400).json({ error: 'Count must be an integer between 2 and 500' }) | |
| } | |
| if (baseName.length > 90) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Base name must be less than 90 characters to allow for numbering' }) | |
| } | |
| if ( | |
| permissions !== undefined && | |
| permissions !== null && | |
| permissions !== '' && | |
| !['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions) | |
| ) { | |
| return res.status(400).json({ | |
| error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' | |
| }) | |
| } | |
| // 生成批量API Keys | |
| const createdKeys = [] | |
| const errors = [] | |
| for (let i = 1; i <= count; i++) { | |
| try { | |
| const name = `${baseName}_${i}` | |
| const newKey = await apiKeyService.generateApiKey({ | |
| name, | |
| description, | |
| tokenLimit, | |
| expiresAt, | |
| claudeAccountId, | |
| claudeConsoleAccountId, | |
| geminiAccountId, | |
| openaiAccountId, | |
| bedrockAccountId, | |
| droidAccountId, | |
| permissions, | |
| concurrencyLimit, | |
| rateLimitWindow, | |
| rateLimitRequests, | |
| rateLimitCost, | |
| enableModelRestriction, | |
| restrictedModels, | |
| enableClientRestriction, | |
| allowedClients, | |
| dailyCostLimit, | |
| totalCostLimit, | |
| weeklyOpusCostLimit, | |
| tags, | |
| activationDays, | |
| activationUnit, | |
| expirationMode, | |
| icon | |
| }) | |
| // 保留原始 API Key 供返回 | |
| createdKeys.push({ | |
| ...newKey, | |
| apiKey: newKey.apiKey | |
| }) | |
| } catch (error) { | |
| errors.push({ | |
| index: i, | |
| name: `${baseName}_${i}`, | |
| error: error.message | |
| }) | |
| } | |
| } | |
| // 如果有部分失败,返回部分成功的结果 | |
| if (errors.length > 0 && createdKeys.length === 0) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: 'Failed to create any API keys', | |
| errors | |
| }) | |
| } | |
| // 返回创建的keys(包含完整的apiKey) | |
| return res.json({ | |
| success: true, | |
| data: createdKeys, | |
| errors: errors.length > 0 ? errors : undefined, | |
| summary: { | |
| requested: count, | |
| created: createdKeys.length, | |
| failed: errors.length | |
| } | |
| }) | |
| } catch (error) { | |
| logger.error('Failed to batch create API keys:', error) | |
| return res.status(500).json({ | |
| success: false, | |
| error: 'Failed to batch create API keys', | |
| message: error.message | |
| }) | |
| } | |
| }) | |
| // 批量编辑API Keys | |
| router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { keyIds, updates } = req.body | |
| if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) { | |
| return res.status(400).json({ | |
| error: 'Invalid input', | |
| message: 'keyIds must be a non-empty array' | |
| }) | |
| } | |
| if (!updates || typeof updates !== 'object') { | |
| return res.status(400).json({ | |
| error: 'Invalid input', | |
| message: 'updates must be an object' | |
| }) | |
| } | |
| if ( | |
| updates.permissions !== undefined && | |
| !['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions) | |
| ) { | |
| return res.status(400).json({ | |
| error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' | |
| }) | |
| } | |
| logger.info( | |
| `🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}` | |
| ) | |
| logger.info(`🔍 Debug: keyIds received: ${JSON.stringify(keyIds)}`) | |
| const results = { | |
| successCount: 0, | |
| failedCount: 0, | |
| errors: [] | |
| } | |
| // 处理每个API Key | |
| for (const keyId of keyIds) { | |
| try { | |
| // 获取当前API Key信息 | |
| const currentKey = await redis.getApiKey(keyId) | |
| if (!currentKey || Object.keys(currentKey).length === 0) { | |
| results.failedCount++ | |
| results.errors.push(`API key ${keyId} not found`) | |
| continue | |
| } | |
| // 构建最终更新数据 | |
| const finalUpdates = {} | |
| // 处理普通字段 | |
| if (updates.name) { | |
| finalUpdates.name = updates.name | |
| } | |
| if (updates.tokenLimit !== undefined) { | |
| finalUpdates.tokenLimit = updates.tokenLimit | |
| } | |
| if (updates.rateLimitCost !== undefined) { | |
| finalUpdates.rateLimitCost = updates.rateLimitCost | |
| } | |
| if (updates.concurrencyLimit !== undefined) { | |
| finalUpdates.concurrencyLimit = updates.concurrencyLimit | |
| } | |
| if (updates.rateLimitWindow !== undefined) { | |
| finalUpdates.rateLimitWindow = updates.rateLimitWindow | |
| } | |
| if (updates.rateLimitRequests !== undefined) { | |
| finalUpdates.rateLimitRequests = updates.rateLimitRequests | |
| } | |
| if (updates.dailyCostLimit !== undefined) { | |
| finalUpdates.dailyCostLimit = updates.dailyCostLimit | |
| } | |
| if (updates.totalCostLimit !== undefined) { | |
| finalUpdates.totalCostLimit = updates.totalCostLimit | |
| } | |
| if (updates.weeklyOpusCostLimit !== undefined) { | |
| finalUpdates.weeklyOpusCostLimit = updates.weeklyOpusCostLimit | |
| } | |
| if (updates.permissions !== undefined) { | |
| finalUpdates.permissions = updates.permissions | |
| } | |
| if (updates.isActive !== undefined) { | |
| finalUpdates.isActive = updates.isActive | |
| } | |
| if (updates.monthlyLimit !== undefined) { | |
| finalUpdates.monthlyLimit = updates.monthlyLimit | |
| } | |
| if (updates.priority !== undefined) { | |
| finalUpdates.priority = updates.priority | |
| } | |
| if (updates.enabled !== undefined) { | |
| finalUpdates.enabled = updates.enabled | |
| } | |
| // 处理账户绑定 | |
| if (updates.claudeAccountId !== undefined) { | |
| finalUpdates.claudeAccountId = updates.claudeAccountId | |
| } | |
| if (updates.claudeConsoleAccountId !== undefined) { | |
| finalUpdates.claudeConsoleAccountId = updates.claudeConsoleAccountId | |
| } | |
| if (updates.geminiAccountId !== undefined) { | |
| finalUpdates.geminiAccountId = updates.geminiAccountId | |
| } | |
| if (updates.openaiAccountId !== undefined) { | |
| finalUpdates.openaiAccountId = updates.openaiAccountId | |
| } | |
| if (updates.bedrockAccountId !== undefined) { | |
| finalUpdates.bedrockAccountId = updates.bedrockAccountId | |
| } | |
| if (updates.droidAccountId !== undefined) { | |
| finalUpdates.droidAccountId = updates.droidAccountId || '' | |
| } | |
| // 处理标签操作 | |
| if (updates.tags !== undefined) { | |
| if (updates.tagOperation) { | |
| const currentTags = currentKey.tags ? JSON.parse(currentKey.tags) : [] | |
| const operationTags = updates.tags | |
| switch (updates.tagOperation) { | |
| case 'replace': { | |
| finalUpdates.tags = operationTags | |
| break | |
| } | |
| case 'add': { | |
| const newTags = [...currentTags] | |
| operationTags.forEach((tag) => { | |
| if (!newTags.includes(tag)) { | |
| newTags.push(tag) | |
| } | |
| }) | |
| finalUpdates.tags = newTags | |
| break | |
| } | |
| case 'remove': { | |
| finalUpdates.tags = currentTags.filter((tag) => !operationTags.includes(tag)) | |
| break | |
| } | |
| } | |
| } else { | |
| // 如果没有指定操作类型,默认为替换 | |
| finalUpdates.tags = updates.tags | |
| } | |
| } | |
| // 执行更新 | |
| await apiKeyService.updateApiKey(keyId, finalUpdates) | |
| results.successCount++ | |
| logger.success(`✅ Batch edit: API key ${keyId} updated successfully`) | |
| } catch (error) { | |
| results.failedCount++ | |
| results.errors.push(`Failed to update key ${keyId}: ${error.message}`) | |
| logger.error(`❌ Batch edit failed for key ${keyId}:`, error) | |
| } | |
| } | |
| // 记录批量编辑结果 | |
| if (results.successCount > 0) { | |
| logger.success( | |
| `🎉 Batch edit completed: ${results.successCount} successful, ${results.failedCount} failed` | |
| ) | |
| } else { | |
| logger.warn( | |
| `⚠️ Batch edit completed with no successful updates: ${results.failedCount} failed` | |
| ) | |
| } | |
| return res.json({ | |
| success: true, | |
| message: `批量编辑完成`, | |
| data: results | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to batch edit API keys:', error) | |
| return res.status(500).json({ | |
| error: 'Batch edit failed', | |
| message: error.message | |
| }) | |
| } | |
| }) | |
| // 更新API Key | |
| router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { keyId } = req.params | |
| const { | |
| name, // 添加名称字段 | |
| tokenLimit, | |
| concurrencyLimit, | |
| rateLimitWindow, | |
| rateLimitRequests, | |
| rateLimitCost, | |
| isActive, | |
| claudeAccountId, | |
| claudeConsoleAccountId, | |
| geminiAccountId, | |
| openaiAccountId, | |
| bedrockAccountId, | |
| droidAccountId, | |
| permissions, | |
| enableModelRestriction, | |
| restrictedModels, | |
| enableClientRestriction, | |
| allowedClients, | |
| expiresAt, | |
| dailyCostLimit, | |
| totalCostLimit, | |
| weeklyOpusCostLimit, | |
| tags, | |
| ownerId // 新增:所有者ID字段 | |
| } = req.body | |
| // 只允许更新指定字段 | |
| const updates = {} | |
| // 处理名称字段 | |
| if (name !== undefined && name !== null && name !== '') { | |
| const trimmedName = name.toString().trim() | |
| if (trimmedName.length === 0) { | |
| return res.status(400).json({ error: 'API Key name cannot be empty' }) | |
| } | |
| if (trimmedName.length > 100) { | |
| return res.status(400).json({ error: 'API Key name must be less than 100 characters' }) | |
| } | |
| updates.name = trimmedName | |
| } | |
| 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 (rateLimitWindow !== undefined && rateLimitWindow !== null && rateLimitWindow !== '') { | |
| if (!Number.isInteger(Number(rateLimitWindow)) || Number(rateLimitWindow) < 0) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Rate limit window must be a non-negative integer (minutes)' }) | |
| } | |
| updates.rateLimitWindow = Number(rateLimitWindow) | |
| } | |
| if (rateLimitRequests !== undefined && rateLimitRequests !== null && rateLimitRequests !== '') { | |
| if (!Number.isInteger(Number(rateLimitRequests)) || Number(rateLimitRequests) < 0) { | |
| return res.status(400).json({ error: 'Rate limit requests must be a non-negative integer' }) | |
| } | |
| updates.rateLimitRequests = Number(rateLimitRequests) | |
| } | |
| if (rateLimitCost !== undefined && rateLimitCost !== null && rateLimitCost !== '') { | |
| const cost = Number(rateLimitCost) | |
| if (isNaN(cost) || cost < 0) { | |
| return res.status(400).json({ error: 'Rate limit cost must be a non-negative number' }) | |
| } | |
| updates.rateLimitCost = cost | |
| } | |
| if (claudeAccountId !== undefined) { | |
| // 空字符串表示解绑,null或空字符串都设置为空字符串 | |
| updates.claudeAccountId = claudeAccountId || '' | |
| } | |
| if (claudeConsoleAccountId !== undefined) { | |
| // 空字符串表示解绑,null或空字符串都设置为空字符串 | |
| updates.claudeConsoleAccountId = claudeConsoleAccountId || '' | |
| } | |
| if (geminiAccountId !== undefined) { | |
| // 空字符串表示解绑,null或空字符串都设置为空字符串 | |
| updates.geminiAccountId = geminiAccountId || '' | |
| } | |
| if (openaiAccountId !== undefined) { | |
| // 空字符串表示解绑,null或空字符串都设置为空字符串 | |
| updates.openaiAccountId = openaiAccountId || '' | |
| } | |
| if (bedrockAccountId !== undefined) { | |
| // 空字符串表示解绑,null或空字符串都设置为空字符串 | |
| updates.bedrockAccountId = bedrockAccountId || '' | |
| } | |
| if (droidAccountId !== undefined) { | |
| // 空字符串表示解绑,null或空字符串都设置为空字符串 | |
| updates.droidAccountId = droidAccountId || '' | |
| } | |
| if (permissions !== undefined) { | |
| // 验证权限值 | |
| if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) { | |
| return res.status(400).json({ | |
| error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' | |
| }) | |
| } | |
| updates.permissions = permissions | |
| } | |
| // 处理模型限制字段 | |
| 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 | |
| } | |
| // 处理客户端限制字段 | |
| 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 | |
| updates.isActive = true | |
| } else { | |
| // 验证日期格式 | |
| const expireDate = new Date(expiresAt) | |
| if (isNaN(expireDate.getTime())) { | |
| return res.status(400).json({ error: 'Invalid expiration date format' }) | |
| } | |
| updates.expiresAt = expiresAt | |
| updates.isActive = expireDate > new Date() // 如果过期时间在当前时间之后,则设置为激活状态 | |
| } | |
| } | |
| // 处理每日费用限制 | |
| 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 | |
| } | |
| if (totalCostLimit !== undefined && totalCostLimit !== null && totalCostLimit !== '') { | |
| const costLimit = Number(totalCostLimit) | |
| if (isNaN(costLimit) || costLimit < 0) { | |
| return res.status(400).json({ error: 'Total cost limit must be a non-negative number' }) | |
| } | |
| updates.totalCostLimit = costLimit | |
| } | |
| // 处理 Opus 周费用限制 | |
| if ( | |
| weeklyOpusCostLimit !== undefined && | |
| weeklyOpusCostLimit !== null && | |
| weeklyOpusCostLimit !== '' | |
| ) { | |
| const costLimit = Number(weeklyOpusCostLimit) | |
| // 明确验证非负数(0 表示禁用,负数无意义) | |
| if (isNaN(costLimit) || costLimit < 0) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Weekly Opus cost limit must be a non-negative number' }) | |
| } | |
| updates.weeklyOpusCostLimit = costLimit | |
| } | |
| // 处理标签 | |
| if (tags !== undefined) { | |
| if (!Array.isArray(tags)) { | |
| return res.status(400).json({ error: 'Tags must be an array' }) | |
| } | |
| if (tags.some((tag) => typeof tag !== 'string' || tag.trim().length === 0)) { | |
| return res.status(400).json({ error: 'All tags must be non-empty strings' }) | |
| } | |
| updates.tags = tags | |
| } | |
| // 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能 | |
| if (isActive !== undefined) { | |
| if (typeof isActive !== 'boolean') { | |
| return res.status(400).json({ error: 'isActive must be a boolean' }) | |
| } | |
| updates.isActive = isActive | |
| } | |
| // 处理所有者变更 | |
| if (ownerId !== undefined) { | |
| const userService = require('../services/userService') | |
| if (ownerId === 'admin') { | |
| // 分配给Admin | |
| updates.userId = '' | |
| updates.userUsername = '' | |
| updates.createdBy = 'admin' | |
| } else if (ownerId) { | |
| // 分配给用户 | |
| try { | |
| const user = await userService.getUserById(ownerId, false) | |
| if (!user) { | |
| return res.status(400).json({ error: 'Invalid owner: User not found' }) | |
| } | |
| if (!user.isActive) { | |
| return res.status(400).json({ error: 'Cannot assign to inactive user' }) | |
| } | |
| // 设置新的所有者信息 | |
| updates.userId = ownerId | |
| updates.userUsername = user.username | |
| updates.createdBy = user.username | |
| // 管理员重新分配时,不检查用户的API Key数量限制 | |
| logger.info(`🔄 Admin reassigning API key ${keyId} to user ${user.username}`) | |
| } catch (error) { | |
| logger.error('Error fetching user for owner reassignment:', error) | |
| return res.status(400).json({ error: 'Invalid owner ID' }) | |
| } | |
| } else { | |
| // 清空所有者(分配给Admin) | |
| updates.userId = '' | |
| updates.userUsername = '' | |
| updates.createdBy = 'admin' | |
| } | |
| } | |
| await apiKeyService.updateApiKey(keyId, updates) | |
| logger.success(`📝 Admin updated API key: ${keyId}`) | |
| return res.json({ success: true, message: 'API key updated successfully' }) | |
| } catch (error) { | |
| logger.error('❌ Failed to update API key:', error) | |
| return res.status(500).json({ error: 'Failed to update API key', message: error.message }) | |
| } | |
| }) | |
| // 修改API Key过期时间(包括手动激活功能) | |
| router.patch('/api-keys/:keyId/expiration', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { keyId } = req.params | |
| const { expiresAt, activateNow } = req.body | |
| // 获取当前API Key信息 | |
| const keyData = await redis.getApiKey(keyId) | |
| if (!keyData || Object.keys(keyData).length === 0) { | |
| return res.status(404).json({ error: 'API key not found' }) | |
| } | |
| const updates = {} | |
| // 如果是激活操作(用于未激活的key) | |
| if (activateNow === true) { | |
| if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') { | |
| const now = new Date() | |
| const activationDays = parseInt(keyData.activationDays || 30) | |
| const newExpiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000) | |
| updates.isActivated = 'true' | |
| updates.activatedAt = now.toISOString() | |
| updates.expiresAt = newExpiresAt.toISOString() | |
| logger.success( | |
| `🔓 API key manually activated by admin: ${keyId} (${ | |
| keyData.name | |
| }), expires at ${newExpiresAt.toISOString()}` | |
| ) | |
| } else { | |
| return res.status(400).json({ | |
| error: 'Cannot activate', | |
| message: 'Key is either already activated or not in activation mode' | |
| }) | |
| } | |
| } | |
| // 如果提供了新的过期时间(但不是激活操作) | |
| if (expiresAt !== undefined && activateNow !== true) { | |
| // 验证过期时间格式 | |
| if (expiresAt && isNaN(Date.parse(expiresAt))) { | |
| return res.status(400).json({ error: 'Invalid expiration date format' }) | |
| } | |
| // 如果设置了过期时间,确保key是激活状态 | |
| if (expiresAt) { | |
| updates.expiresAt = new Date(expiresAt).toISOString() | |
| // 如果之前是未激活状态,现在激活它 | |
| if (keyData.isActivated !== 'true') { | |
| updates.isActivated = 'true' | |
| updates.activatedAt = new Date().toISOString() | |
| } | |
| } else { | |
| // 清除过期时间(永不过期) | |
| updates.expiresAt = '' | |
| } | |
| } | |
| if (Object.keys(updates).length === 0) { | |
| return res.status(400).json({ error: 'No valid updates provided' }) | |
| } | |
| // 更新API Key | |
| await apiKeyService.updateApiKey(keyId, updates) | |
| logger.success(`📝 Updated API key expiration: ${keyId} (${keyData.name})`) | |
| return res.json({ | |
| success: true, | |
| message: 'API key expiration updated successfully', | |
| updates | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to update API key expiration:', error) | |
| return res.status(500).json({ | |
| error: 'Failed to update API key expiration', | |
| message: error.message | |
| }) | |
| } | |
| }) | |
| // 批量删除API Keys(必须在 :keyId 路由之前定义) | |
| router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { keyIds } = req.body | |
| // 调试信息 | |
| logger.info(`🐛 Batch delete request body: ${JSON.stringify(req.body)}`) | |
| logger.info(`🐛 keyIds type: ${typeof keyIds}, value: ${JSON.stringify(keyIds)}`) | |
| // 参数验证 | |
| if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) { | |
| logger.warn( | |
| `🚨 Invalid keyIds: ${JSON.stringify({ | |
| keyIds, | |
| type: typeof keyIds, | |
| isArray: Array.isArray(keyIds) | |
| })}` | |
| ) | |
| return res.status(400).json({ | |
| error: 'Invalid request', | |
| message: 'keyIds 必须是一个非空数组' | |
| }) | |
| } | |
| if (keyIds.length > 100) { | |
| return res.status(400).json({ | |
| error: 'Too many keys', | |
| message: '每次最多只能删除100个API Keys' | |
| }) | |
| } | |
| // 验证keyIds格式 | |
| const invalidKeys = keyIds.filter((id) => !id || typeof id !== 'string') | |
| if (invalidKeys.length > 0) { | |
| return res.status(400).json({ | |
| error: 'Invalid key IDs', | |
| message: '包含无效的API Key ID' | |
| }) | |
| } | |
| logger.info( | |
| `🗑️ Admin attempting batch delete of ${keyIds.length} API keys: ${JSON.stringify(keyIds)}` | |
| ) | |
| const results = { | |
| successCount: 0, | |
| failedCount: 0, | |
| errors: [] | |
| } | |
| // 逐个删除,记录成功和失败情况 | |
| for (const keyId of keyIds) { | |
| try { | |
| // 检查API Key是否存在 | |
| const apiKey = await redis.getApiKey(keyId) | |
| if (!apiKey || Object.keys(apiKey).length === 0) { | |
| results.failedCount++ | |
| results.errors.push({ keyId, error: 'API Key 不存在' }) | |
| continue | |
| } | |
| // 执行删除 | |
| await apiKeyService.deleteApiKey(keyId) | |
| results.successCount++ | |
| logger.success(`✅ Batch delete: API key ${keyId} deleted successfully`) | |
| } catch (error) { | |
| results.failedCount++ | |
| results.errors.push({ | |
| keyId, | |
| error: error.message || '删除失败' | |
| }) | |
| logger.error(`❌ Batch delete failed for key ${keyId}:`, error) | |
| } | |
| } | |
| // 记录批量删除结果 | |
| if (results.successCount > 0) { | |
| logger.success( | |
| `🎉 Batch delete completed: ${results.successCount} successful, ${results.failedCount} failed` | |
| ) | |
| } else { | |
| logger.warn( | |
| `⚠️ Batch delete completed with no successful deletions: ${results.failedCount} failed` | |
| ) | |
| } | |
| return res.json({ | |
| success: true, | |
| message: `批量删除完成`, | |
| data: results | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to batch delete API keys:', error) | |
| return res.status(500).json({ | |
| error: 'Batch delete failed', | |
| message: error.message | |
| }) | |
| } | |
| }) | |
| // 删除单个API Key(必须在批量删除路由之后定义) | |
| router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { keyId } = req.params | |
| await apiKeyService.deleteApiKey(keyId, req.admin.username, 'admin') | |
| logger.success(`🗑️ Admin deleted API key: ${keyId}`) | |
| return res.json({ success: true, message: 'API key deleted successfully' }) | |
| } catch (error) { | |
| logger.error('❌ Failed to delete API key:', error) | |
| return res.status(500).json({ error: 'Failed to delete API key', message: error.message }) | |
| } | |
| }) | |
| // 📋 获取已删除的API Keys | |
| router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => { | |
| try { | |
| const deletedApiKeys = await apiKeyService.getAllApiKeys(true) // Include deleted | |
| const onlyDeleted = deletedApiKeys.filter((key) => key.isDeleted === 'true') | |
| // Add additional metadata for deleted keys | |
| const enrichedKeys = onlyDeleted.map((key) => ({ | |
| ...key, | |
| isDeleted: key.isDeleted === 'true', | |
| deletedAt: key.deletedAt, | |
| deletedBy: key.deletedBy, | |
| deletedByType: key.deletedByType, | |
| canRestore: true // 已删除的API Key可以恢复 | |
| })) | |
| logger.success(`📋 Admin retrieved ${enrichedKeys.length} deleted API keys`) | |
| return res.json({ success: true, apiKeys: enrichedKeys, total: enrichedKeys.length }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get deleted API keys:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to retrieve deleted API keys', message: error.message }) | |
| } | |
| }) | |
| // 🔄 恢复已删除的API Key | |
| router.post('/api-keys/:keyId/restore', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { keyId } = req.params | |
| const adminUsername = req.session?.admin?.username || 'unknown' | |
| // 调用服务层的恢复方法 | |
| const result = await apiKeyService.restoreApiKey(keyId, adminUsername, 'admin') | |
| if (result.success) { | |
| logger.success(`✅ Admin ${adminUsername} restored API key: ${keyId}`) | |
| return res.json({ | |
| success: true, | |
| message: 'API Key 已成功恢复', | |
| apiKey: result.apiKey | |
| }) | |
| } else { | |
| return res.status(400).json({ | |
| success: false, | |
| error: 'Failed to restore API key' | |
| }) | |
| } | |
| } catch (error) { | |
| logger.error('❌ Failed to restore API key:', error) | |
| // 根据错误类型返回适当的响应 | |
| if (error.message === 'API key not found') { | |
| return res.status(404).json({ | |
| success: false, | |
| error: 'API Key 不存在' | |
| }) | |
| } else if (error.message === 'API key is not deleted') { | |
| return res.status(400).json({ | |
| success: false, | |
| error: '该 API Key 未被删除,无需恢复' | |
| }) | |
| } | |
| return res.status(500).json({ | |
| success: false, | |
| error: '恢复 API Key 失败', | |
| message: error.message | |
| }) | |
| } | |
| }) | |
| // 🗑️ 彻底删除API Key(物理删除) | |
| router.delete('/api-keys/:keyId/permanent', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { keyId } = req.params | |
| const adminUsername = req.session?.admin?.username || 'unknown' | |
| // 调用服务层的彻底删除方法 | |
| const result = await apiKeyService.permanentDeleteApiKey(keyId) | |
| if (result.success) { | |
| logger.success(`🗑️ Admin ${adminUsername} permanently deleted API key: ${keyId}`) | |
| return res.json({ | |
| success: true, | |
| message: 'API Key 已彻底删除' | |
| }) | |
| } | |
| } catch (error) { | |
| logger.error('❌ Failed to permanently delete API key:', error) | |
| if (error.message === 'API key not found') { | |
| return res.status(404).json({ | |
| success: false, | |
| error: 'API Key 不存在' | |
| }) | |
| } else if (error.message === '只能彻底删除已经删除的API Key') { | |
| return res.status(400).json({ | |
| success: false, | |
| error: '只能彻底删除已经删除的API Key' | |
| }) | |
| } | |
| return res.status(500).json({ | |
| success: false, | |
| error: '彻底删除 API Key 失败', | |
| message: error.message | |
| }) | |
| } | |
| }) | |
| // 🧹 清空所有已删除的API Keys | |
| router.delete('/api-keys/deleted/clear-all', authenticateAdmin, async (req, res) => { | |
| try { | |
| const adminUsername = req.session?.admin?.username || 'unknown' | |
| // 调用服务层的清空方法 | |
| const result = await apiKeyService.clearAllDeletedApiKeys() | |
| logger.success( | |
| `🧹 Admin ${adminUsername} cleared deleted API keys: ${result.successCount}/${result.total}` | |
| ) | |
| return res.json({ | |
| success: true, | |
| message: `成功清空 ${result.successCount} 个已删除的 API Keys`, | |
| details: { | |
| total: result.total, | |
| successCount: result.successCount, | |
| failedCount: result.failedCount, | |
| errors: result.errors | |
| } | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to clear all deleted API keys:', error) | |
| return res.status(500).json({ | |
| success: false, | |
| error: '清空已删除的 API Keys 失败', | |
| message: error.message | |
| }) | |
| } | |
| }) | |
| // 👥 账户分组管理 | |
| // 创建账户分组 | |
| router.post('/account-groups', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { name, platform, description } = req.body | |
| const group = await accountGroupService.createGroup({ | |
| name, | |
| platform, | |
| description | |
| }) | |
| return res.json({ success: true, data: group }) | |
| } catch (error) { | |
| logger.error('❌ Failed to create account group:', error) | |
| return res.status(400).json({ error: error.message }) | |
| } | |
| }) | |
| // 获取所有分组 | |
| router.get('/account-groups', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { platform } = req.query | |
| const groups = await accountGroupService.getAllGroups(platform) | |
| return res.json({ success: true, data: groups }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get account groups:', error) | |
| return res.status(500).json({ error: error.message }) | |
| } | |
| }) | |
| // 获取分组详情 | |
| router.get('/account-groups/:groupId', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { groupId } = req.params | |
| const group = await accountGroupService.getGroup(groupId) | |
| if (!group) { | |
| return res.status(404).json({ error: '分组不存在' }) | |
| } | |
| return res.json({ success: true, data: group }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get account group:', error) | |
| return res.status(500).json({ error: error.message }) | |
| } | |
| }) | |
| // 更新分组 | |
| router.put('/account-groups/:groupId', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { groupId } = req.params | |
| const updates = req.body | |
| const updatedGroup = await accountGroupService.updateGroup(groupId, updates) | |
| return res.json({ success: true, data: updatedGroup }) | |
| } catch (error) { | |
| logger.error('❌ Failed to update account group:', error) | |
| return res.status(400).json({ error: error.message }) | |
| } | |
| }) | |
| // 删除分组 | |
| router.delete('/account-groups/:groupId', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { groupId } = req.params | |
| await accountGroupService.deleteGroup(groupId) | |
| return res.json({ success: true, message: '分组删除成功' }) | |
| } catch (error) { | |
| logger.error('❌ Failed to delete account group:', error) | |
| return res.status(400).json({ error: error.message }) | |
| } | |
| }) | |
| // 获取分组成员 | |
| router.get('/account-groups/:groupId/members', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { groupId } = req.params | |
| const group = await accountGroupService.getGroup(groupId) | |
| if (!group) { | |
| return res.status(404).json({ error: '分组不存在' }) | |
| } | |
| const memberIds = await accountGroupService.getGroupMembers(groupId) | |
| // 获取成员详细信息 | |
| const members = [] | |
| for (const memberId of memberIds) { | |
| // 根据分组平台优先查找对应账户 | |
| let account = null | |
| switch (group.platform) { | |
| case 'droid': | |
| account = await droidAccountService.getAccount(memberId) | |
| break | |
| case 'gemini': | |
| account = await geminiAccountService.getAccount(memberId) | |
| break | |
| case 'openai': | |
| account = await openaiAccountService.getAccount(memberId) | |
| break | |
| case 'claude': | |
| default: | |
| account = await claudeAccountService.getAccount(memberId) | |
| if (!account) { | |
| account = await claudeConsoleAccountService.getAccount(memberId) | |
| } | |
| break | |
| } | |
| // 兼容旧数据:若按平台未找到,则继续尝试其他平台 | |
| if (!account) { | |
| account = await claudeAccountService.getAccount(memberId) | |
| } | |
| if (!account) { | |
| account = await claudeConsoleAccountService.getAccount(memberId) | |
| } | |
| if (!account) { | |
| account = await geminiAccountService.getAccount(memberId) | |
| } | |
| if (!account) { | |
| account = await openaiAccountService.getAccount(memberId) | |
| } | |
| if (!account && group.platform !== 'droid') { | |
| account = await droidAccountService.getAccount(memberId) | |
| } | |
| if (account) { | |
| members.push(account) | |
| } | |
| } | |
| return res.json({ success: true, data: members }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get group members:', error) | |
| return res.status(500).json({ error: 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') | |
| return res.json({ | |
| success: true, | |
| data: { | |
| authUrl: oauthParams.authUrl, | |
| sessionId, | |
| instructions: [ | |
| '1. 复制上面的链接到浏览器中打开', | |
| '2. 登录您的 Anthropic 账户', | |
| '3. 同意应用权限', | |
| '4. 复制浏览器地址栏中的完整 URL', | |
| '5. 在添加账户表单中粘贴完整的回调 URL 和授权码' | |
| ] | |
| } | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to generate OAuth URL:', error) | |
| return 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') | |
| return 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' | |
| }) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to exchange authorization code', message: error.message }) | |
| } | |
| }) | |
| // 生成Claude setup-token授权URL | |
| router.post('/claude-accounts/generate-setup-token-url', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { proxy } = req.body // 接收代理配置 | |
| const setupTokenParams = await oauthHelper.generateSetupTokenParams() | |
| // 将codeVerifier和state临时存储到Redis,用于后续验证 | |
| const sessionId = require('crypto').randomUUID() | |
| await redis.setOAuthSession(sessionId, { | |
| type: 'setup-token', // 标记为setup-token类型 | |
| codeVerifier: setupTokenParams.codeVerifier, | |
| state: setupTokenParams.state, | |
| codeChallenge: setupTokenParams.codeChallenge, | |
| proxy: proxy || null, // 存储代理配置 | |
| createdAt: new Date().toISOString(), | |
| expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期 | |
| }) | |
| logger.success('🔗 Generated Setup Token authorization URL with proxy support') | |
| return res.json({ | |
| success: true, | |
| data: { | |
| authUrl: setupTokenParams.authUrl, | |
| sessionId, | |
| instructions: [ | |
| '1. 复制上面的链接到浏览器中打开', | |
| '2. 登录您的 Claude 账户并授权 Claude Code', | |
| '3. 完成授权后,从返回页面复制 Authorization Code', | |
| '4. 在添加账户表单中粘贴 Authorization Code' | |
| ] | |
| } | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to generate Setup Token URL:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to generate Setup Token URL', message: error.message }) | |
| } | |
| }) | |
| // 验证setup-token授权码并获取token | |
| router.post('/claude-accounts/exchange-setup-token-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' }) | |
| } | |
| // 检查是否是setup-token类型 | |
| if (oauthSession.type !== 'setup-token') { | |
| return res.status(400).json({ error: 'Invalid session type for setup token exchange' }) | |
| } | |
| // 检查会话是否过期 | |
| 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 }) | |
| } | |
| // 交换Setup Token | |
| const tokenData = await oauthHelper.exchangeSetupTokenCode( | |
| finalAuthCode, | |
| oauthSession.codeVerifier, | |
| oauthSession.state, | |
| oauthSession.proxy // 传递代理配置 | |
| ) | |
| // 清理OAuth会话 | |
| await redis.deleteOAuthSession(sessionId) | |
| logger.success('🎉 Successfully exchanged setup token authorization code for tokens') | |
| return res.json({ | |
| success: true, | |
| data: { | |
| claudeAiOauth: tokenData | |
| } | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to exchange setup token 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' | |
| }) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to exchange setup token authorization code', message: error.message }) | |
| } | |
| }) | |
| // 获取所有Claude账户 | |
| router.get('/claude-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { platform, groupId } = req.query | |
| let accounts = await claudeAccountService.getAllAccounts() | |
| // 根据查询参数进行筛选 | |
| if (platform && platform !== 'all' && platform !== 'claude') { | |
| // 如果指定了其他平台,返回空数组 | |
| accounts = [] | |
| } | |
| // 如果指定了分组筛选 | |
| if (groupId && groupId !== 'all') { | |
| if (groupId === 'ungrouped') { | |
| // 筛选未分组账户 | |
| const filteredAccounts = [] | |
| for (const account of accounts) { | |
| const groups = await accountGroupService.getAccountGroups(account.id) | |
| if (!groups || groups.length === 0) { | |
| filteredAccounts.push(account) | |
| } | |
| } | |
| accounts = filteredAccounts | |
| } else { | |
| // 筛选特定分组的账户 | |
| const groupMembers = await accountGroupService.getGroupMembers(groupId) | |
| accounts = accounts.filter((account) => groupMembers.includes(account.id)) | |
| } | |
| } | |
| // 为每个账户添加使用统计信息 | |
| const accountsWithStats = await Promise.all( | |
| accounts.map(async (account) => { | |
| try { | |
| const usageStats = await redis.getAccountUsageStats(account.id, 'openai') | |
| const groupInfos = await accountGroupService.getAccountGroups(account.id) | |
| // 获取会话窗口使用统计(仅对有活跃窗口的账户) | |
| let sessionWindowUsage = null | |
| if (account.sessionWindow && account.sessionWindow.hasActiveWindow) { | |
| const windowUsage = await redis.getAccountSessionWindowUsage( | |
| account.id, | |
| account.sessionWindow.windowStart, | |
| account.sessionWindow.windowEnd | |
| ) | |
| // 计算会话窗口的总费用 | |
| let totalCost = 0 | |
| const modelCosts = {} | |
| for (const [modelName, usage] of Object.entries(windowUsage.modelUsage)) { | |
| const usageData = { | |
| input_tokens: usage.inputTokens, | |
| output_tokens: usage.outputTokens, | |
| cache_creation_input_tokens: usage.cacheCreateTokens, | |
| cache_read_input_tokens: usage.cacheReadTokens | |
| } | |
| logger.debug(`💰 Calculating cost for model ${modelName}:`, JSON.stringify(usageData)) | |
| const costResult = CostCalculator.calculateCost(usageData, modelName) | |
| logger.debug(`💰 Cost result for ${modelName}: total=${costResult.costs.total}`) | |
| modelCosts[modelName] = { | |
| ...usage, | |
| cost: costResult.costs.total | |
| } | |
| totalCost += costResult.costs.total | |
| } | |
| sessionWindowUsage = { | |
| totalTokens: windowUsage.totalAllTokens, | |
| totalRequests: windowUsage.totalRequests, | |
| totalCost, | |
| modelUsage: modelCosts | |
| } | |
| } | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| // 转换schedulable为布尔值 | |
| schedulable: account.schedulable === 'true' || account.schedulable === true, | |
| groupInfos, | |
| usage: { | |
| daily: usageStats.daily, | |
| total: usageStats.total, | |
| averages: usageStats.averages, | |
| sessionWindow: sessionWindowUsage | |
| } | |
| } | |
| } catch (statsError) { | |
| logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message) | |
| // 如果获取统计失败,返回空统计 | |
| try { | |
| const groupInfos = await accountGroupService.getAccountGroups(account.id) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| groupInfos, | |
| usage: { | |
| daily: { tokens: 0, requests: 0, allTokens: 0 }, | |
| total: { tokens: 0, requests: 0, allTokens: 0 }, | |
| averages: { rpm: 0, tpm: 0 }, | |
| sessionWindow: null | |
| } | |
| } | |
| } catch (groupError) { | |
| logger.warn( | |
| `⚠️ Failed to get group info for account ${account.id}:`, | |
| groupError.message | |
| ) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| groupInfos: [], | |
| usage: { | |
| daily: { tokens: 0, requests: 0, allTokens: 0 }, | |
| total: { tokens: 0, requests: 0, allTokens: 0 }, | |
| averages: { rpm: 0, tpm: 0 }, | |
| sessionWindow: null | |
| } | |
| } | |
| } | |
| } | |
| }) | |
| ) | |
| return res.json({ success: true, data: accountsWithStats }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get Claude accounts:', error) | |
| return res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message }) | |
| } | |
| }) | |
| // 批量获取 Claude 账户的 OAuth Usage 数据 | |
| router.get('/claude-accounts/usage', authenticateAdmin, async (req, res) => { | |
| try { | |
| const accounts = await redis.getAllClaudeAccounts() | |
| const now = Date.now() | |
| const usageCacheTtlMs = 300 * 1000 | |
| // 批量并发获取所有活跃 OAuth 账户的 Usage | |
| const usagePromises = accounts.map(async (account) => { | |
| // 检查是否为 OAuth 账户:scopes 包含 OAuth 相关权限 | |
| const scopes = account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [] | |
| const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference') | |
| // 仅为 OAuth 授权的活跃账户调用 usage API | |
| if ( | |
| isOAuth && | |
| account.isActive === 'true' && | |
| account.accessToken && | |
| account.status === 'active' | |
| ) { | |
| // 若快照在 300 秒内更新,直接使用缓存避免频繁请求 | |
| const cachedUsage = claudeAccountService.buildClaudeUsageSnapshot(account) | |
| const lastUpdatedAt = account.claudeUsageUpdatedAt | |
| ? new Date(account.claudeUsageUpdatedAt).getTime() | |
| : 0 | |
| const isCacheFresh = cachedUsage && lastUpdatedAt && now - lastUpdatedAt < usageCacheTtlMs | |
| if (isCacheFresh) { | |
| return { | |
| accountId: account.id, | |
| claudeUsage: cachedUsage | |
| } | |
| } | |
| try { | |
| const usageData = await claudeAccountService.fetchOAuthUsage(account.id) | |
| if (usageData) { | |
| await claudeAccountService.updateClaudeUsageSnapshot(account.id, usageData) | |
| } | |
| // 重新读取更新后的数据 | |
| const updatedAccount = await redis.getClaudeAccount(account.id) | |
| return { | |
| accountId: account.id, | |
| claudeUsage: claudeAccountService.buildClaudeUsageSnapshot(updatedAccount) | |
| } | |
| } catch (error) { | |
| logger.debug(`Failed to fetch OAuth usage for ${account.id}:`, error.message) | |
| return { accountId: account.id, claudeUsage: null } | |
| } | |
| } | |
| // Setup Token 账户不调用 usage API,直接返回 null | |
| return { accountId: account.id, claudeUsage: null } | |
| }) | |
| const results = await Promise.allSettled(usagePromises) | |
| // 转换为 { accountId: usage } 映射 | |
| const usageMap = {} | |
| results.forEach((result) => { | |
| if (result.status === 'fulfilled' && result.value) { | |
| usageMap[result.value.accountId] = result.value.claudeUsage | |
| } | |
| }) | |
| res.json({ success: true, data: usageMap }) | |
| } catch (error) { | |
| logger.error('❌ Failed to fetch Claude accounts usage:', error) | |
| res.status(500).json({ error: 'Failed to fetch usage data', message: error.message }) | |
| } | |
| }) | |
| // 创建新的Claude账户 | |
| router.post('/claude-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { | |
| name, | |
| description, | |
| email, | |
| password, | |
| refreshToken, | |
| claudeAiOauth, | |
| proxy, | |
| accountType, | |
| platform = 'claude', | |
| priority, | |
| groupId, | |
| groupIds, | |
| autoStopOnWarning, | |
| useUnifiedUserAgent, | |
| useUnifiedClientId, | |
| unifiedClientId, | |
| expiresAt | |
| } = req.body | |
| if (!name) { | |
| return res.status(400).json({ error: 'Name is required' }) | |
| } | |
| // 验证accountType的有效性 | |
| if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) | |
| } | |
| // 如果是分组类型,验证groupId或groupIds | |
| if (accountType === 'group' && !groupId && (!groupIds || groupIds.length === 0)) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Group ID or Group IDs are required for group type accounts' }) | |
| } | |
| // 验证priority的有效性 | |
| if ( | |
| priority !== undefined && | |
| (typeof priority !== 'number' || priority < 1 || priority > 100) | |
| ) { | |
| return res.status(400).json({ error: 'Priority must be a number between 1 and 100' }) | |
| } | |
| const newAccount = await claudeAccountService.createAccount({ | |
| name, | |
| description, | |
| email, | |
| password, | |
| refreshToken, | |
| claudeAiOauth, | |
| proxy, | |
| accountType: accountType || 'shared', // 默认为共享类型 | |
| platform, | |
| priority: priority || 50, // 默认优先级为50 | |
| autoStopOnWarning: autoStopOnWarning === true, // 默认为false | |
| useUnifiedUserAgent: useUnifiedUserAgent === true, // 默认为false | |
| useUnifiedClientId: useUnifiedClientId === true, // 默认为false | |
| unifiedClientId: unifiedClientId || '', // 统一的客户端标识 | |
| expiresAt: expiresAt || null // 账户订阅到期时间 | |
| }) | |
| // 如果是分组类型,将账户添加到分组 | |
| if (accountType === 'group') { | |
| if (groupIds && groupIds.length > 0) { | |
| // 使用多分组设置 | |
| await accountGroupService.setAccountGroups(newAccount.id, groupIds, newAccount.platform) | |
| } else if (groupId) { | |
| // 兼容单分组模式 | |
| await accountGroupService.addAccountToGroup(newAccount.id, groupId, newAccount.platform) | |
| } | |
| } | |
| logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`) | |
| const formattedAccount = formatAccountExpiry(newAccount) | |
| return res.json({ success: true, data: formattedAccount }) | |
| } catch (error) { | |
| logger.error('❌ Failed to create Claude account:', error) | |
| return 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 | |
| // ✅ 【修改】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt(提前到参数验证之前) | |
| const mappedUpdates = mapExpiryField(updates, 'Claude', accountId) | |
| // 验证priority的有效性 | |
| if ( | |
| mappedUpdates.priority !== undefined && | |
| (typeof mappedUpdates.priority !== 'number' || | |
| mappedUpdates.priority < 1 || | |
| mappedUpdates.priority > 100) | |
| ) { | |
| return res.status(400).json({ error: 'Priority must be a number between 1 and 100' }) | |
| } | |
| // 验证accountType的有效性 | |
| if ( | |
| mappedUpdates.accountType && | |
| !['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType) | |
| ) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) | |
| } | |
| // 如果更新为分组类型,验证groupId或groupIds | |
| if ( | |
| mappedUpdates.accountType === 'group' && | |
| !mappedUpdates.groupId && | |
| (!mappedUpdates.groupIds || mappedUpdates.groupIds.length === 0) | |
| ) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Group ID or Group IDs are required for group type accounts' }) | |
| } | |
| // 获取账户当前信息以处理分组变更 | |
| const currentAccount = await claudeAccountService.getAccount(accountId) | |
| if (!currentAccount) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| // 处理分组的变更 | |
| if (mappedUpdates.accountType !== undefined) { | |
| // 如果之前是分组类型,需要从所有分组中移除 | |
| if (currentAccount.accountType === 'group') { | |
| await accountGroupService.removeAccountFromAllGroups(accountId) | |
| } | |
| // 如果新类型是分组,添加到新分组 | |
| if (mappedUpdates.accountType === 'group') { | |
| // 处理多分组/单分组的兼容性 | |
| if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) { | |
| if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) { | |
| // 使用多分组设置 | |
| await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude') | |
| } else { | |
| // groupIds 为空数组,从所有分组中移除 | |
| await accountGroupService.removeAccountFromAllGroups(accountId) | |
| } | |
| } else if (mappedUpdates.groupId) { | |
| // 兼容单分组模式 | |
| await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude') | |
| } | |
| } | |
| } | |
| await claudeAccountService.updateAccount(accountId, mappedUpdates) | |
| logger.success(`📝 Admin updated Claude account: ${accountId}`) | |
| return res.json({ success: true, message: 'Claude account updated successfully' }) | |
| } catch (error) { | |
| logger.error('❌ Failed to update Claude account:', error) | |
| return 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 | |
| // 自动解绑所有绑定的 API Keys | |
| const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'claude') | |
| // 获取账户信息以检查是否在分组中 | |
| const account = await claudeAccountService.getAccount(accountId) | |
| if (account && account.accountType === 'group') { | |
| const groups = await accountGroupService.getAccountGroups(accountId) | |
| for (const group of groups) { | |
| await accountGroupService.removeAccountFromGroup(accountId, group.id) | |
| } | |
| } | |
| await claudeAccountService.deleteAccount(accountId) | |
| let message = 'Claude账号已成功删除' | |
| if (unboundCount > 0) { | |
| message += `,${unboundCount} 个 API Key 已切换为共享池模式` | |
| } | |
| logger.success(`🗑️ Admin deleted Claude account: ${accountId}, unbound ${unboundCount} keys`) | |
| return res.json({ | |
| success: true, | |
| message, | |
| unboundKeys: unboundCount | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to delete Claude account:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to delete Claude account', message: error.message }) | |
| } | |
| }) | |
| // 更新单个Claude账户的Profile信息 | |
| router.post('/claude-accounts/:accountId/update-profile', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId) | |
| logger.success(`✅ Updated profile for Claude account: ${accountId}`) | |
| return res.json({ | |
| success: true, | |
| message: 'Account profile updated successfully', | |
| data: profileInfo | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to update account profile:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to update account profile', message: error.message }) | |
| } | |
| }) | |
| // 批量更新所有Claude账户的Profile信息 | |
| router.post('/claude-accounts/update-all-profiles', authenticateAdmin, async (req, res) => { | |
| try { | |
| const result = await claudeAccountService.updateAllAccountProfiles() | |
| logger.success('✅ Batch profile update completed') | |
| return res.json({ | |
| success: true, | |
| message: 'Batch profile update completed', | |
| data: result | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to update all account profiles:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to update all account profiles', 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}`) | |
| return res.json({ success: true, data: result }) | |
| } catch (error) { | |
| logger.error('❌ Failed to refresh Claude account token:', error) | |
| return res.status(500).json({ error: 'Failed to refresh token', message: error.message }) | |
| } | |
| }) | |
| // 重置Claude账户状态(清除所有异常状态) | |
| router.post('/claude-accounts/:accountId/reset-status', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const result = await claudeAccountService.resetAccountStatus(accountId) | |
| logger.success(`✅ Admin reset status for Claude account: ${accountId}`) | |
| return res.json({ success: true, data: result }) | |
| } catch (error) { | |
| logger.error('❌ Failed to reset Claude account status:', error) | |
| return res.status(500).json({ error: 'Failed to reset status', message: error.message }) | |
| } | |
| }) | |
| // 切换Claude账户调度状态 | |
| router.put( | |
| '/claude-accounts/:accountId/toggle-schedulable', | |
| authenticateAdmin, | |
| async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const accounts = await claudeAccountService.getAllAccounts() | |
| const account = accounts.find((acc) => acc.id === accountId) | |
| if (!account) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| const newSchedulable = !account.schedulable | |
| await claudeAccountService.updateAccount(accountId, { schedulable: newSchedulable }) | |
| // 如果账号被禁用,发送webhook通知 | |
| if (!newSchedulable) { | |
| await webhookNotifier.sendAccountAnomalyNotification({ | |
| accountId: account.id, | |
| accountName: account.name || account.claudeAiOauth?.email || 'Claude Account', | |
| platform: 'claude-oauth', | |
| status: 'disabled', | |
| errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED', | |
| reason: '账号已被管理员手动禁用调度', | |
| timestamp: new Date().toISOString() | |
| }) | |
| } | |
| logger.success( | |
| `🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${ | |
| newSchedulable ? 'schedulable' : 'not schedulable' | |
| }` | |
| ) | |
| return res.json({ success: true, schedulable: newSchedulable }) | |
| } catch (error) { | |
| logger.error('❌ Failed to toggle Claude account schedulable status:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to toggle schedulable status', message: error.message }) | |
| } | |
| } | |
| ) | |
| // 🎮 Claude Console 账户管理 | |
| // 获取所有Claude Console账户 | |
| router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { platform, groupId } = req.query | |
| let accounts = await claudeConsoleAccountService.getAllAccounts() | |
| // 根据查询参数进行筛选 | |
| if (platform && platform !== 'all' && platform !== 'claude-console') { | |
| // 如果指定了其他平台,返回空数组 | |
| accounts = [] | |
| } | |
| // 如果指定了分组筛选 | |
| if (groupId && groupId !== 'all') { | |
| if (groupId === 'ungrouped') { | |
| // 筛选未分组账户 | |
| const filteredAccounts = [] | |
| for (const account of accounts) { | |
| const groups = await accountGroupService.getAccountGroups(account.id) | |
| if (!groups || groups.length === 0) { | |
| filteredAccounts.push(account) | |
| } | |
| } | |
| accounts = filteredAccounts | |
| } else { | |
| // 筛选特定分组的账户 | |
| const groupMembers = await accountGroupService.getGroupMembers(groupId) | |
| accounts = accounts.filter((account) => groupMembers.includes(account.id)) | |
| } | |
| } | |
| // 为每个账户添加使用统计信息 | |
| const accountsWithStats = await Promise.all( | |
| accounts.map(async (account) => { | |
| try { | |
| const usageStats = await redis.getAccountUsageStats(account.id, 'openai') | |
| const groupInfos = await accountGroupService.getAccountGroups(account.id) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| // 转换schedulable为布尔值 | |
| schedulable: account.schedulable === 'true' || account.schedulable === true, | |
| groupInfos, | |
| usage: { | |
| daily: usageStats.daily, | |
| total: usageStats.total, | |
| averages: usageStats.averages | |
| } | |
| } | |
| } catch (statsError) { | |
| logger.warn( | |
| `⚠️ Failed to get usage stats for Claude Console account ${account.id}:`, | |
| statsError.message | |
| ) | |
| try { | |
| const groupInfos = await accountGroupService.getAccountGroups(account.id) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| // 转换schedulable为布尔值 | |
| schedulable: account.schedulable === 'true' || account.schedulable === true, | |
| groupInfos, | |
| usage: { | |
| daily: { tokens: 0, requests: 0, allTokens: 0 }, | |
| total: { tokens: 0, requests: 0, allTokens: 0 }, | |
| averages: { rpm: 0, tpm: 0 } | |
| } | |
| } | |
| } catch (groupError) { | |
| logger.warn( | |
| `⚠️ Failed to get group info for Claude Console account ${account.id}:`, | |
| groupError.message | |
| ) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| groupInfos: [], | |
| usage: { | |
| daily: { tokens: 0, requests: 0, allTokens: 0 }, | |
| total: { tokens: 0, requests: 0, allTokens: 0 }, | |
| averages: { rpm: 0, tpm: 0 } | |
| } | |
| } | |
| } | |
| } | |
| }) | |
| ) | |
| return res.json({ success: true, data: accountsWithStats }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get Claude Console accounts:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to get Claude Console accounts', message: error.message }) | |
| } | |
| }) | |
| // 创建新的Claude Console账户 | |
| router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { | |
| name, | |
| description, | |
| apiUrl, | |
| apiKey, | |
| priority, | |
| supportedModels, | |
| userAgent, | |
| rateLimitDuration, | |
| proxy, | |
| accountType, | |
| groupId, | |
| dailyQuota, | |
| quotaResetTime | |
| } = req.body | |
| if (!name || !apiUrl || !apiKey) { | |
| return res.status(400).json({ error: 'Name, API URL and API Key are required' }) | |
| } | |
| // 验证priority的有效性(1-100) | |
| if (priority !== undefined && (priority < 1 || priority > 100)) { | |
| return res.status(400).json({ error: 'Priority must be between 1 and 100' }) | |
| } | |
| // 验证accountType的有效性 | |
| if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) | |
| } | |
| // 如果是分组类型,验证groupId | |
| if (accountType === 'group' && !groupId) { | |
| return res.status(400).json({ error: 'Group ID is required for group type accounts' }) | |
| } | |
| const newAccount = await claudeConsoleAccountService.createAccount({ | |
| name, | |
| description, | |
| apiUrl, | |
| apiKey, | |
| priority: priority || 50, | |
| supportedModels: supportedModels || [], | |
| userAgent, | |
| rateLimitDuration: | |
| rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60, | |
| proxy, | |
| accountType: accountType || 'shared', | |
| dailyQuota: dailyQuota || 0, | |
| quotaResetTime: quotaResetTime || '00:00' | |
| }) | |
| // 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组) | |
| if (accountType === 'group' && groupId) { | |
| await accountGroupService.addAccountToGroup(newAccount.id, groupId, 'claude') | |
| } | |
| logger.success(`🎮 Admin created Claude Console account: ${name}`) | |
| const formattedAccount = formatAccountExpiry(newAccount) | |
| return res.json({ success: true, data: formattedAccount }) | |
| } catch (error) { | |
| logger.error('❌ Failed to create Claude Console account:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to create Claude Console account', message: error.message }) | |
| } | |
| }) | |
| // 更新Claude Console账户 | |
| router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const updates = req.body | |
| // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt | |
| const mappedUpdates = mapExpiryField(updates, 'Claude Console', accountId) | |
| // 验证priority的有效性(1-100) | |
| if ( | |
| mappedUpdates.priority !== undefined && | |
| (mappedUpdates.priority < 1 || mappedUpdates.priority > 100) | |
| ) { | |
| return res.status(400).json({ error: 'Priority must be between 1 and 100' }) | |
| } | |
| // 验证accountType的有效性 | |
| if ( | |
| mappedUpdates.accountType && | |
| !['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType) | |
| ) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) | |
| } | |
| // 如果更新为分组类型,验证groupId | |
| if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) { | |
| return res.status(400).json({ error: 'Group ID is required for group type accounts' }) | |
| } | |
| // 获取账户当前信息以处理分组变更 | |
| const currentAccount = await claudeConsoleAccountService.getAccount(accountId) | |
| if (!currentAccount) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| // 处理分组的变更 | |
| if (mappedUpdates.accountType !== undefined) { | |
| // 如果之前是分组类型,需要从所有分组中移除 | |
| if (currentAccount.accountType === 'group') { | |
| const oldGroups = await accountGroupService.getAccountGroups(accountId) | |
| for (const oldGroup of oldGroups) { | |
| await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) | |
| } | |
| } | |
| // 如果新类型是分组,处理多分组支持 | |
| if (mappedUpdates.accountType === 'group') { | |
| if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) { | |
| // 如果明确提供了 groupIds 参数(包括空数组) | |
| if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) { | |
| // 设置新的多分组 | |
| await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude') | |
| } else { | |
| // groupIds 为空数组,从所有分组中移除 | |
| await accountGroupService.removeAccountFromAllGroups(accountId) | |
| } | |
| } else if (mappedUpdates.groupId) { | |
| // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 | |
| await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude') | |
| } | |
| } | |
| } | |
| await claudeConsoleAccountService.updateAccount(accountId, mappedUpdates) | |
| logger.success(`📝 Admin updated Claude Console account: ${accountId}`) | |
| return res.json({ success: true, message: 'Claude Console account updated successfully' }) | |
| } catch (error) { | |
| logger.error('❌ Failed to update Claude Console account:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to update Claude Console account', message: error.message }) | |
| } | |
| }) | |
| // 删除Claude Console账户 | |
| router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| // 自动解绑所有绑定的 API Keys | |
| const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'claude-console') | |
| // 获取账户信息以检查是否在分组中 | |
| const account = await claudeConsoleAccountService.getAccount(accountId) | |
| if (account && account.accountType === 'group') { | |
| const groups = await accountGroupService.getAccountGroups(accountId) | |
| for (const group of groups) { | |
| await accountGroupService.removeAccountFromGroup(accountId, group.id) | |
| } | |
| } | |
| await claudeConsoleAccountService.deleteAccount(accountId) | |
| let message = 'Claude Console账号已成功删除' | |
| if (unboundCount > 0) { | |
| message += `,${unboundCount} 个 API Key 已切换为共享池模式` | |
| } | |
| logger.success( | |
| `🗑️ Admin deleted Claude Console account: ${accountId}, unbound ${unboundCount} keys` | |
| ) | |
| return res.json({ | |
| success: true, | |
| message, | |
| unboundKeys: unboundCount | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to delete Claude Console account:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to delete Claude Console account', message: error.message }) | |
| } | |
| }) | |
| // 切换Claude Console账户状态 | |
| router.put('/claude-console-accounts/:accountId/toggle', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const account = await claudeConsoleAccountService.getAccount(accountId) | |
| if (!account) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| const newStatus = !account.isActive | |
| await claudeConsoleAccountService.updateAccount(accountId, { isActive: newStatus }) | |
| logger.success( | |
| `🔄 Admin toggled Claude Console account status: ${accountId} -> ${ | |
| newStatus ? 'active' : 'inactive' | |
| }` | |
| ) | |
| return res.json({ success: true, isActive: newStatus }) | |
| } catch (error) { | |
| logger.error('❌ Failed to toggle Claude Console account status:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to toggle account status', message: error.message }) | |
| } | |
| }) | |
| // 切换Claude Console账户调度状态 | |
| router.put( | |
| '/claude-console-accounts/:accountId/toggle-schedulable', | |
| authenticateAdmin, | |
| async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const account = await claudeConsoleAccountService.getAccount(accountId) | |
| if (!account) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| const newSchedulable = !account.schedulable | |
| await claudeConsoleAccountService.updateAccount(accountId, { schedulable: newSchedulable }) | |
| // 如果账号被禁用,发送webhook通知 | |
| if (!newSchedulable) { | |
| await webhookNotifier.sendAccountAnomalyNotification({ | |
| accountId: account.id, | |
| accountName: account.name || 'Claude Console Account', | |
| platform: 'claude-console', | |
| status: 'disabled', | |
| errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED', | |
| reason: '账号已被管理员手动禁用调度', | |
| timestamp: new Date().toISOString() | |
| }) | |
| } | |
| logger.success( | |
| `🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${ | |
| newSchedulable ? 'schedulable' : 'not schedulable' | |
| }` | |
| ) | |
| return res.json({ success: true, schedulable: newSchedulable }) | |
| } catch (error) { | |
| logger.error('❌ Failed to toggle Claude Console account schedulable status:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to toggle schedulable status', message: error.message }) | |
| } | |
| } | |
| ) | |
| // 获取Claude Console账户的使用统计 | |
| router.get('/claude-console-accounts/:accountId/usage', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const usageStats = await claudeConsoleAccountService.getAccountUsageStats(accountId) | |
| if (!usageStats) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| return res.json(usageStats) | |
| } catch (error) { | |
| logger.error('❌ Failed to get Claude Console account usage stats:', error) | |
| return res.status(500).json({ error: 'Failed to get usage stats', message: error.message }) | |
| } | |
| }) | |
| // 手动重置Claude Console账户的每日使用量 | |
| router.post( | |
| '/claude-console-accounts/:accountId/reset-usage', | |
| authenticateAdmin, | |
| async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| await claudeConsoleAccountService.resetDailyUsage(accountId) | |
| logger.success(`✅ Admin manually reset daily usage for Claude Console account: ${accountId}`) | |
| return res.json({ success: true, message: 'Daily usage reset successfully' }) | |
| } catch (error) { | |
| logger.error('❌ Failed to reset Claude Console account daily usage:', error) | |
| return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message }) | |
| } | |
| } | |
| ) | |
| // 重置Claude Console账户状态(清除所有异常状态) | |
| router.post( | |
| '/claude-console-accounts/:accountId/reset-status', | |
| authenticateAdmin, | |
| async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const result = await claudeConsoleAccountService.resetAccountStatus(accountId) | |
| logger.success(`✅ Admin reset status for Claude Console account: ${accountId}`) | |
| return res.json({ success: true, data: result }) | |
| } catch (error) { | |
| logger.error('❌ Failed to reset Claude Console account status:', error) | |
| return res.status(500).json({ error: 'Failed to reset status', message: error.message }) | |
| } | |
| } | |
| ) | |
| // 手动重置所有Claude Console账户的每日使用量 | |
| router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async (req, res) => { | |
| try { | |
| await claudeConsoleAccountService.resetAllDailyUsage() | |
| logger.success('✅ Admin manually reset daily usage for all Claude Console accounts') | |
| return res.json({ success: true, message: 'All daily usage reset successfully' }) | |
| } catch (error) { | |
| logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to reset all daily usage', message: error.message }) | |
| } | |
| }) | |
| // 🔧 CCR 账户管理 | |
| // 获取所有CCR账户 | |
| router.get('/ccr-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { platform, groupId } = req.query | |
| let accounts = await ccrAccountService.getAllAccounts() | |
| // 根据查询参数进行筛选 | |
| if (platform && platform !== 'all' && platform !== 'ccr') { | |
| // 如果指定了其他平台,返回空数组 | |
| accounts = [] | |
| } | |
| // 如果指定了分组筛选 | |
| if (groupId && groupId !== 'all') { | |
| if (groupId === 'ungrouped') { | |
| // 筛选未分组账户 | |
| const filteredAccounts = [] | |
| for (const account of accounts) { | |
| const groups = await accountGroupService.getAccountGroups(account.id) | |
| if (!groups || groups.length === 0) { | |
| filteredAccounts.push(account) | |
| } | |
| } | |
| accounts = filteredAccounts | |
| } else { | |
| // 筛选特定分组的账户 | |
| const groupMembers = await accountGroupService.getGroupMembers(groupId) | |
| accounts = accounts.filter((account) => groupMembers.includes(account.id)) | |
| } | |
| } | |
| // 为每个账户添加使用统计信息 | |
| const accountsWithStats = await Promise.all( | |
| accounts.map(async (account) => { | |
| try { | |
| const usageStats = await redis.getAccountUsageStats(account.id) | |
| const groupInfos = await accountGroupService.getAccountGroups(account.id) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| // 转换schedulable为布尔值 | |
| schedulable: account.schedulable === 'true' || account.schedulable === true, | |
| groupInfos, | |
| usage: { | |
| daily: usageStats.daily, | |
| total: usageStats.total, | |
| averages: usageStats.averages | |
| } | |
| } | |
| } catch (statsError) { | |
| logger.warn( | |
| `⚠️ Failed to get usage stats for CCR account ${account.id}:`, | |
| statsError.message | |
| ) | |
| try { | |
| const groupInfos = await accountGroupService.getAccountGroups(account.id) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| // 转换schedulable为布尔值 | |
| schedulable: account.schedulable === 'true' || account.schedulable === true, | |
| groupInfos, | |
| usage: { | |
| daily: { tokens: 0, requests: 0, allTokens: 0 }, | |
| total: { tokens: 0, requests: 0, allTokens: 0 }, | |
| averages: { rpm: 0, tpm: 0 } | |
| } | |
| } | |
| } catch (groupError) { | |
| logger.warn( | |
| `⚠️ Failed to get group info for CCR account ${account.id}:`, | |
| groupError.message | |
| ) | |
| return { | |
| ...account, | |
| groupInfos: [], | |
| usage: { | |
| daily: { tokens: 0, requests: 0, allTokens: 0 }, | |
| total: { tokens: 0, requests: 0, allTokens: 0 }, | |
| averages: { rpm: 0, tpm: 0 } | |
| } | |
| } | |
| } | |
| } | |
| }) | |
| ) | |
| return res.json({ success: true, data: accountsWithStats }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get CCR accounts:', error) | |
| return res.status(500).json({ error: 'Failed to get CCR accounts', message: error.message }) | |
| } | |
| }) | |
| // 创建新的CCR账户 | |
| router.post('/ccr-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { | |
| name, | |
| description, | |
| apiUrl, | |
| apiKey, | |
| priority, | |
| supportedModels, | |
| userAgent, | |
| rateLimitDuration, | |
| proxy, | |
| accountType, | |
| groupId, | |
| dailyQuota, | |
| quotaResetTime | |
| } = req.body | |
| if (!name || !apiUrl || !apiKey) { | |
| return res.status(400).json({ error: 'Name, API URL and API Key are required' }) | |
| } | |
| // 验证priority的有效性(1-100) | |
| if (priority !== undefined && (priority < 1 || priority > 100)) { | |
| return res.status(400).json({ error: 'Priority must be between 1 and 100' }) | |
| } | |
| // 验证accountType的有效性 | |
| if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) | |
| } | |
| // 如果是分组类型,验证groupId | |
| if (accountType === 'group' && !groupId) { | |
| return res.status(400).json({ error: 'Group ID is required for group type accounts' }) | |
| } | |
| const newAccount = await ccrAccountService.createAccount({ | |
| name, | |
| description, | |
| apiUrl, | |
| apiKey, | |
| priority: priority || 50, | |
| supportedModels: supportedModels || [], | |
| userAgent, | |
| rateLimitDuration: | |
| rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60, | |
| proxy, | |
| accountType: accountType || 'shared', | |
| dailyQuota: dailyQuota || 0, | |
| quotaResetTime: quotaResetTime || '00:00' | |
| }) | |
| // 如果是分组类型,将账户添加到分组 | |
| if (accountType === 'group' && groupId) { | |
| await accountGroupService.addAccountToGroup(newAccount.id, groupId) | |
| } | |
| logger.success(`🔧 Admin created CCR account: ${name}`) | |
| const formattedAccount = formatAccountExpiry(newAccount) | |
| return res.json({ success: true, data: formattedAccount }) | |
| } catch (error) { | |
| logger.error('❌ Failed to create CCR account:', error) | |
| return res.status(500).json({ error: 'Failed to create CCR account', message: error.message }) | |
| } | |
| }) | |
| // 更新CCR账户 | |
| router.put('/ccr-accounts/:accountId', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const updates = req.body | |
| // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt | |
| const mappedUpdates = mapExpiryField(updates, 'CCR', accountId) | |
| // 验证priority的有效性(1-100) | |
| if ( | |
| mappedUpdates.priority !== undefined && | |
| (mappedUpdates.priority < 1 || mappedUpdates.priority > 100) | |
| ) { | |
| return res.status(400).json({ error: 'Priority must be between 1 and 100' }) | |
| } | |
| // 验证accountType的有效性 | |
| if ( | |
| mappedUpdates.accountType && | |
| !['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType) | |
| ) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) | |
| } | |
| // 如果更新为分组类型,验证groupId | |
| if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) { | |
| return res.status(400).json({ error: 'Group ID is required for group type accounts' }) | |
| } | |
| // 获取账户当前信息以处理分组变更 | |
| const currentAccount = await ccrAccountService.getAccount(accountId) | |
| if (!currentAccount) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| // 处理分组的变更 | |
| if (mappedUpdates.accountType !== undefined) { | |
| // 如果之前是分组类型,需要从所有分组中移除 | |
| if (currentAccount.accountType === 'group') { | |
| const oldGroups = await accountGroupService.getAccountGroups(accountId) | |
| for (const oldGroup of oldGroups) { | |
| await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) | |
| } | |
| } | |
| // 如果新类型是分组,处理多分组支持 | |
| if (mappedUpdates.accountType === 'group') { | |
| if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) { | |
| // 如果明确提供了 groupIds 参数(包括空数组) | |
| if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) { | |
| // 设置新的多分组 | |
| await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude') | |
| } else { | |
| // groupIds 为空数组,从所有分组中移除 | |
| await accountGroupService.removeAccountFromAllGroups(accountId) | |
| } | |
| } else if (mappedUpdates.groupId) { | |
| // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 | |
| await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude') | |
| } | |
| } | |
| } | |
| await ccrAccountService.updateAccount(accountId, mappedUpdates) | |
| logger.success(`📝 Admin updated CCR account: ${accountId}`) | |
| return res.json({ success: true, message: 'CCR account updated successfully' }) | |
| } catch (error) { | |
| logger.error('❌ Failed to update CCR account:', error) | |
| return res.status(500).json({ error: 'Failed to update CCR account', message: error.message }) | |
| } | |
| }) | |
| // 删除CCR账户 | |
| router.delete('/ccr-accounts/:accountId', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| // 尝试自动解绑(CCR账户实际上不会绑定API Key,但保持代码一致性) | |
| const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'ccr') | |
| // 获取账户信息以检查是否在分组中 | |
| const account = await ccrAccountService.getAccount(accountId) | |
| if (account && account.accountType === 'group') { | |
| const groups = await accountGroupService.getAccountGroups(accountId) | |
| for (const group of groups) { | |
| await accountGroupService.removeAccountFromGroup(accountId, group.id) | |
| } | |
| } | |
| await ccrAccountService.deleteAccount(accountId) | |
| let message = 'CCR账号已成功删除' | |
| if (unboundCount > 0) { | |
| // 理论上不会发生,但保持消息格式一致 | |
| message += `,${unboundCount} 个 API Key 已切换为共享池模式` | |
| } | |
| logger.success(`🗑️ Admin deleted CCR account: ${accountId}`) | |
| return res.json({ | |
| success: true, | |
| message, | |
| unboundKeys: unboundCount | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to delete CCR account:', error) | |
| return res.status(500).json({ error: 'Failed to delete CCR account', message: error.message }) | |
| } | |
| }) | |
| // 切换CCR账户状态 | |
| router.put('/ccr-accounts/:accountId/toggle', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const account = await ccrAccountService.getAccount(accountId) | |
| if (!account) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| const newStatus = !account.isActive | |
| await ccrAccountService.updateAccount(accountId, { isActive: newStatus }) | |
| logger.success( | |
| `🔄 Admin toggled CCR account status: ${accountId} -> ${newStatus ? 'active' : 'inactive'}` | |
| ) | |
| return res.json({ success: true, isActive: newStatus }) | |
| } catch (error) { | |
| logger.error('❌ Failed to toggle CCR account status:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to toggle account status', message: error.message }) | |
| } | |
| }) | |
| // 切换CCR账户调度状态 | |
| router.put('/ccr-accounts/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const account = await ccrAccountService.getAccount(accountId) | |
| if (!account) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| const newSchedulable = !account.schedulable | |
| await ccrAccountService.updateAccount(accountId, { schedulable: newSchedulable }) | |
| // 如果账号被禁用,发送webhook通知 | |
| if (!newSchedulable) { | |
| await webhookNotifier.sendAccountAnomalyNotification({ | |
| accountId: account.id, | |
| accountName: account.name || 'CCR Account', | |
| platform: 'ccr', | |
| status: 'disabled', | |
| errorCode: 'CCR_MANUALLY_DISABLED', | |
| reason: '账号已被管理员手动禁用调度', | |
| timestamp: new Date().toISOString() | |
| }) | |
| } | |
| logger.success( | |
| `🔄 Admin toggled CCR account schedulable status: ${accountId} -> ${ | |
| newSchedulable ? 'schedulable' : 'not schedulable' | |
| }` | |
| ) | |
| return res.json({ success: true, schedulable: newSchedulable }) | |
| } catch (error) { | |
| logger.error('❌ Failed to toggle CCR account schedulable status:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to toggle schedulable status', message: error.message }) | |
| } | |
| }) | |
| // 获取CCR账户的使用统计 | |
| router.get('/ccr-accounts/:accountId/usage', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const usageStats = await ccrAccountService.getAccountUsageStats(accountId) | |
| if (!usageStats) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| return res.json(usageStats) | |
| } catch (error) { | |
| logger.error('❌ Failed to get CCR account usage stats:', error) | |
| return res.status(500).json({ error: 'Failed to get usage stats', message: error.message }) | |
| } | |
| }) | |
| // 手动重置CCR账户的每日使用量 | |
| router.post('/ccr-accounts/:accountId/reset-usage', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| await ccrAccountService.resetDailyUsage(accountId) | |
| logger.success(`✅ Admin manually reset daily usage for CCR account: ${accountId}`) | |
| return res.json({ success: true, message: 'Daily usage reset successfully' }) | |
| } catch (error) { | |
| logger.error('❌ Failed to reset CCR account daily usage:', error) | |
| return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message }) | |
| } | |
| }) | |
| // 重置CCR账户状态(清除所有异常状态) | |
| router.post('/ccr-accounts/:accountId/reset-status', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const result = await ccrAccountService.resetAccountStatus(accountId) | |
| logger.success(`✅ Admin reset status for CCR account: ${accountId}`) | |
| return res.json({ success: true, data: result }) | |
| } catch (error) { | |
| logger.error('❌ Failed to reset CCR account status:', error) | |
| return res.status(500).json({ error: 'Failed to reset status', message: error.message }) | |
| } | |
| }) | |
| // 手动重置所有CCR账户的每日使用量 | |
| router.post('/ccr-accounts/reset-all-usage', authenticateAdmin, async (req, res) => { | |
| try { | |
| await ccrAccountService.resetAllDailyUsage() | |
| logger.success('✅ Admin manually reset daily usage for all CCR accounts') | |
| return res.json({ success: true, message: 'All daily usage reset successfully' }) | |
| } catch (error) { | |
| logger.error('❌ Failed to reset all CCR accounts daily usage:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to reset all daily usage', message: error.message }) | |
| } | |
| }) | |
| // ☁️ Bedrock 账户管理 | |
| // 获取所有Bedrock账户 | |
| router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { platform, groupId } = req.query | |
| const result = await bedrockAccountService.getAllAccounts() | |
| if (!result.success) { | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to get Bedrock accounts', message: result.error }) | |
| } | |
| let accounts = result.data | |
| // 根据查询参数进行筛选 | |
| if (platform && platform !== 'all' && platform !== 'bedrock') { | |
| // 如果指定了其他平台,返回空数组 | |
| accounts = [] | |
| } | |
| // 如果指定了分组筛选 | |
| if (groupId && groupId !== 'all') { | |
| if (groupId === 'ungrouped') { | |
| // 筛选未分组账户 | |
| const filteredAccounts = [] | |
| for (const account of accounts) { | |
| const groups = await accountGroupService.getAccountGroups(account.id) | |
| if (!groups || groups.length === 0) { | |
| filteredAccounts.push(account) | |
| } | |
| } | |
| accounts = filteredAccounts | |
| } else { | |
| // 筛选特定分组的账户 | |
| const groupMembers = await accountGroupService.getGroupMembers(groupId) | |
| accounts = accounts.filter((account) => groupMembers.includes(account.id)) | |
| } | |
| } | |
| // 为每个账户添加使用统计信息 | |
| const accountsWithStats = await Promise.all( | |
| accounts.map(async (account) => { | |
| try { | |
| const usageStats = await redis.getAccountUsageStats(account.id, 'openai') | |
| const groupInfos = await accountGroupService.getAccountGroups(account.id) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| groupInfos, | |
| usage: { | |
| daily: usageStats.daily, | |
| total: usageStats.total, | |
| averages: usageStats.averages | |
| } | |
| } | |
| } catch (statsError) { | |
| logger.warn( | |
| `⚠️ Failed to get usage stats for Bedrock account ${account.id}:`, | |
| statsError.message | |
| ) | |
| try { | |
| const groupInfos = await accountGroupService.getAccountGroups(account.id) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| groupInfos, | |
| usage: { | |
| daily: { tokens: 0, requests: 0, allTokens: 0 }, | |
| total: { tokens: 0, requests: 0, allTokens: 0 }, | |
| averages: { rpm: 0, tpm: 0 } | |
| } | |
| } | |
| } catch (groupError) { | |
| logger.warn( | |
| `⚠️ Failed to get group info for account ${account.id}:`, | |
| groupError.message | |
| ) | |
| return { | |
| ...account, | |
| groupInfos: [], | |
| usage: { | |
| daily: { tokens: 0, requests: 0, allTokens: 0 }, | |
| total: { tokens: 0, requests: 0, allTokens: 0 }, | |
| averages: { rpm: 0, tpm: 0 } | |
| } | |
| } | |
| } | |
| } | |
| }) | |
| ) | |
| return res.json({ success: true, data: accountsWithStats }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get Bedrock accounts:', error) | |
| return res.status(500).json({ error: 'Failed to get Bedrock accounts', message: error.message }) | |
| } | |
| }) | |
| // 创建新的Bedrock账户 | |
| router.post('/bedrock-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { | |
| name, | |
| description, | |
| region, | |
| awsCredentials, | |
| defaultModel, | |
| priority, | |
| accountType, | |
| credentialType | |
| } = req.body | |
| if (!name) { | |
| return res.status(400).json({ error: 'Name is required' }) | |
| } | |
| // 验证priority的有效性(1-100) | |
| if (priority !== undefined && (priority < 1 || priority > 100)) { | |
| return res.status(400).json({ error: 'Priority must be between 1 and 100' }) | |
| } | |
| // 验证accountType的有效性 | |
| if (accountType && !['shared', 'dedicated'].includes(accountType)) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Invalid account type. Must be "shared" or "dedicated"' }) | |
| } | |
| // 验证credentialType的有效性 | |
| if (credentialType && !['default', 'access_key', 'bearer_token'].includes(credentialType)) { | |
| return res.status(400).json({ | |
| error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"' | |
| }) | |
| } | |
| const result = await bedrockAccountService.createAccount({ | |
| name, | |
| description: description || '', | |
| region: region || 'us-east-1', | |
| awsCredentials, | |
| defaultModel, | |
| priority: priority || 50, | |
| accountType: accountType || 'shared', | |
| credentialType: credentialType || 'default' | |
| }) | |
| if (!result.success) { | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to create Bedrock account', message: result.error }) | |
| } | |
| logger.success(`☁️ Admin created Bedrock account: ${name}`) | |
| const formattedAccount = formatAccountExpiry(result.data) | |
| return res.json({ success: true, data: formattedAccount }) | |
| } catch (error) { | |
| logger.error('❌ Failed to create Bedrock account:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to create Bedrock account', message: error.message }) | |
| } | |
| }) | |
| // 更新Bedrock账户 | |
| router.put('/bedrock-accounts/:accountId', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const updates = req.body | |
| // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt | |
| const mappedUpdates = mapExpiryField(updates, 'Bedrock', accountId) | |
| // 验证priority的有效性(1-100) | |
| if ( | |
| mappedUpdates.priority !== undefined && | |
| (mappedUpdates.priority < 1 || mappedUpdates.priority > 100) | |
| ) { | |
| return res.status(400).json({ error: 'Priority must be between 1 and 100' }) | |
| } | |
| // 验证accountType的有效性 | |
| if (mappedUpdates.accountType && !['shared', 'dedicated'].includes(mappedUpdates.accountType)) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Invalid account type. Must be "shared" or "dedicated"' }) | |
| } | |
| // 验证credentialType的有效性 | |
| if ( | |
| mappedUpdates.credentialType && | |
| !['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType) | |
| ) { | |
| return res.status(400).json({ | |
| error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"' | |
| }) | |
| } | |
| const result = await bedrockAccountService.updateAccount(accountId, mappedUpdates) | |
| if (!result.success) { | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to update Bedrock account', message: result.error }) | |
| } | |
| logger.success(`📝 Admin updated Bedrock account: ${accountId}`) | |
| return res.json({ success: true, message: 'Bedrock account updated successfully' }) | |
| } catch (error) { | |
| logger.error('❌ Failed to update Bedrock account:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to update Bedrock account', message: error.message }) | |
| } | |
| }) | |
| // 删除Bedrock账户 | |
| router.delete('/bedrock-accounts/:accountId', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| // 自动解绑所有绑定的 API Keys | |
| const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'bedrock') | |
| const result = await bedrockAccountService.deleteAccount(accountId) | |
| if (!result.success) { | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to delete Bedrock account', message: result.error }) | |
| } | |
| let message = 'Bedrock账号已成功删除' | |
| if (unboundCount > 0) { | |
| message += `,${unboundCount} 个 API Key 已切换为共享池模式` | |
| } | |
| logger.success(`🗑️ Admin deleted Bedrock account: ${accountId}, unbound ${unboundCount} keys`) | |
| return res.json({ | |
| success: true, | |
| message, | |
| unboundKeys: unboundCount | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to delete Bedrock account:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to delete Bedrock account', message: error.message }) | |
| } | |
| }) | |
| // 切换Bedrock账户状态 | |
| router.put('/bedrock-accounts/:accountId/toggle', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const accountResult = await bedrockAccountService.getAccount(accountId) | |
| if (!accountResult.success) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| const newStatus = !accountResult.data.isActive | |
| const updateResult = await bedrockAccountService.updateAccount(accountId, { | |
| isActive: newStatus | |
| }) | |
| if (!updateResult.success) { | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to toggle account status', message: updateResult.error }) | |
| } | |
| logger.success( | |
| `🔄 Admin toggled Bedrock account status: ${accountId} -> ${ | |
| newStatus ? 'active' : 'inactive' | |
| }` | |
| ) | |
| return res.json({ success: true, isActive: newStatus }) | |
| } catch (error) { | |
| logger.error('❌ Failed to toggle Bedrock account status:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to toggle account status', message: error.message }) | |
| } | |
| }) | |
| // 切换Bedrock账户调度状态 | |
| router.put( | |
| '/bedrock-accounts/:accountId/toggle-schedulable', | |
| authenticateAdmin, | |
| async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const accountResult = await bedrockAccountService.getAccount(accountId) | |
| if (!accountResult.success) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| const newSchedulable = !accountResult.data.schedulable | |
| const updateResult = await bedrockAccountService.updateAccount(accountId, { | |
| schedulable: newSchedulable | |
| }) | |
| if (!updateResult.success) { | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to toggle schedulable status', message: updateResult.error }) | |
| } | |
| // 如果账号被禁用,发送webhook通知 | |
| if (!newSchedulable) { | |
| await webhookNotifier.sendAccountAnomalyNotification({ | |
| accountId: accountResult.data.id, | |
| accountName: accountResult.data.name || 'Bedrock Account', | |
| platform: 'bedrock', | |
| status: 'disabled', | |
| errorCode: 'BEDROCK_MANUALLY_DISABLED', | |
| reason: '账号已被管理员手动禁用调度', | |
| timestamp: new Date().toISOString() | |
| }) | |
| } | |
| logger.success( | |
| `🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${ | |
| newSchedulable ? 'schedulable' : 'not schedulable' | |
| }` | |
| ) | |
| return res.json({ success: true, schedulable: newSchedulable }) | |
| } catch (error) { | |
| logger.error('❌ Failed to toggle Bedrock account schedulable status:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to toggle schedulable status', message: error.message }) | |
| } | |
| } | |
| ) | |
| // 测试Bedrock账户连接 | |
| router.post('/bedrock-accounts/:accountId/test', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const result = await bedrockAccountService.testAccount(accountId) | |
| if (!result.success) { | |
| return res.status(500).json({ error: 'Account test failed', message: result.error }) | |
| } | |
| logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`) | |
| return res.json({ success: true, data: result.data }) | |
| } catch (error) { | |
| logger.error('❌ Failed to test Bedrock account:', error) | |
| return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message }) | |
| } | |
| }) | |
| // 🤖 Gemini 账户管理 | |
| // 生成 Gemini OAuth 授权 URL | |
| router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { state, proxy } = req.body // 接收代理配置 | |
| // 使用新的 codeassist.google.com 回调地址 | |
| const redirectUri = 'https://codeassist.google.com/authcode' | |
| logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`) | |
| const { | |
| authUrl, | |
| state: authState, | |
| codeVerifier, | |
| redirectUri: finalRedirectUri | |
| } = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy) | |
| // 创建 OAuth 会话,包含 codeVerifier 和代理配置 | |
| const sessionId = authState | |
| await redis.setOAuthSession(sessionId, { | |
| state: authState, | |
| type: 'gemini', | |
| redirectUri: finalRedirectUri, | |
| codeVerifier, // 保存 PKCE code verifier | |
| proxy: proxy || null, // 保存代理配置 | |
| createdAt: new Date().toISOString() | |
| }) | |
| logger.info(`Generated Gemini OAuth URL with session: ${sessionId}`) | |
| return res.json({ | |
| success: true, | |
| data: { | |
| authUrl, | |
| sessionId | |
| } | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to generate Gemini auth URL:', error) | |
| return res.status(500).json({ error: 'Failed to generate auth URL', message: error.message }) | |
| } | |
| }) | |
| // 轮询 Gemini OAuth 授权状态 | |
| router.post('/gemini-accounts/poll-auth-status', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { sessionId } = req.body | |
| if (!sessionId) { | |
| return res.status(400).json({ error: 'Session ID is required' }) | |
| } | |
| const result = await geminiAccountService.pollAuthorizationStatus(sessionId) | |
| if (result.success) { | |
| logger.success(`✅ Gemini OAuth authorization successful for session: ${sessionId}`) | |
| return res.json({ success: true, data: { tokens: result.tokens } }) | |
| } else { | |
| return res.json({ success: false, error: result.error }) | |
| } | |
| } catch (error) { | |
| logger.error('❌ Failed to poll Gemini auth status:', error) | |
| return res.status(500).json({ error: 'Failed to poll auth status', message: error.message }) | |
| } | |
| }) | |
| // 交换 Gemini 授权码 | |
| router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { code, sessionId, proxy: requestProxy } = req.body | |
| if (!code) { | |
| return res.status(400).json({ error: 'Authorization code is required' }) | |
| } | |
| let redirectUri = 'https://codeassist.google.com/authcode' | |
| let codeVerifier = null | |
| let proxyConfig = null | |
| // 如果提供了 sessionId,从 OAuth 会话中获取信息 | |
| if (sessionId) { | |
| const sessionData = await redis.getOAuthSession(sessionId) | |
| if (sessionData) { | |
| const { | |
| redirectUri: sessionRedirectUri, | |
| codeVerifier: sessionCodeVerifier, | |
| proxy | |
| } = sessionData | |
| redirectUri = sessionRedirectUri || redirectUri | |
| codeVerifier = sessionCodeVerifier | |
| proxyConfig = proxy // 获取代理配置 | |
| logger.info( | |
| `Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}` | |
| ) | |
| } | |
| } | |
| // 如果请求体中直接提供了代理配置,优先使用它 | |
| if (requestProxy) { | |
| proxyConfig = requestProxy | |
| logger.info( | |
| `Using proxy from request body: ${proxyConfig ? JSON.stringify(proxyConfig) : 'none'}` | |
| ) | |
| } | |
| const tokens = await geminiAccountService.exchangeCodeForTokens( | |
| code, | |
| redirectUri, | |
| codeVerifier, | |
| proxyConfig // 传递代理配置 | |
| ) | |
| // 清理 OAuth 会话 | |
| if (sessionId) { | |
| await redis.deleteOAuthSession(sessionId) | |
| } | |
| logger.success('✅ Successfully exchanged Gemini authorization code') | |
| return res.json({ success: true, data: { tokens } }) | |
| } catch (error) { | |
| logger.error('❌ Failed to exchange Gemini authorization code:', error) | |
| return res.status(500).json({ error: 'Failed to exchange code', message: error.message }) | |
| } | |
| }) | |
| // 获取所有 Gemini 账户 | |
| router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { platform, groupId } = req.query | |
| let accounts = await geminiAccountService.getAllAccounts() | |
| // 根据查询参数进行筛选 | |
| if (platform && platform !== 'all' && platform !== 'gemini') { | |
| // 如果指定了其他平台,返回空数组 | |
| accounts = [] | |
| } | |
| // 如果指定了分组筛选 | |
| if (groupId && groupId !== 'all') { | |
| if (groupId === 'ungrouped') { | |
| // 筛选未分组账户 | |
| const filteredAccounts = [] | |
| for (const account of accounts) { | |
| const groups = await accountGroupService.getAccountGroups(account.id) | |
| if (!groups || groups.length === 0) { | |
| filteredAccounts.push(account) | |
| } | |
| } | |
| accounts = filteredAccounts | |
| } else { | |
| // 筛选特定分组的账户 | |
| const groupMembers = await accountGroupService.getGroupMembers(groupId) | |
| accounts = accounts.filter((account) => groupMembers.includes(account.id)) | |
| } | |
| } | |
| // 为每个账户添加使用统计信息(与Claude账户相同的逻辑) | |
| const accountsWithStats = await Promise.all( | |
| accounts.map(async (account) => { | |
| try { | |
| const usageStats = await redis.getAccountUsageStats(account.id, 'openai') | |
| const groupInfos = await accountGroupService.getAccountGroups(account.id) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| groupInfos, | |
| usage: { | |
| daily: usageStats.daily, | |
| total: usageStats.total, | |
| averages: usageStats.averages | |
| } | |
| } | |
| } catch (statsError) { | |
| logger.warn( | |
| `⚠️ Failed to get usage stats for Gemini account ${account.id}:`, | |
| statsError.message | |
| ) | |
| // 如果获取统计失败,返回空统计 | |
| try { | |
| const groupInfos = await accountGroupService.getAccountGroups(account.id) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| groupInfos, | |
| usage: { | |
| daily: { tokens: 0, requests: 0, allTokens: 0 }, | |
| total: { tokens: 0, requests: 0, allTokens: 0 }, | |
| averages: { rpm: 0, tpm: 0 } | |
| } | |
| } | |
| } catch (groupError) { | |
| logger.warn( | |
| `⚠️ Failed to get group info for account ${account.id}:`, | |
| groupError.message | |
| ) | |
| return { | |
| ...account, | |
| groupInfos: [], | |
| usage: { | |
| daily: { tokens: 0, requests: 0, allTokens: 0 }, | |
| total: { tokens: 0, requests: 0, allTokens: 0 }, | |
| averages: { rpm: 0, tpm: 0 } | |
| } | |
| } | |
| } | |
| } | |
| }) | |
| ) | |
| return res.json({ success: true, data: accountsWithStats }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get Gemini accounts:', error) | |
| return res.status(500).json({ error: 'Failed to get accounts', message: error.message }) | |
| } | |
| }) | |
| // 创建新的 Gemini 账户 | |
| router.post('/gemini-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const accountData = req.body | |
| // 输入验证 | |
| if (!accountData.name) { | |
| return res.status(400).json({ error: 'Account name is required' }) | |
| } | |
| // 验证accountType的有效性 | |
| if ( | |
| accountData.accountType && | |
| !['shared', 'dedicated', 'group'].includes(accountData.accountType) | |
| ) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) | |
| } | |
| // 如果是分组类型,验证groupId | |
| if (accountData.accountType === 'group' && !accountData.groupId) { | |
| return res.status(400).json({ error: 'Group ID is required for group type accounts' }) | |
| } | |
| const newAccount = await geminiAccountService.createAccount(accountData) | |
| // 如果是分组类型,将账户添加到分组 | |
| if (accountData.accountType === 'group' && accountData.groupId) { | |
| await accountGroupService.addAccountToGroup(newAccount.id, accountData.groupId, 'gemini') | |
| } | |
| logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`) | |
| const formattedAccount = formatAccountExpiry(newAccount) | |
| return res.json({ success: true, data: formattedAccount }) | |
| } catch (error) { | |
| logger.error('❌ Failed to create Gemini account:', error) | |
| return res.status(500).json({ error: 'Failed to create account', message: error.message }) | |
| } | |
| }) | |
| // 更新 Gemini 账户 | |
| router.put('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const updates = req.body | |
| // 验证accountType的有效性 | |
| if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) | |
| } | |
| // 如果更新为分组类型,验证groupId | |
| if (updates.accountType === 'group' && !updates.groupId) { | |
| return res.status(400).json({ error: 'Group ID is required for group type accounts' }) | |
| } | |
| // 获取账户当前信息以处理分组变更 | |
| const currentAccount = await geminiAccountService.getAccount(accountId) | |
| if (!currentAccount) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt | |
| const mappedUpdates = mapExpiryField(updates, 'Gemini', accountId) | |
| // 处理分组的变更 | |
| if (mappedUpdates.accountType !== undefined) { | |
| // 如果之前是分组类型,需要从所有分组中移除 | |
| if (currentAccount.accountType === 'group') { | |
| const oldGroups = await accountGroupService.getAccountGroups(accountId) | |
| for (const oldGroup of oldGroups) { | |
| await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) | |
| } | |
| } | |
| // 如果新类型是分组,处理多分组支持 | |
| if (mappedUpdates.accountType === 'group') { | |
| if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) { | |
| // 如果明确提供了 groupIds 参数(包括空数组) | |
| if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) { | |
| // 设置新的多分组 | |
| await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'gemini') | |
| } else { | |
| // groupIds 为空数组,从所有分组中移除 | |
| await accountGroupService.removeAccountFromAllGroups(accountId) | |
| } | |
| } else if (mappedUpdates.groupId) { | |
| // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 | |
| await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'gemini') | |
| } | |
| } | |
| } | |
| const updatedAccount = await geminiAccountService.updateAccount(accountId, mappedUpdates) | |
| logger.success(`📝 Admin updated Gemini account: ${accountId}`) | |
| return res.json({ success: true, data: updatedAccount }) | |
| } catch (error) { | |
| logger.error('❌ Failed to update Gemini account:', error) | |
| return res.status(500).json({ error: 'Failed to update account', message: error.message }) | |
| } | |
| }) | |
| // 删除 Gemini 账户 | |
| router.delete('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| // 自动解绑所有绑定的 API Keys | |
| const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'gemini') | |
| // 获取账户信息以检查是否在分组中 | |
| const account = await geminiAccountService.getAccount(accountId) | |
| if (account && account.accountType === 'group') { | |
| const groups = await accountGroupService.getAccountGroups(accountId) | |
| for (const group of groups) { | |
| await accountGroupService.removeAccountFromGroup(accountId, group.id) | |
| } | |
| } | |
| await geminiAccountService.deleteAccount(accountId) | |
| let message = 'Gemini账号已成功删除' | |
| if (unboundCount > 0) { | |
| message += `,${unboundCount} 个 API Key 已切换为共享池模式` | |
| } | |
| logger.success(`🗑️ Admin deleted Gemini account: ${accountId}, unbound ${unboundCount} keys`) | |
| return res.json({ | |
| success: true, | |
| message, | |
| unboundKeys: unboundCount | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to delete Gemini account:', error) | |
| return res.status(500).json({ error: 'Failed to delete account', message: error.message }) | |
| } | |
| }) | |
| // 刷新 Gemini 账户 token | |
| router.post('/gemini-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const result = await geminiAccountService.refreshAccountToken(accountId) | |
| logger.success(`🔄 Admin refreshed token for Gemini account: ${accountId}`) | |
| return res.json({ success: true, data: result }) | |
| } catch (error) { | |
| logger.error('❌ Failed to refresh Gemini account token:', error) | |
| return res.status(500).json({ error: 'Failed to refresh token', message: error.message }) | |
| } | |
| }) | |
| // 切换 Gemini 账户调度状态 | |
| router.put( | |
| '/gemini-accounts/:accountId/toggle-schedulable', | |
| authenticateAdmin, | |
| async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const account = await geminiAccountService.getAccount(accountId) | |
| if (!account) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| // 现在 account.schedulable 已经是布尔值了,直接取反即可 | |
| const newSchedulable = !account.schedulable | |
| await geminiAccountService.updateAccount(accountId, { schedulable: String(newSchedulable) }) | |
| // 验证更新是否成功,重新获取账户信息 | |
| const updatedAccount = await geminiAccountService.getAccount(accountId) | |
| const actualSchedulable = updatedAccount ? updatedAccount.schedulable : newSchedulable | |
| // 如果账号被禁用,发送webhook通知 | |
| if (!actualSchedulable) { | |
| await webhookNotifier.sendAccountAnomalyNotification({ | |
| accountId: account.id, | |
| accountName: account.accountName || 'Gemini Account', | |
| platform: 'gemini', | |
| status: 'disabled', | |
| errorCode: 'GEMINI_MANUALLY_DISABLED', | |
| reason: '账号已被管理员手动禁用调度', | |
| timestamp: new Date().toISOString() | |
| }) | |
| } | |
| logger.success( | |
| `🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${ | |
| actualSchedulable ? 'schedulable' : 'not schedulable' | |
| }` | |
| ) | |
| // 返回实际的数据库值,确保前端状态与后端一致 | |
| return res.json({ success: true, schedulable: actualSchedulable }) | |
| } catch (error) { | |
| logger.error('❌ Failed to toggle Gemini account schedulable status:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to toggle schedulable status', message: error.message }) | |
| } | |
| } | |
| ) | |
| // 📊 账户使用统计 | |
| // 获取所有账户的使用统计 | |
| router.get('/accounts/usage-stats', authenticateAdmin, async (req, res) => { | |
| try { | |
| const accountsStats = await redis.getAllAccountsUsageStats() | |
| return 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) | |
| return 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' | |
| }) | |
| } | |
| return 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) | |
| return res.status(500).json({ | |
| success: false, | |
| error: 'Failed to get account usage stats', | |
| message: error.message | |
| }) | |
| } | |
| }) | |
| // 获取账号近30天使用历史 | |
| router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const { platform = 'claude', days = 30 } = req.query | |
| const allowedPlatforms = [ | |
| 'claude', | |
| 'claude-console', | |
| 'openai', | |
| 'openai-responses', | |
| 'gemini', | |
| 'droid' | |
| ] | |
| if (!allowedPlatforms.includes(platform)) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: 'Unsupported account platform' | |
| }) | |
| } | |
| const accountTypeMap = { | |
| openai: 'openai', | |
| 'openai-responses': 'openai-responses', | |
| droid: 'droid' | |
| } | |
| const fallbackModelMap = { | |
| claude: 'claude-3-5-sonnet-20241022', | |
| 'claude-console': 'claude-3-5-sonnet-20241022', | |
| openai: 'gpt-4o-mini-2024-07-18', | |
| 'openai-responses': 'gpt-4o-mini-2024-07-18', | |
| gemini: 'gemini-1.5-flash', | |
| droid: 'unknown' | |
| } | |
| // 获取账户信息以获取创建时间 | |
| let accountData = null | |
| let accountCreatedAt = null | |
| try { | |
| switch (platform) { | |
| case 'claude': | |
| accountData = await claudeAccountService.getAccount(accountId) | |
| break | |
| case 'claude-console': | |
| accountData = await claudeConsoleAccountService.getAccount(accountId) | |
| break | |
| case 'openai': | |
| accountData = await openaiAccountService.getAccount(accountId) | |
| break | |
| case 'openai-responses': | |
| accountData = await openaiResponsesAccountService.getAccount(accountId) | |
| break | |
| case 'gemini': | |
| accountData = await geminiAccountService.getAccount(accountId) | |
| break | |
| case 'droid': | |
| accountData = await droidAccountService.getAccount(accountId) | |
| break | |
| } | |
| if (accountData && accountData.createdAt) { | |
| accountCreatedAt = new Date(accountData.createdAt) | |
| } | |
| } catch (error) { | |
| logger.warn(`Failed to get account data for avgDailyCost calculation: ${error.message}`) | |
| } | |
| const client = redis.getClientSafe() | |
| const fallbackModel = fallbackModelMap[platform] || 'unknown' | |
| const daysCount = Math.min(Math.max(parseInt(days, 10) || 30, 1), 60) | |
| // 获取概览统计数据 | |
| const accountUsageStats = await redis.getAccountUsageStats( | |
| accountId, | |
| accountTypeMap[platform] || null | |
| ) | |
| const history = [] | |
| let totalCost = 0 | |
| let totalRequests = 0 | |
| let totalTokens = 0 | |
| let highestCostDay = null | |
| let highestRequestDay = null | |
| const sumModelCostsForDay = async (dateKey) => { | |
| const modelPattern = `account_usage:model:daily:${accountId}:*:${dateKey}` | |
| const modelKeys = await client.keys(modelPattern) | |
| let summedCost = 0 | |
| if (modelKeys.length === 0) { | |
| return summedCost | |
| } | |
| for (const modelKey of modelKeys) { | |
| const modelParts = modelKey.split(':') | |
| const modelName = modelParts[4] || 'unknown' | |
| const modelData = await client.hgetall(modelKey) | |
| if (!modelData || Object.keys(modelData).length === 0) { | |
| continue | |
| } | |
| const usage = { | |
| input_tokens: parseInt(modelData.inputTokens) || 0, | |
| output_tokens: parseInt(modelData.outputTokens) || 0, | |
| cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, | |
| cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 | |
| } | |
| const costResult = CostCalculator.calculateCost(usage, modelName) | |
| summedCost += costResult.costs.total | |
| } | |
| return summedCost | |
| } | |
| const today = new Date() | |
| for (let offset = daysCount - 1; offset >= 0; offset--) { | |
| const date = new Date(today) | |
| date.setDate(date.getDate() - offset) | |
| const tzDate = redis.getDateInTimezone(date) | |
| const dateKey = redis.getDateStringInTimezone(date) | |
| const monthLabel = String(tzDate.getUTCMonth() + 1).padStart(2, '0') | |
| const dayLabel = String(tzDate.getUTCDate()).padStart(2, '0') | |
| const label = `${monthLabel}/${dayLabel}` | |
| const dailyKey = `account_usage:daily:${accountId}:${dateKey}` | |
| const dailyData = await client.hgetall(dailyKey) | |
| const inputTokens = parseInt(dailyData?.inputTokens) || 0 | |
| const outputTokens = parseInt(dailyData?.outputTokens) || 0 | |
| const cacheCreateTokens = parseInt(dailyData?.cacheCreateTokens) || 0 | |
| const cacheReadTokens = parseInt(dailyData?.cacheReadTokens) || 0 | |
| const allTokens = | |
| parseInt(dailyData?.allTokens) || | |
| inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens | |
| const requests = parseInt(dailyData?.requests) || 0 | |
| let cost = await sumModelCostsForDay(dateKey) | |
| if (cost === 0 && allTokens > 0) { | |
| const fallbackUsage = { | |
| input_tokens: inputTokens, | |
| output_tokens: outputTokens, | |
| cache_creation_input_tokens: cacheCreateTokens, | |
| cache_read_input_tokens: cacheReadTokens | |
| } | |
| const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel) | |
| cost = fallbackResult.costs.total | |
| } | |
| const normalizedCost = Math.round(cost * 1_000_000) / 1_000_000 | |
| totalCost += normalizedCost | |
| totalRequests += requests | |
| totalTokens += allTokens | |
| if (!highestCostDay || normalizedCost > highestCostDay.cost) { | |
| highestCostDay = { | |
| date: dateKey, | |
| label, | |
| cost: normalizedCost, | |
| formattedCost: CostCalculator.formatCost(normalizedCost) | |
| } | |
| } | |
| if (!highestRequestDay || requests > highestRequestDay.requests) { | |
| highestRequestDay = { | |
| date: dateKey, | |
| label, | |
| requests | |
| } | |
| } | |
| history.push({ | |
| date: dateKey, | |
| label, | |
| cost: normalizedCost, | |
| formattedCost: CostCalculator.formatCost(normalizedCost), | |
| requests, | |
| tokens: allTokens | |
| }) | |
| } | |
| // 计算实际使用天数(从账户创建到现在) | |
| let actualDaysForAvg = daysCount | |
| if (accountCreatedAt) { | |
| const now = new Date() | |
| const diffTime = Math.abs(now - accountCreatedAt) | |
| const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) | |
| // 使用实际使用天数,但不超过请求的天数范围 | |
| actualDaysForAvg = Math.min(diffDays, daysCount) | |
| // 至少为1天,避免除零 | |
| actualDaysForAvg = Math.max(actualDaysForAvg, 1) | |
| } | |
| // 使用实际天数计算日均值 | |
| const avgDailyCost = actualDaysForAvg > 0 ? totalCost / actualDaysForAvg : 0 | |
| const avgDailyRequests = actualDaysForAvg > 0 ? totalRequests / actualDaysForAvg : 0 | |
| const avgDailyTokens = actualDaysForAvg > 0 ? totalTokens / actualDaysForAvg : 0 | |
| const todayData = history.length > 0 ? history[history.length - 1] : null | |
| return res.json({ | |
| success: true, | |
| data: { | |
| history, | |
| summary: { | |
| days: daysCount, | |
| actualDaysUsed: actualDaysForAvg, // 实际使用的天数(用于计算日均值) | |
| accountCreatedAt: accountCreatedAt ? accountCreatedAt.toISOString() : null, | |
| totalCost, | |
| totalCostFormatted: CostCalculator.formatCost(totalCost), | |
| totalRequests, | |
| totalTokens, | |
| avgDailyCost, | |
| avgDailyCostFormatted: CostCalculator.formatCost(avgDailyCost), | |
| avgDailyRequests, | |
| avgDailyTokens, | |
| today: todayData | |
| ? { | |
| date: todayData.date, | |
| cost: todayData.cost, | |
| costFormatted: todayData.formattedCost, | |
| requests: todayData.requests, | |
| tokens: todayData.tokens | |
| } | |
| : null, | |
| highestCostDay, | |
| highestRequestDay | |
| }, | |
| overview: accountUsageStats, | |
| generatedAt: new Date().toISOString() | |
| } | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get account usage history:', error) | |
| return res.status(500).json({ | |
| success: false, | |
| error: 'Failed to get account usage history', | |
| message: error.message | |
| }) | |
| } | |
| }) | |
| // 📊 系统统计 | |
| // 获取系统概览 | |
| router.get('/dashboard', authenticateAdmin, async (req, res) => { | |
| try { | |
| const [ | |
| , | |
| apiKeys, | |
| claudeAccounts, | |
| claudeConsoleAccounts, | |
| geminiAccounts, | |
| bedrockAccountsResult, | |
| openaiAccounts, | |
| ccrAccounts, | |
| openaiResponsesAccounts, | |
| droidAccounts, | |
| todayStats, | |
| systemAverages, | |
| realtimeMetrics | |
| ] = await Promise.all([ | |
| redis.getSystemStats(), | |
| apiKeyService.getAllApiKeys(), | |
| claudeAccountService.getAllAccounts(), | |
| claudeConsoleAccountService.getAllAccounts(), | |
| geminiAccountService.getAllAccounts(), | |
| bedrockAccountService.getAllAccounts(), | |
| redis.getAllOpenAIAccounts(), | |
| ccrAccountService.getAllAccounts(), | |
| openaiResponsesAccountService.getAllAccounts(true), | |
| droidAccountService.getAllAccounts(), | |
| redis.getTodayStats(), | |
| redis.getSystemAverages(), | |
| redis.getRealtimeSystemMetrics() | |
| ]) | |
| // 处理Bedrock账户数据 | |
| const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : [] | |
| const normalizeBoolean = (value) => value === true || value === 'true' | |
| const isRateLimitedFlag = (status) => { | |
| if (!status) { | |
| return false | |
| } | |
| if (typeof status === 'string') { | |
| return status === 'limited' | |
| } | |
| if (typeof status === 'object') { | |
| return status.isRateLimited === true | |
| } | |
| return false | |
| } | |
| const normalDroidAccounts = droidAccounts.filter( | |
| (acc) => | |
| normalizeBoolean(acc.isActive) && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' && | |
| normalizeBoolean(acc.schedulable) && | |
| !isRateLimitedFlag(acc.rateLimitStatus) | |
| ).length | |
| const abnormalDroidAccounts = droidAccounts.filter( | |
| (acc) => | |
| !normalizeBoolean(acc.isActive) || acc.status === 'blocked' || acc.status === 'unauthorized' | |
| ).length | |
| const pausedDroidAccounts = droidAccounts.filter( | |
| (acc) => | |
| !normalizeBoolean(acc.schedulable) && | |
| normalizeBoolean(acc.isActive) && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' | |
| ).length | |
| const rateLimitedDroidAccounts = droidAccounts.filter((acc) => | |
| isRateLimitedFlag(acc.rateLimitStatus) | |
| ).length | |
| // 计算使用统计(统一使用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 | |
| ) | |
| 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 | |
| // Claude账户统计 - 根据账户管理页面的判断逻辑 | |
| const normalClaudeAccounts = claudeAccounts.filter( | |
| (acc) => | |
| acc.isActive && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' && | |
| acc.schedulable !== false && | |
| !(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) | |
| ).length | |
| const abnormalClaudeAccounts = claudeAccounts.filter( | |
| (acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized' | |
| ).length | |
| const pausedClaudeAccounts = claudeAccounts.filter( | |
| (acc) => | |
| acc.schedulable === false && | |
| acc.isActive && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' | |
| ).length | |
| const rateLimitedClaudeAccounts = claudeAccounts.filter( | |
| (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited | |
| ).length | |
| // Claude Console账户统计 | |
| const normalClaudeConsoleAccounts = claudeConsoleAccounts.filter( | |
| (acc) => | |
| acc.isActive && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' && | |
| acc.schedulable !== false && | |
| !(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) | |
| ).length | |
| const abnormalClaudeConsoleAccounts = claudeConsoleAccounts.filter( | |
| (acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized' | |
| ).length | |
| const pausedClaudeConsoleAccounts = claudeConsoleAccounts.filter( | |
| (acc) => | |
| acc.schedulable === false && | |
| acc.isActive && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' | |
| ).length | |
| const rateLimitedClaudeConsoleAccounts = claudeConsoleAccounts.filter( | |
| (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited | |
| ).length | |
| // Gemini账户统计 | |
| const normalGeminiAccounts = geminiAccounts.filter( | |
| (acc) => | |
| acc.isActive && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' && | |
| acc.schedulable !== false && | |
| !( | |
| acc.rateLimitStatus === 'limited' || | |
| (acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) | |
| ) | |
| ).length | |
| const abnormalGeminiAccounts = geminiAccounts.filter( | |
| (acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized' | |
| ).length | |
| const pausedGeminiAccounts = geminiAccounts.filter( | |
| (acc) => | |
| acc.schedulable === false && | |
| acc.isActive && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' | |
| ).length | |
| const rateLimitedGeminiAccounts = geminiAccounts.filter( | |
| (acc) => | |
| acc.rateLimitStatus === 'limited' || | |
| (acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) | |
| ).length | |
| // Bedrock账户统计 | |
| const normalBedrockAccounts = bedrockAccounts.filter( | |
| (acc) => | |
| acc.isActive && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' && | |
| acc.schedulable !== false && | |
| !(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) | |
| ).length | |
| const abnormalBedrockAccounts = bedrockAccounts.filter( | |
| (acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized' | |
| ).length | |
| const pausedBedrockAccounts = bedrockAccounts.filter( | |
| (acc) => | |
| acc.schedulable === false && | |
| acc.isActive && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' | |
| ).length | |
| const rateLimitedBedrockAccounts = bedrockAccounts.filter( | |
| (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited | |
| ).length | |
| // OpenAI账户统计 | |
| // 注意:OpenAI账户的isActive和schedulable是字符串类型,默认值为'true' | |
| const normalOpenAIAccounts = openaiAccounts.filter( | |
| (acc) => | |
| (acc.isActive === 'true' || | |
| acc.isActive === true || | |
| (!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' && | |
| acc.schedulable !== 'false' && | |
| acc.schedulable !== false && // 包括'true'、true和undefined | |
| !(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) | |
| ).length | |
| const abnormalOpenAIAccounts = openaiAccounts.filter( | |
| (acc) => | |
| acc.isActive === 'false' || | |
| acc.isActive === false || | |
| acc.status === 'blocked' || | |
| acc.status === 'unauthorized' | |
| ).length | |
| const pausedOpenAIAccounts = openaiAccounts.filter( | |
| (acc) => | |
| (acc.schedulable === 'false' || acc.schedulable === false) && | |
| (acc.isActive === 'true' || | |
| acc.isActive === true || | |
| (!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' | |
| ).length | |
| const rateLimitedOpenAIAccounts = openaiAccounts.filter( | |
| (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited | |
| ).length | |
| // CCR账户统计 | |
| const normalCcrAccounts = ccrAccounts.filter( | |
| (acc) => | |
| acc.isActive && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' && | |
| acc.schedulable !== false && | |
| !(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) | |
| ).length | |
| const abnormalCcrAccounts = ccrAccounts.filter( | |
| (acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized' | |
| ).length | |
| const pausedCcrAccounts = ccrAccounts.filter( | |
| (acc) => | |
| acc.schedulable === false && | |
| acc.isActive && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' | |
| ).length | |
| const rateLimitedCcrAccounts = ccrAccounts.filter( | |
| (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited | |
| ).length | |
| // OpenAI-Responses账户统计 | |
| // 注意:OpenAI-Responses账户的isActive和schedulable也是字符串类型 | |
| const normalOpenAIResponsesAccounts = openaiResponsesAccounts.filter( | |
| (acc) => | |
| (acc.isActive === 'true' || | |
| acc.isActive === true || | |
| (!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' && | |
| acc.schedulable !== 'false' && | |
| acc.schedulable !== false && | |
| !(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) | |
| ).length | |
| const abnormalOpenAIResponsesAccounts = openaiResponsesAccounts.filter( | |
| (acc) => | |
| acc.isActive === 'false' || | |
| acc.isActive === false || | |
| acc.status === 'blocked' || | |
| acc.status === 'unauthorized' | |
| ).length | |
| const pausedOpenAIResponsesAccounts = openaiResponsesAccounts.filter( | |
| (acc) => | |
| (acc.schedulable === 'false' || acc.schedulable === false) && | |
| (acc.isActive === 'true' || | |
| acc.isActive === true || | |
| (!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) && | |
| acc.status !== 'blocked' && | |
| acc.status !== 'unauthorized' | |
| ).length | |
| const rateLimitedOpenAIResponsesAccounts = openaiResponsesAccounts.filter( | |
| (acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited | |
| ).length | |
| const dashboard = { | |
| overview: { | |
| totalApiKeys: apiKeys.length, | |
| activeApiKeys, | |
| // 总账户统计(所有平台) | |
| totalAccounts: | |
| claudeAccounts.length + | |
| claudeConsoleAccounts.length + | |
| geminiAccounts.length + | |
| bedrockAccounts.length + | |
| openaiAccounts.length + | |
| openaiResponsesAccounts.length + | |
| ccrAccounts.length, | |
| normalAccounts: | |
| normalClaudeAccounts + | |
| normalClaudeConsoleAccounts + | |
| normalGeminiAccounts + | |
| normalBedrockAccounts + | |
| normalOpenAIAccounts + | |
| normalOpenAIResponsesAccounts + | |
| normalCcrAccounts, | |
| abnormalAccounts: | |
| abnormalClaudeAccounts + | |
| abnormalClaudeConsoleAccounts + | |
| abnormalGeminiAccounts + | |
| abnormalBedrockAccounts + | |
| abnormalOpenAIAccounts + | |
| abnormalOpenAIResponsesAccounts + | |
| abnormalCcrAccounts + | |
| abnormalDroidAccounts, | |
| pausedAccounts: | |
| pausedClaudeAccounts + | |
| pausedClaudeConsoleAccounts + | |
| pausedGeminiAccounts + | |
| pausedBedrockAccounts + | |
| pausedOpenAIAccounts + | |
| pausedOpenAIResponsesAccounts + | |
| pausedCcrAccounts + | |
| pausedDroidAccounts, | |
| rateLimitedAccounts: | |
| rateLimitedClaudeAccounts + | |
| rateLimitedClaudeConsoleAccounts + | |
| rateLimitedGeminiAccounts + | |
| rateLimitedBedrockAccounts + | |
| rateLimitedOpenAIAccounts + | |
| rateLimitedOpenAIResponsesAccounts + | |
| rateLimitedCcrAccounts + | |
| rateLimitedDroidAccounts, | |
| // 各平台详细统计 | |
| accountsByPlatform: { | |
| claude: { | |
| total: claudeAccounts.length, | |
| normal: normalClaudeAccounts, | |
| abnormal: abnormalClaudeAccounts, | |
| paused: pausedClaudeAccounts, | |
| rateLimited: rateLimitedClaudeAccounts | |
| }, | |
| 'claude-console': { | |
| total: claudeConsoleAccounts.length, | |
| normal: normalClaudeConsoleAccounts, | |
| abnormal: abnormalClaudeConsoleAccounts, | |
| paused: pausedClaudeConsoleAccounts, | |
| rateLimited: rateLimitedClaudeConsoleAccounts | |
| }, | |
| gemini: { | |
| total: geminiAccounts.length, | |
| normal: normalGeminiAccounts, | |
| abnormal: abnormalGeminiAccounts, | |
| paused: pausedGeminiAccounts, | |
| rateLimited: rateLimitedGeminiAccounts | |
| }, | |
| bedrock: { | |
| total: bedrockAccounts.length, | |
| normal: normalBedrockAccounts, | |
| abnormal: abnormalBedrockAccounts, | |
| paused: pausedBedrockAccounts, | |
| rateLimited: rateLimitedBedrockAccounts | |
| }, | |
| openai: { | |
| total: openaiAccounts.length, | |
| normal: normalOpenAIAccounts, | |
| abnormal: abnormalOpenAIAccounts, | |
| paused: pausedOpenAIAccounts, | |
| rateLimited: rateLimitedOpenAIAccounts | |
| }, | |
| ccr: { | |
| total: ccrAccounts.length, | |
| normal: normalCcrAccounts, | |
| abnormal: abnormalCcrAccounts, | |
| paused: pausedCcrAccounts, | |
| rateLimited: rateLimitedCcrAccounts | |
| }, | |
| 'openai-responses': { | |
| total: openaiResponsesAccounts.length, | |
| normal: normalOpenAIResponsesAccounts, | |
| abnormal: abnormalOpenAIResponsesAccounts, | |
| paused: pausedOpenAIResponsesAccounts, | |
| rateLimited: rateLimitedOpenAIResponsesAccounts | |
| }, | |
| droid: { | |
| total: droidAccounts.length, | |
| normal: normalDroidAccounts, | |
| abnormal: abnormalDroidAccounts, | |
| paused: pausedDroidAccounts, | |
| rateLimited: rateLimitedDroidAccounts | |
| } | |
| }, | |
| // 保留旧字段以兼容 | |
| activeAccounts: | |
| normalClaudeAccounts + | |
| normalClaudeConsoleAccounts + | |
| normalGeminiAccounts + | |
| normalBedrockAccounts + | |
| normalOpenAIAccounts + | |
| normalOpenAIResponsesAccounts + | |
| normalCcrAccounts + | |
| normalDroidAccounts, | |
| totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length, | |
| activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts, | |
| rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts, | |
| totalGeminiAccounts: geminiAccounts.length, | |
| activeGeminiAccounts: normalGeminiAccounts, | |
| rateLimitedGeminiAccounts, | |
| 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 | |
| }, | |
| realtimeMetrics: { | |
| rpm: realtimeMetrics.realtimeRPM, | |
| tpm: realtimeMetrics.realtimeTPM, | |
| windowMinutes: realtimeMetrics.windowMinutes, | |
| isHistorical: realtimeMetrics.windowMinutes === 0 // 标识是否使用了历史数据 | |
| }, | |
| systemHealth: { | |
| redisConnected: redis.isConnected, | |
| claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0, | |
| geminiAccountsHealthy: normalGeminiAccounts > 0, | |
| droidAccountsHealthy: normalDroidAccounts > 0, | |
| uptime: process.uptime() | |
| }, | |
| systemTimezone: config.system.timezoneOffset || 8 | |
| } | |
| return res.json({ success: true, data: dashboard }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get dashboard data:', error) | |
| return 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 | |
| })) | |
| return res.json({ success: true, data: { period, stats } }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get usage stats:', error) | |
| return 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', startDate, endDate } = req.query // daily, monthly, 支持自定义时间范围 | |
| const today = redis.getDateStringInTimezone() | |
| const tzDate = redis.getDateInTimezone() | |
| const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( | |
| 2, | |
| '0' | |
| )}` | |
| logger.info( | |
| `📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}` | |
| ) | |
| const client = redis.getClientSafe() | |
| // 获取所有模型的统计数据 | |
| let searchPatterns = [] | |
| if (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' }) | |
| } | |
| // 限制最大范围为365天 | |
| const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 | |
| if (daysDiff > 365) { | |
| return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) | |
| } | |
| // 生成日期范围内所有日期的搜索模式 | |
| const currentDate = new Date(start) | |
| while (currentDate <= end) { | |
| const dateStr = redis.getDateStringInTimezone(currentDate) | |
| searchPatterns.push(`usage:model:daily:*:${dateStr}`) | |
| currentDate.setDate(currentDate.getDate() + 1) | |
| } | |
| logger.info(`📊 Generated ${searchPatterns.length} search patterns for date range`) | |
| } else { | |
| // 使用默认的period | |
| const pattern = | |
| period === 'daily' | |
| ? `usage:model:daily:*:${today}` | |
| : `usage:model:monthly:*:${currentMonth}` | |
| searchPatterns = [pattern] | |
| } | |
| logger.info('📊 Searching patterns:', searchPatterns) | |
| // 获取所有匹配的keys | |
| const allKeys = [] | |
| for (const pattern of searchPatterns) { | |
| const keys = await client.keys(pattern) | |
| allKeys.push(...keys) | |
| } | |
| logger.info(`📊 Found ${allKeys.length} matching keys in total`) | |
| // 模型名标准化函数(与redis.js保持一致) | |
| const normalizeModelName = (model) => { | |
| if (!model || model === 'unknown') { | |
| return model | |
| } | |
| // 对于Bedrock模型,去掉区域前缀进行统一 | |
| if (model.includes('.anthropic.') || model.includes('.claude')) { | |
| // 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name | |
| // 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等 | |
| let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用) | |
| normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀 | |
| normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等) | |
| return normalized | |
| } | |
| // 对于其他模型,去掉常见的版本后缀 | |
| return model.replace(/-v\d+:\d+$|:latest$/, '') | |
| } | |
| // 聚合相同模型的数据 | |
| const modelStatsMap = new Map() | |
| for (const key of allKeys) { | |
| const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) | |
| if (!match) { | |
| logger.warn(`📊 Pattern mismatch for key: ${key}`) | |
| continue | |
| } | |
| const rawModel = match[1] | |
| const normalizedModel = normalizeModelName(rawModel) | |
| const data = await client.hgetall(key) | |
| if (data && Object.keys(data).length > 0) { | |
| const stats = modelStatsMap.get(normalizedModel) || { | |
| requests: 0, | |
| inputTokens: 0, | |
| outputTokens: 0, | |
| cacheCreateTokens: 0, | |
| cacheReadTokens: 0, | |
| allTokens: 0 | |
| } | |
| 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 | |
| modelStatsMap.set(normalizedModel, stats) | |
| } | |
| } | |
| // 转换为数组并计算费用 | |
| const modelStats = [] | |
| 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 costData = CostCalculator.calculateCost(usage, model) | |
| modelStats.push({ | |
| model, | |
| period: startDate && endDate ? 'custom' : period, | |
| requests: stats.requests, | |
| inputTokens: usage.input_tokens, | |
| outputTokens: usage.output_tokens, | |
| cacheCreateTokens: usage.cache_creation_input_tokens, | |
| cacheReadTokens: usage.cache_read_input_tokens, | |
| allTokens: stats.allTokens, | |
| usage: { | |
| requests: stats.requests, | |
| 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 | |
| ) | |
| return res.json({ success: true, data: modelStats }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get model stats:', error) | |
| return 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` | |
| ) | |
| return res.json({ | |
| success: true, | |
| message: 'Cleanup completed', | |
| data: { | |
| expiredKeysRemoved: expiredKeys, | |
| errorAccountsReset: errorAccounts | |
| } | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Cleanup failed:', error) | |
| return 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) | |
| // 调试日志 | |
| logger.info('📊 Usage trend hour granularity - received times:') | |
| logger.info(` startDate (raw): ${startDate}`) | |
| logger.info(` endDate (raw): ${endDate}`) | |
| logger.info(` startTime (parsed): ${startTime.toISOString()}`) | |
| logger.info(` endTime (parsed): ${endTime.toISOString()}`) | |
| logger.info(` System timezone offset: ${config.system.timezoneOffset || 8}`) | |
| } 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) { | |
| // 注意:前端发送的时间已经是UTC时间,不需要再次转换 | |
| // 直接从currentHour生成对应系统时区的日期和小时 | |
| const tzCurrentHour = redis.getDateInTimezone(currentHour) | |
| const dateStr = redis.getDateStringInTimezone(currentHour) | |
| const hour = String(tzCurrentHour.getUTCHours()).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 | |
| } | |
| // 格式化时间标签 - 使用系统时区的显示 | |
| const tzDateForLabel = redis.getDateInTimezone(currentHour) | |
| const month = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0') | |
| const day = String(tzDateForLabel.getUTCDate()).padStart(2, '0') | |
| const hourStr = String(tzDateForLabel.getUTCHours()).padStart(2, '0') | |
| trendData.push({ | |
| // 对于小时粒度,只返回hour字段,不返回date字段 | |
| hour: currentHour.toISOString(), // 保留原始ISO时间用于排序 | |
| label: `${month}/${day} ${hourStr}:00`, // 添加格式化的标签 | |
| 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 = redis.getDateStringInTimezone(date) | |
| // 汇总当天所有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)) | |
| } | |
| return res.json({ success: true, data: trendData, granularity }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get usage trend:', error) | |
| return 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 = redis.getDateStringInTimezone() | |
| const tzDate = redis.getDateInTimezone() | |
| const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 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' }) | |
| } | |
| // 限制最大范围为365天 | |
| const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 | |
| if (daysDiff > 365) { | |
| return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) | |
| } | |
| // 生成日期范围内所有日期的搜索模式 | |
| for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { | |
| const dateStr = redis.getDateStringInTimezone(d) | |
| 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) | |
| return res.json({ success: true, data: modelStats }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get API key model stats:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to get API key model stats', message: error.message }) | |
| } | |
| }) | |
| // 获取按账号分组的使用趋势 | |
| router.get('/account-usage-trend', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query | |
| const allowedGroups = ['claude', 'openai', 'gemini', 'droid'] | |
| if (!allowedGroups.includes(group)) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: 'Invalid account group' | |
| }) | |
| } | |
| const groupLabels = { | |
| claude: 'Claude账户', | |
| openai: 'OpenAI账户', | |
| gemini: 'Gemini账户', | |
| droid: 'Droid账户' | |
| } | |
| // 拉取各平台账号列表 | |
| let accounts = [] | |
| if (group === 'claude') { | |
| const [claudeAccounts, claudeConsoleAccounts] = await Promise.all([ | |
| claudeAccountService.getAllAccounts(), | |
| claudeConsoleAccountService.getAllAccounts() | |
| ]) | |
| accounts = [ | |
| ...claudeAccounts.map((account) => { | |
| const id = String(account.id || '') | |
| const shortId = id ? id.slice(0, 8) : '未知' | |
| return { | |
| id, | |
| name: account.name || account.email || `Claude账号 ${shortId}`, | |
| platform: 'claude' | |
| } | |
| }), | |
| ...claudeConsoleAccounts.map((account) => { | |
| const id = String(account.id || '') | |
| const shortId = id ? id.slice(0, 8) : '未知' | |
| return { | |
| id, | |
| name: account.name || `Console账号 ${shortId}`, | |
| platform: 'claude-console' | |
| } | |
| }) | |
| ] | |
| } else if (group === 'openai') { | |
| const [openaiAccounts, openaiResponsesAccounts] = await Promise.all([ | |
| openaiAccountService.getAllAccounts(), | |
| openaiResponsesAccountService.getAllAccounts(true) | |
| ]) | |
| accounts = [ | |
| ...openaiAccounts.map((account) => { | |
| const id = String(account.id || '') | |
| const shortId = id ? id.slice(0, 8) : '未知' | |
| return { | |
| id, | |
| name: account.name || account.email || `OpenAI账号 ${shortId}`, | |
| platform: 'openai' | |
| } | |
| }), | |
| ...openaiResponsesAccounts.map((account) => { | |
| const id = String(account.id || '') | |
| const shortId = id ? id.slice(0, 8) : '未知' | |
| return { | |
| id, | |
| name: account.name || `Responses账号 ${shortId}`, | |
| platform: 'openai-responses' | |
| } | |
| }) | |
| ] | |
| } else if (group === 'gemini') { | |
| const geminiAccounts = await geminiAccountService.getAllAccounts() | |
| accounts = geminiAccounts.map((account) => { | |
| const id = String(account.id || '') | |
| const shortId = id ? id.slice(0, 8) : '未知' | |
| return { | |
| id, | |
| name: account.name || account.email || `Gemini账号 ${shortId}`, | |
| platform: 'gemini' | |
| } | |
| }) | |
| } else if (group === 'droid') { | |
| const droidAccounts = await droidAccountService.getAllAccounts() | |
| accounts = droidAccounts.map((account) => { | |
| const id = String(account.id || '') | |
| const shortId = id ? id.slice(0, 8) : '未知' | |
| return { | |
| id, | |
| name: account.name || account.ownerEmail || account.ownerName || `Droid账号 ${shortId}`, | |
| platform: 'droid' | |
| } | |
| }) | |
| } | |
| if (!accounts || accounts.length === 0) { | |
| return res.json({ | |
| success: true, | |
| data: [], | |
| granularity, | |
| group, | |
| groupLabel: groupLabels[group], | |
| topAccounts: [], | |
| totalAccounts: 0 | |
| }) | |
| } | |
| const accountMap = new Map() | |
| const accountIdSet = new Set() | |
| for (const account of accounts) { | |
| accountMap.set(account.id, { | |
| name: account.name, | |
| platform: account.platform | |
| }) | |
| accountIdSet.add(account.id) | |
| } | |
| const fallbackModelByGroup = { | |
| claude: 'claude-3-5-sonnet-20241022', | |
| openai: 'gpt-4o-mini-2024-07-18', | |
| gemini: 'gemini-1.5-flash' | |
| } | |
| const fallbackModel = fallbackModelByGroup[group] || 'unknown' | |
| const client = redis.getClientSafe() | |
| const trendData = [] | |
| const accountCostTotals = new Map() | |
| const sumModelCosts = async (accountId, period, timeKey) => { | |
| const modelPattern = `account_usage:model:${period}:${accountId}:*:${timeKey}` | |
| const modelKeys = await client.keys(modelPattern) | |
| let totalCost = 0 | |
| for (const modelKey of modelKeys) { | |
| const modelData = await client.hgetall(modelKey) | |
| if (!modelData) { | |
| continue | |
| } | |
| const parts = modelKey.split(':') | |
| if (parts.length < 5) { | |
| continue | |
| } | |
| const modelName = parts[4] | |
| const usage = { | |
| input_tokens: parseInt(modelData.inputTokens) || 0, | |
| output_tokens: parseInt(modelData.outputTokens) || 0, | |
| cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, | |
| cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 | |
| } | |
| const costResult = CostCalculator.calculateCost(usage, modelName) | |
| totalCost += costResult.costs.total | |
| } | |
| return totalCost | |
| } | |
| if (granularity === 'hour') { | |
| let startTime | |
| let endTime | |
| if (startDate && endDate) { | |
| startTime = new Date(startDate) | |
| endTime = new Date(endDate) | |
| } else { | |
| 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 tzCurrentHour = redis.getDateInTimezone(currentHour) | |
| const dateStr = redis.getDateStringInTimezone(currentHour) | |
| const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0') | |
| const hourKey = `${dateStr}:${hour}` | |
| const tzDateForLabel = redis.getDateInTimezone(currentHour) | |
| const monthLabel = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0') | |
| const dayLabel = String(tzDateForLabel.getUTCDate()).padStart(2, '0') | |
| const hourLabel = String(tzDateForLabel.getUTCHours()).padStart(2, '0') | |
| const hourData = { | |
| hour: currentHour.toISOString(), | |
| label: `${monthLabel}/${dayLabel} ${hourLabel}:00`, | |
| accounts: {} | |
| } | |
| const pattern = `account_usage:hourly:*:${hourKey}` | |
| const keys = await client.keys(pattern) | |
| for (const key of keys) { | |
| const match = key.match(/account_usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/) | |
| if (!match) { | |
| continue | |
| } | |
| const accountId = match[1] | |
| if (!accountIdSet.has(accountId)) { | |
| continue | |
| } | |
| const data = await client.hgetall(key) | |
| if (!data) { | |
| continue | |
| } | |
| const inputTokens = parseInt(data.inputTokens) || 0 | |
| const outputTokens = parseInt(data.outputTokens) || 0 | |
| const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 | |
| const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 | |
| const allTokens = | |
| parseInt(data.allTokens) || | |
| inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens | |
| const requests = parseInt(data.requests) || 0 | |
| let cost = await sumModelCosts(accountId, 'hourly', hourKey) | |
| if (cost === 0 && allTokens > 0) { | |
| const fallbackUsage = { | |
| input_tokens: inputTokens, | |
| output_tokens: outputTokens, | |
| cache_creation_input_tokens: cacheCreateTokens, | |
| cache_read_input_tokens: cacheReadTokens | |
| } | |
| const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel) | |
| cost = fallbackResult.costs.total | |
| } | |
| const formattedCost = CostCalculator.formatCost(cost) | |
| const accountInfo = accountMap.get(accountId) | |
| hourData.accounts[accountId] = { | |
| name: accountInfo ? accountInfo.name : `账号 ${accountId.slice(0, 8)}`, | |
| cost, | |
| formattedCost, | |
| requests | |
| } | |
| accountCostTotals.set(accountId, (accountCostTotals.get(accountId) || 0) + cost) | |
| } | |
| trendData.push(hourData) | |
| currentHour.setHours(currentHour.getHours() + 1) | |
| } | |
| } else { | |
| const daysCount = parseInt(days) || 7 | |
| const today = new Date() | |
| for (let i = 0; i < daysCount; i++) { | |
| const date = new Date(today) | |
| date.setDate(date.getDate() - i) | |
| const dateStr = redis.getDateStringInTimezone(date) | |
| const dayData = { | |
| date: dateStr, | |
| accounts: {} | |
| } | |
| const pattern = `account_usage:daily:*:${dateStr}` | |
| const keys = await client.keys(pattern) | |
| for (const key of keys) { | |
| const match = key.match(/account_usage:daily:(.+?):\d{4}-\d{2}-\d{2}/) | |
| if (!match) { | |
| continue | |
| } | |
| const accountId = match[1] | |
| if (!accountIdSet.has(accountId)) { | |
| continue | |
| } | |
| const data = await client.hgetall(key) | |
| if (!data) { | |
| continue | |
| } | |
| const inputTokens = parseInt(data.inputTokens) || 0 | |
| const outputTokens = parseInt(data.outputTokens) || 0 | |
| const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 | |
| const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 | |
| const allTokens = | |
| parseInt(data.allTokens) || | |
| inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens | |
| const requests = parseInt(data.requests) || 0 | |
| let cost = await sumModelCosts(accountId, 'daily', dateStr) | |
| if (cost === 0 && allTokens > 0) { | |
| const fallbackUsage = { | |
| input_tokens: inputTokens, | |
| output_tokens: outputTokens, | |
| cache_creation_input_tokens: cacheCreateTokens, | |
| cache_read_input_tokens: cacheReadTokens | |
| } | |
| const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel) | |
| cost = fallbackResult.costs.total | |
| } | |
| const formattedCost = CostCalculator.formatCost(cost) | |
| const accountInfo = accountMap.get(accountId) | |
| dayData.accounts[accountId] = { | |
| name: accountInfo ? accountInfo.name : `账号 ${accountId.slice(0, 8)}`, | |
| cost, | |
| formattedCost, | |
| requests | |
| } | |
| accountCostTotals.set(accountId, (accountCostTotals.get(accountId) || 0) + cost) | |
| } | |
| 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)) | |
| } | |
| const topAccounts = Array.from(accountCostTotals.entries()) | |
| .sort((a, b) => b[1] - a[1]) | |
| .slice(0, 20) | |
| .map(([accountId]) => accountId) | |
| return res.json({ | |
| success: true, | |
| data: trendData, | |
| granularity, | |
| group, | |
| groupLabel: groupLabels[group], | |
| topAccounts, | |
| totalAccounts: accountCostTotals.size | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get account usage trend:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to get account usage trend', 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 tzCurrentHour = redis.getDateInTimezone(currentHour) | |
| const dateStr = redis.getDateStringInTimezone(currentHour) | |
| const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0') | |
| const hourKey = `${dateStr}:${hour}` | |
| // 获取这个小时所有API Key的数据 | |
| const pattern = `usage:hourly:*:${hourKey}` | |
| const keys = await client.keys(pattern) | |
| // 格式化时间标签 | |
| const tzDateForLabel = redis.getDateInTimezone(currentHour) | |
| const monthLabel = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0') | |
| const dayLabel = String(tzDateForLabel.getUTCDate()).padStart(2, '0') | |
| const hourLabel = String(tzDateForLabel.getUTCHours()).padStart(2, '0') | |
| const hourData = { | |
| hour: currentHour.toISOString(), // 使用原始时间,不进行时区转换 | |
| label: `${monthLabel}/${dayLabel} ${hourLabel}:00`, // 添加格式化的标签 | |
| apiKeys: {} | |
| } | |
| // 先收集基础数据 | |
| const apiKeyDataMap = new Map() | |
| 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 inputTokens = parseInt(data.inputTokens) || 0 | |
| const outputTokens = parseInt(data.outputTokens) || 0 | |
| const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 | |
| const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 | |
| const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens | |
| apiKeyDataMap.set(apiKeyId, { | |
| name: apiKeyMap.get(apiKeyId).name, | |
| tokens: totalTokens, | |
| requests: parseInt(data.requests) || 0, | |
| inputTokens, | |
| outputTokens, | |
| cacheCreateTokens, | |
| cacheReadTokens | |
| }) | |
| } | |
| } | |
| // 获取该小时的模型级别数据来计算准确费用 | |
| const modelPattern = `usage:*:model:hourly:*:${hourKey}` | |
| const modelKeys = await client.keys(modelPattern) | |
| const apiKeyCostMap = new Map() | |
| for (const modelKey of modelKeys) { | |
| const match = modelKey.match(/usage:(.+?):model:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/) | |
| if (!match) { | |
| continue | |
| } | |
| const apiKeyId = match[1] | |
| const model = match[2] | |
| const modelData = await client.hgetall(modelKey) | |
| if (modelData && apiKeyDataMap.has(apiKeyId)) { | |
| const usage = { | |
| input_tokens: parseInt(modelData.inputTokens) || 0, | |
| output_tokens: parseInt(modelData.outputTokens) || 0, | |
| cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, | |
| cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 | |
| } | |
| const costResult = CostCalculator.calculateCost(usage, model) | |
| const currentCost = apiKeyCostMap.get(apiKeyId) || 0 | |
| apiKeyCostMap.set(apiKeyId, currentCost + costResult.costs.total) | |
| } | |
| } | |
| // 组合数据 | |
| for (const [apiKeyId, data] of apiKeyDataMap) { | |
| const cost = apiKeyCostMap.get(apiKeyId) || 0 | |
| // 如果没有模型级别数据,使用默认模型计算(降级方案) | |
| let finalCost = cost | |
| let formattedCost = CostCalculator.formatCost(cost) | |
| if (cost === 0 && data.tokens > 0) { | |
| const usage = { | |
| input_tokens: data.inputTokens, | |
| output_tokens: data.outputTokens, | |
| cache_creation_input_tokens: data.cacheCreateTokens, | |
| cache_read_input_tokens: data.cacheReadTokens | |
| } | |
| const fallbackResult = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022') | |
| finalCost = fallbackResult.costs.total | |
| formattedCost = fallbackResult.formatted.total | |
| } | |
| hourData.apiKeys[apiKeyId] = { | |
| name: data.name, | |
| tokens: data.tokens, | |
| requests: data.requests, | |
| cost: finalCost, | |
| formattedCost | |
| } | |
| } | |
| 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 = redis.getDateStringInTimezone(date) | |
| // 获取这一天所有API Key的数据 | |
| const pattern = `usage:daily:*:${dateStr}` | |
| const keys = await client.keys(pattern) | |
| const dayData = { | |
| date: dateStr, | |
| apiKeys: {} | |
| } | |
| // 先收集基础数据 | |
| const apiKeyDataMap = new Map() | |
| 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 inputTokens = parseInt(data.inputTokens) || 0 | |
| const outputTokens = parseInt(data.outputTokens) || 0 | |
| const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 | |
| const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 | |
| const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens | |
| apiKeyDataMap.set(apiKeyId, { | |
| name: apiKeyMap.get(apiKeyId).name, | |
| tokens: totalTokens, | |
| requests: parseInt(data.requests) || 0, | |
| inputTokens, | |
| outputTokens, | |
| cacheCreateTokens, | |
| cacheReadTokens | |
| }) | |
| } | |
| } | |
| // 获取该天的模型级别数据来计算准确费用 | |
| const modelPattern = `usage:*:model:daily:*:${dateStr}` | |
| const modelKeys = await client.keys(modelPattern) | |
| const apiKeyCostMap = new Map() | |
| for (const modelKey of modelKeys) { | |
| const match = modelKey.match(/usage:(.+?):model:daily:(.+?):\d{4}-\d{2}-\d{2}/) | |
| if (!match) { | |
| continue | |
| } | |
| const apiKeyId = match[1] | |
| const model = match[2] | |
| const modelData = await client.hgetall(modelKey) | |
| if (modelData && apiKeyDataMap.has(apiKeyId)) { | |
| const usage = { | |
| input_tokens: parseInt(modelData.inputTokens) || 0, | |
| output_tokens: parseInt(modelData.outputTokens) || 0, | |
| cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, | |
| cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 | |
| } | |
| const costResult = CostCalculator.calculateCost(usage, model) | |
| const currentCost = apiKeyCostMap.get(apiKeyId) || 0 | |
| apiKeyCostMap.set(apiKeyId, currentCost + costResult.costs.total) | |
| } | |
| } | |
| // 组合数据 | |
| for (const [apiKeyId, data] of apiKeyDataMap) { | |
| const cost = apiKeyCostMap.get(apiKeyId) || 0 | |
| // 如果没有模型级别数据,使用默认模型计算(降级方案) | |
| let finalCost = cost | |
| let formattedCost = CostCalculator.formatCost(cost) | |
| if (cost === 0 && data.tokens > 0) { | |
| const usage = { | |
| input_tokens: data.inputTokens, | |
| output_tokens: data.outputTokens, | |
| cache_creation_input_tokens: data.cacheCreateTokens, | |
| cache_read_input_tokens: data.cacheReadTokens | |
| } | |
| const fallbackResult = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022') | |
| finalCost = fallbackResult.costs.total | |
| formattedCost = fallbackResult.formatted.total | |
| } | |
| dayData.apiKeys[apiKeyId] = { | |
| name: data.name, | |
| tokens: data.tokens, | |
| requests: data.requests, | |
| cost: finalCost, | |
| formattedCost | |
| } | |
| } | |
| 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) | |
| return res.json({ | |
| success: true, | |
| data: trendData, | |
| granularity, | |
| topApiKeys, | |
| totalApiKeys: apiKeyTotals.size | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get API keys usage trend:', error) | |
| return 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, 7days | |
| logger.info(`💰 Calculating usage costs for period: ${period}`) | |
| // 模型名标准化函数(与redis.js保持一致) | |
| const normalizeModelName = (model) => { | |
| if (!model || model === 'unknown') { | |
| return model | |
| } | |
| // 对于Bedrock模型,去掉区域前缀进行统一 | |
| if (model.includes('.anthropic.') || model.includes('.claude')) { | |
| // 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name | |
| // 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等 | |
| let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用) | |
| normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀 | |
| normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等) | |
| return normalized | |
| } | |
| // 对于其他模型,去掉常见的版本后缀 | |
| return model.replace(/-v\d+:\d+$|:latest$/, '') | |
| } | |
| // 获取所有API Keys的使用统计 | |
| const apiKeys = await apiKeyService.getAllApiKeys() | |
| const totalCosts = { | |
| inputCost: 0, | |
| outputCost: 0, | |
| cacheCreateCost: 0, | |
| cacheReadCost: 0, | |
| totalCost: 0 | |
| } | |
| const modelCosts = {} | |
| // 按模型统计费用 | |
| const client = redis.getClientSafe() | |
| const today = redis.getDateStringInTimezone() | |
| const tzDate = redis.getDateInTimezone() | |
| const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( | |
| 2, | |
| '0' | |
| )}` | |
| let pattern | |
| if (period === 'today') { | |
| pattern = `usage:model:daily:*:${today}` | |
| } else if (period === 'monthly') { | |
| pattern = `usage:model:monthly:*:${currentMonth}` | |
| } else if (period === '7days') { | |
| // 最近7天:汇总daily数据 | |
| const modelUsageMap = new Map() | |
| // 获取最近7天的所有daily统计数据 | |
| for (let i = 0; i < 7; i++) { | |
| const date = new Date() | |
| date.setDate(date.getDate() - i) | |
| const currentTzDate = redis.getDateInTimezone(date) | |
| const dateStr = `${currentTzDate.getUTCFullYear()}-${String( | |
| currentTzDate.getUTCMonth() + 1 | |
| ).padStart(2, '0')}-${String(currentTzDate.getUTCDate()).padStart(2, '0')}` | |
| const dayPattern = `usage:model:daily:*:${dateStr}` | |
| const dayKeys = await client.keys(dayPattern) | |
| for (const key of dayKeys) { | |
| const modelMatch = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) | |
| if (!modelMatch) { | |
| continue | |
| } | |
| const rawModel = modelMatch[1] | |
| const normalizedModel = normalizeModelName(rawModel) | |
| const data = await client.hgetall(key) | |
| if (data && Object.keys(data).length > 0) { | |
| if (!modelUsageMap.has(normalizedModel)) { | |
| modelUsageMap.set(normalizedModel, { | |
| inputTokens: 0, | |
| outputTokens: 0, | |
| cacheCreateTokens: 0, | |
| cacheReadTokens: 0 | |
| }) | |
| } | |
| const modelUsage = modelUsageMap.get(normalizedModel) | |
| modelUsage.inputTokens += parseInt(data.inputTokens) || 0 | |
| modelUsage.outputTokens += parseInt(data.outputTokens) || 0 | |
| modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 | |
| modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 | |
| } | |
| } | |
| } | |
| // 计算7天统计的费用 | |
| logger.info(`💰 Processing ${modelUsageMap.size} unique models for 7days 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} (7days): ${ | |
| usage.inputTokens + usage.outputTokens + usage.cacheCreateTokens + usage.cacheReadTokens | |
| } tokens, cost: ${costResult.formatted.total}` | |
| ) | |
| // 记录模型费用 | |
| modelCosts[model] = { | |
| model, | |
| requests: 0, // 7天汇总数据没有请求数统计 | |
| usage: usageData, | |
| costs: costResult.costs, | |
| formatted: costResult.formatted, | |
| usingDynamicPricing: costResult.usingDynamicPricing | |
| } | |
| } | |
| // 返回7天统计结果 | |
| return 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) | |
| } | |
| }) | |
| } else { | |
| // 全部时间,先尝试从Redis获取所有历史模型统计数据(只使用monthly数据避免重复计算) | |
| const allModelKeys = await client.keys('usage:model:monthly:*:*') | |
| logger.info(`💰 Total period calculation: found ${allModelKeys.length} monthly model keys`) | |
| if (allModelKeys.length > 0) { | |
| // 如果有详细的模型统计数据,使用模型级别的计算 | |
| const modelUsageMap = new Map() | |
| for (const key of allModelKeys) { | |
| // 解析模型名称(只处理monthly数据) | |
| 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 | |
| } | |
| } | |
| // 使用模型级别的数据计算费用 | |
| 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 | |
| } | |
| } | |
| } | |
| return 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() | |
| } | |
| }) | |
| } | |
| // 对于今日或本月,从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 | |
| } | |
| } | |
| } | |
| return 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) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to calculate usage costs', message: error.message }) | |
| } | |
| }) | |
| // 📋 获取所有账号的 Claude Code headers 信息 | |
| router.get('/claude-code-headers', authenticateAdmin, async (req, res) => { | |
| try { | |
| const allHeaders = await claudeCodeHeadersService.getAllAccountHeaders() | |
| // 获取所有 Claude 账号信息 | |
| const accounts = await claudeAccountService.getAllAccounts() | |
| const accountMap = {} | |
| accounts.forEach((account) => { | |
| accountMap[account.id] = account.name | |
| }) | |
| // 格式化输出 | |
| const formattedData = Object.entries(allHeaders).map(([accountId, data]) => ({ | |
| accountId, | |
| accountName: accountMap[accountId] || 'Unknown', | |
| version: data.version, | |
| userAgent: data.headers['user-agent'], | |
| updatedAt: data.updatedAt, | |
| headers: data.headers | |
| })) | |
| return res.json({ | |
| success: true, | |
| data: formattedData | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get Claude Code headers:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to get Claude Code headers', message: error.message }) | |
| } | |
| }) | |
| // 🗑️ 清除指定账号的 Claude Code headers | |
| router.delete('/claude-code-headers/:accountId', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| await claudeCodeHeadersService.clearAccountHeaders(accountId) | |
| return res.json({ | |
| success: true, | |
| message: `Claude Code headers cleared for account ${accountId}` | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to clear Claude Code headers:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to clear Claude Code headers', message: error.message }) | |
| } | |
| }) | |
| // 🔄 版本检查 | |
| router.get('/check-updates', authenticateAdmin, async (req, res) => { | |
| // 读取当前版本 | |
| const versionPath = path.join(__dirname, '../../VERSION') | |
| let currentVersion = '1.0.0' | |
| try { | |
| currentVersion = fs.readFileSync(versionPath, 'utf8').trim() | |
| } catch (err) { | |
| logger.warn('⚠️ Could not read VERSION file:', err.message) | |
| } | |
| try { | |
| // 从缓存获取 | |
| const cacheKey = 'version_check_cache' | |
| const cached = await redis.getClient().get(cacheKey) | |
| if (cached && !req.query.force) { | |
| const cachedData = JSON.parse(cached) | |
| const cacheAge = Date.now() - cachedData.timestamp | |
| // 缓存有效期1小时 | |
| if (cacheAge < 3600000) { | |
| // 实时计算 hasUpdate,不使用缓存的值 | |
| const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0 | |
| return res.json({ | |
| success: true, | |
| data: { | |
| current: currentVersion, | |
| latest: cachedData.latest, | |
| hasUpdate, // 实时计算,不用缓存 | |
| releaseInfo: cachedData.releaseInfo, | |
| cached: true | |
| } | |
| }) | |
| } | |
| } | |
| // 请求 GitHub API | |
| const githubRepo = 'wei-shaw/claude-relay-service' | |
| const response = await axios.get(`https://api.github.com/repos/${githubRepo}/releases/latest`, { | |
| headers: { | |
| Accept: 'application/vnd.github.v3+json', | |
| 'User-Agent': 'Claude-Relay-Service' | |
| }, | |
| timeout: 10000 | |
| }) | |
| const release = response.data | |
| const latestVersion = release.tag_name.replace(/^v/, '') | |
| // 比较版本 | |
| const hasUpdate = compareVersions(currentVersion, latestVersion) < 0 | |
| const releaseInfo = { | |
| name: release.name, | |
| body: release.body, | |
| publishedAt: release.published_at, | |
| htmlUrl: release.html_url | |
| } | |
| // 缓存结果(不缓存 hasUpdate,因为它应该实时计算) | |
| await redis.getClient().set( | |
| cacheKey, | |
| JSON.stringify({ | |
| latest: latestVersion, | |
| releaseInfo, | |
| timestamp: Date.now() | |
| }), | |
| 'EX', | |
| 3600 | |
| ) // 1小时过期 | |
| return res.json({ | |
| success: true, | |
| data: { | |
| current: currentVersion, | |
| latest: latestVersion, | |
| hasUpdate, | |
| releaseInfo, | |
| cached: false | |
| } | |
| }) | |
| } catch (error) { | |
| // 改进错误日志记录 | |
| const errorDetails = { | |
| message: error.message || 'Unknown error', | |
| code: error.code, | |
| response: error.response | |
| ? { | |
| status: error.response.status, | |
| statusText: error.response.statusText, | |
| data: error.response.data | |
| } | |
| : null, | |
| request: error.request ? 'Request was made but no response received' : null | |
| } | |
| logger.error('❌ Failed to check for updates:', errorDetails.message) | |
| // 处理 404 错误 - 仓库或版本不存在 | |
| if (error.response && error.response.status === 404) { | |
| return res.json({ | |
| success: true, | |
| data: { | |
| current: currentVersion, | |
| latest: currentVersion, | |
| hasUpdate: false, | |
| releaseInfo: { | |
| name: 'No releases found', | |
| body: 'The GitHub repository has no releases yet.', | |
| publishedAt: new Date().toISOString(), | |
| htmlUrl: '#' | |
| }, | |
| warning: 'GitHub repository has no releases' | |
| } | |
| }) | |
| } | |
| // 如果是网络错误,尝试返回缓存的数据 | |
| if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') { | |
| const cacheKey = 'version_check_cache' | |
| const cached = await redis.getClient().get(cacheKey) | |
| if (cached) { | |
| const cachedData = JSON.parse(cached) | |
| // 实时计算 hasUpdate | |
| const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0 | |
| return res.json({ | |
| success: true, | |
| data: { | |
| current: currentVersion, | |
| latest: cachedData.latest, | |
| hasUpdate, // 实时计算 | |
| releaseInfo: cachedData.releaseInfo, | |
| cached: true, | |
| warning: 'Using cached data due to network error' | |
| } | |
| }) | |
| } | |
| } | |
| // 其他错误返回当前版本信息 | |
| return res.json({ | |
| success: true, | |
| data: { | |
| current: currentVersion, | |
| latest: currentVersion, | |
| hasUpdate: false, | |
| releaseInfo: { | |
| name: 'Update check failed', | |
| body: `Unable to check for updates: ${error.message || 'Unknown error'}`, | |
| publishedAt: new Date().toISOString(), | |
| htmlUrl: '#' | |
| }, | |
| error: true, | |
| warning: error.message || 'Failed to check for updates' | |
| } | |
| }) | |
| } | |
| }) | |
| // 版本比较函数 | |
| function compareVersions(current, latest) { | |
| const parseVersion = (v) => { | |
| const parts = v.split('.').map(Number) | |
| return { | |
| major: parts[0] || 0, | |
| minor: parts[1] || 0, | |
| patch: parts[2] || 0 | |
| } | |
| } | |
| const currentV = parseVersion(current) | |
| const latestV = parseVersion(latest) | |
| if (currentV.major !== latestV.major) { | |
| return currentV.major - latestV.major | |
| } | |
| if (currentV.minor !== latestV.minor) { | |
| return currentV.minor - latestV.minor | |
| } | |
| 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编码的图标数据 | |
| showAdminButton: true, // 是否显示管理后台按钮 | |
| 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) | |
| } | |
| } | |
| // 添加 LDAP 启用状态到响应中 | |
| return res.json({ | |
| success: true, | |
| data: { | |
| ...settings, | |
| ldapEnabled: config.ldap && config.ldap.enabled === true | |
| } | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to get OEM settings:', error) | |
| return 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, showAdminButton } = 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数据 | |
| showAdminButton: showAdminButton !== false, // 默认为true | |
| updatedAt: new Date().toISOString() | |
| } | |
| const client = redis.getClient() | |
| await client.set('oem:settings', JSON.stringify(settings)) | |
| logger.info(`✅ OEM settings updated: ${siteName}`) | |
| return res.json({ | |
| success: true, | |
| message: 'OEM settings updated successfully', | |
| data: settings | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Failed to update OEM settings:', error) | |
| return res.status(500).json({ error: 'Failed to update OEM settings', message: error.message }) | |
| } | |
| }) | |
| // 🤖 OpenAI 账户管理 | |
| // OpenAI OAuth 配置 | |
| const OPENAI_CONFIG = { | |
| BASE_URL: 'https://auth.openai.com', | |
| CLIENT_ID: 'app_EMoamEEZ73f0CkXaXp7hrann', | |
| REDIRECT_URI: 'http://localhost:1455/auth/callback', | |
| SCOPE: 'openid profile email offline_access' | |
| } | |
| // 生成 PKCE 参数 | |
| function generateOpenAIPKCE() { | |
| const codeVerifier = crypto.randomBytes(64).toString('hex') | |
| const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url') | |
| return { | |
| codeVerifier, | |
| codeChallenge | |
| } | |
| } | |
| // 生成 OpenAI OAuth 授权 URL | |
| router.post('/openai-accounts/generate-auth-url', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { proxy } = req.body | |
| // 生成 PKCE 参数 | |
| const pkce = generateOpenAIPKCE() | |
| // 生成随机 state | |
| const state = crypto.randomBytes(32).toString('hex') | |
| // 创建会话 ID | |
| const sessionId = crypto.randomUUID() | |
| // 将 PKCE 参数和代理配置存储到 Redis | |
| await redis.setOAuthSession(sessionId, { | |
| codeVerifier: pkce.codeVerifier, | |
| codeChallenge: pkce.codeChallenge, | |
| state, | |
| proxy: proxy || null, | |
| platform: 'openai', | |
| createdAt: new Date().toISOString(), | |
| expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() | |
| }) | |
| // 构建授权 URL 参数 | |
| const params = new URLSearchParams({ | |
| response_type: 'code', | |
| client_id: OPENAI_CONFIG.CLIENT_ID, | |
| redirect_uri: OPENAI_CONFIG.REDIRECT_URI, | |
| scope: OPENAI_CONFIG.SCOPE, | |
| code_challenge: pkce.codeChallenge, | |
| code_challenge_method: 'S256', | |
| state, | |
| id_token_add_organizations: 'true', | |
| codex_cli_simplified_flow: 'true' | |
| }) | |
| const authUrl = `${OPENAI_CONFIG.BASE_URL}/oauth/authorize?${params.toString()}` | |
| logger.success('🔗 Generated OpenAI OAuth authorization URL') | |
| return res.json({ | |
| success: true, | |
| data: { | |
| authUrl, | |
| sessionId, | |
| instructions: [ | |
| '1. 复制上面的链接到浏览器中打开', | |
| '2. 登录您的 OpenAI 账户', | |
| '3. 同意应用权限', | |
| '4. 复制浏览器地址栏中的完整 URL(包含 code 参数)', | |
| '5. 在添加账户表单中粘贴完整的回调 URL' | |
| ] | |
| } | |
| }) | |
| } catch (error) { | |
| logger.error('生成 OpenAI OAuth URL 失败:', error) | |
| return res.status(500).json({ | |
| success: false, | |
| message: '生成授权链接失败', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 交换 OpenAI 授权码 | |
| router.post('/openai-accounts/exchange-code', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { code, sessionId } = req.body | |
| if (!code || !sessionId) { | |
| return res.status(400).json({ | |
| success: false, | |
| message: '缺少必要参数' | |
| }) | |
| } | |
| // 从 Redis 获取会话数据 | |
| const sessionData = await redis.getOAuthSession(sessionId) | |
| if (!sessionData) { | |
| return res.status(400).json({ | |
| success: false, | |
| message: '会话已过期或无效' | |
| }) | |
| } | |
| // 准备 token 交换请求 | |
| const tokenData = { | |
| grant_type: 'authorization_code', | |
| code: code.trim(), | |
| redirect_uri: OPENAI_CONFIG.REDIRECT_URI, | |
| client_id: OPENAI_CONFIG.CLIENT_ID, | |
| code_verifier: sessionData.codeVerifier | |
| } | |
| logger.info('Exchanging OpenAI authorization code:', { | |
| sessionId, | |
| codeLength: code.length, | |
| hasCodeVerifier: !!sessionData.codeVerifier | |
| }) | |
| // 配置代理(如果有) | |
| const axiosConfig = { | |
| headers: { | |
| 'Content-Type': 'application/x-www-form-urlencoded' | |
| } | |
| } | |
| // 配置代理(如果有) | |
| const proxyAgent = ProxyHelper.createProxyAgent(sessionData.proxy) | |
| if (proxyAgent) { | |
| axiosConfig.httpAgent = proxyAgent | |
| axiosConfig.httpsAgent = proxyAgent | |
| axiosConfig.proxy = false | |
| } | |
| // 交换 authorization code 获取 tokens | |
| const tokenResponse = await axios.post( | |
| `${OPENAI_CONFIG.BASE_URL}/oauth/token`, | |
| new URLSearchParams(tokenData).toString(), | |
| axiosConfig | |
| ) | |
| const { id_token, access_token, refresh_token, expires_in } = tokenResponse.data | |
| // 解析 ID token 获取用户信息 | |
| const idTokenParts = id_token.split('.') | |
| if (idTokenParts.length !== 3) { | |
| throw new Error('Invalid ID token format') | |
| } | |
| // 解码 JWT payload | |
| const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64url').toString()) | |
| // 获取 OpenAI 特定的声明 | |
| const authClaims = payload['https://api.openai.com/auth'] || {} | |
| const accountId = authClaims.chatgpt_account_id || '' | |
| const chatgptUserId = authClaims.chatgpt_user_id || authClaims.user_id || '' | |
| const planType = authClaims.chatgpt_plan_type || '' | |
| // 获取组织信息 | |
| const organizations = authClaims.organizations || [] | |
| const defaultOrg = organizations.find((org) => org.is_default) || organizations[0] || {} | |
| const organizationId = defaultOrg.id || '' | |
| const organizationRole = defaultOrg.role || '' | |
| const organizationTitle = defaultOrg.title || '' | |
| // 清理 Redis 会话 | |
| await redis.deleteOAuthSession(sessionId) | |
| logger.success('✅ OpenAI OAuth token exchange successful') | |
| return res.json({ | |
| success: true, | |
| data: { | |
| tokens: { | |
| idToken: id_token, | |
| accessToken: access_token, | |
| refreshToken: refresh_token, | |
| expires_in | |
| }, | |
| accountInfo: { | |
| accountId, | |
| chatgptUserId, | |
| organizationId, | |
| organizationRole, | |
| organizationTitle, | |
| planType, | |
| email: payload.email || '', | |
| name: payload.name || '', | |
| emailVerified: payload.email_verified || false, | |
| organizations | |
| } | |
| } | |
| }) | |
| } catch (error) { | |
| logger.error('OpenAI OAuth token exchange failed:', error) | |
| return res.status(500).json({ | |
| success: false, | |
| message: '交换授权码失败', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 获取所有 OpenAI 账户 | |
| router.get('/openai-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { platform, groupId } = req.query | |
| let accounts = await openaiAccountService.getAllAccounts() | |
| // 缓存账户所属分组,避免重复查询 | |
| const accountGroupCache = new Map() | |
| const fetchAccountGroups = async (accountId) => { | |
| if (!accountGroupCache.has(accountId)) { | |
| const groups = await accountGroupService.getAccountGroups(accountId) | |
| accountGroupCache.set(accountId, groups || []) | |
| } | |
| return accountGroupCache.get(accountId) | |
| } | |
| // 根据查询参数进行筛选 | |
| if (platform && platform !== 'all' && platform !== 'openai') { | |
| // 如果指定了其他平台,返回空数组 | |
| accounts = [] | |
| } | |
| // 如果指定了分组筛选 | |
| if (groupId && groupId !== 'all') { | |
| if (groupId === 'ungrouped') { | |
| // 筛选未分组账户 | |
| const filteredAccounts = [] | |
| for (const account of accounts) { | |
| const groups = await fetchAccountGroups(account.id) | |
| if (!groups || groups.length === 0) { | |
| filteredAccounts.push(account) | |
| } | |
| } | |
| accounts = filteredAccounts | |
| } else { | |
| // 筛选特定分组的账户 | |
| const groupMembers = await accountGroupService.getGroupMembers(groupId) | |
| accounts = accounts.filter((account) => groupMembers.includes(account.id)) | |
| } | |
| } | |
| // 为每个账户添加使用统计信息 | |
| const accountsWithStats = await Promise.all( | |
| accounts.map(async (account) => { | |
| try { | |
| const usageStats = await redis.getAccountUsageStats(account.id, 'openai') | |
| const groupInfos = await fetchAccountGroups(account.id) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| groupInfos, | |
| usage: { | |
| daily: usageStats.daily, | |
| total: usageStats.total, | |
| monthly: usageStats.monthly | |
| } | |
| } | |
| } catch (error) { | |
| logger.debug(`Failed to get usage stats for OpenAI account ${account.id}:`, error) | |
| const groupInfos = await fetchAccountGroups(account.id) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| groupInfos, | |
| usage: { | |
| daily: { requests: 0, tokens: 0, allTokens: 0 }, | |
| total: { requests: 0, tokens: 0, allTokens: 0 }, | |
| monthly: { requests: 0, tokens: 0, allTokens: 0 } | |
| } | |
| } | |
| } | |
| }) | |
| ) | |
| logger.info(`获取 OpenAI 账户列表: ${accountsWithStats.length} 个账户`) | |
| return res.json({ | |
| success: true, | |
| data: accountsWithStats | |
| }) | |
| } catch (error) { | |
| logger.error('获取 OpenAI 账户列表失败:', error) | |
| return res.status(500).json({ | |
| success: false, | |
| message: '获取账户列表失败', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 创建 OpenAI 账户 | |
| router.post('/openai-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { | |
| name, | |
| description, | |
| openaiOauth, | |
| accountInfo, | |
| proxy, | |
| accountType, | |
| groupId, | |
| rateLimitDuration, | |
| priority, | |
| needsImmediateRefresh, // 是否需要立即刷新 | |
| requireRefreshSuccess // 是否必须刷新成功才能创建 | |
| } = req.body | |
| if (!name) { | |
| return res.status(400).json({ | |
| success: false, | |
| message: '账户名称不能为空' | |
| }) | |
| } | |
| // 准备账户数据 | |
| const accountData = { | |
| name, | |
| description: description || '', | |
| accountType: accountType || 'shared', | |
| priority: priority || 50, | |
| rateLimitDuration: | |
| rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60, | |
| openaiOauth: openaiOauth || {}, | |
| accountInfo: accountInfo || {}, | |
| proxy: proxy || null, | |
| isActive: true, | |
| schedulable: true | |
| } | |
| // 如果需要立即刷新且必须成功(OpenAI 手动模式) | |
| if (needsImmediateRefresh && requireRefreshSuccess) { | |
| // 先创建临时账户以测试刷新 | |
| const tempAccount = await openaiAccountService.createAccount(accountData) | |
| try { | |
| logger.info(`🔄 测试刷新 OpenAI 账户以获取完整 token 信息`) | |
| // 尝试刷新 token(会自动使用账户配置的代理) | |
| await openaiAccountService.refreshAccountToken(tempAccount.id) | |
| // 刷新成功,获取更新后的账户信息 | |
| const refreshedAccount = await openaiAccountService.getAccount(tempAccount.id) | |
| // 检查是否获取到了 ID Token | |
| if (!refreshedAccount.idToken || refreshedAccount.idToken === '') { | |
| // 没有获取到 ID Token,删除账户 | |
| await openaiAccountService.deleteAccount(tempAccount.id) | |
| throw new Error('无法获取 ID Token,请检查 Refresh Token 是否有效') | |
| } | |
| // 如果是分组类型,添加到分组 | |
| if (accountType === 'group' && groupId) { | |
| await accountGroupService.addAccountToGroup(tempAccount.id, groupId, 'openai') | |
| } | |
| // 清除敏感信息后返回 | |
| delete refreshedAccount.idToken | |
| delete refreshedAccount.accessToken | |
| delete refreshedAccount.refreshToken | |
| logger.success(`✅ 创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`) | |
| return res.json({ | |
| success: true, | |
| data: refreshedAccount, | |
| message: '账户创建成功,并已获取完整 token 信息' | |
| }) | |
| } catch (refreshError) { | |
| // 刷新失败,删除临时创建的账户 | |
| logger.warn(`❌ 刷新失败,删除临时账户: ${refreshError.message}`) | |
| await openaiAccountService.deleteAccount(tempAccount.id) | |
| // 构建详细的错误信息 | |
| const errorResponse = { | |
| success: false, | |
| message: '账户创建失败', | |
| error: refreshError.message | |
| } | |
| // 添加更详细的错误信息 | |
| if (refreshError.status) { | |
| errorResponse.errorCode = refreshError.status | |
| } | |
| if (refreshError.details) { | |
| errorResponse.errorDetails = refreshError.details | |
| } | |
| if (refreshError.code) { | |
| errorResponse.networkError = refreshError.code | |
| } | |
| // 提供更友好的错误提示 | |
| if (refreshError.message.includes('Refresh Token 无效')) { | |
| errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取' | |
| } else if (refreshError.message.includes('代理')) { | |
| errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息' | |
| } else if (refreshError.message.includes('过于频繁')) { | |
| errorResponse.suggestion = '请稍后再试,或更换代理 IP' | |
| } else if (refreshError.message.includes('连接')) { | |
| errorResponse.suggestion = '请检查网络连接和代理设置' | |
| } | |
| return res.status(400).json(errorResponse) | |
| } | |
| } | |
| // 不需要强制刷新的情况(OAuth 模式或其他平台) | |
| const createdAccount = await openaiAccountService.createAccount(accountData) | |
| // 如果是分组类型,添加到分组 | |
| if (accountType === 'group' && groupId) { | |
| await accountGroupService.addAccountToGroup(createdAccount.id, groupId, 'openai') | |
| } | |
| // 如果需要刷新但不强制成功(OAuth 模式可能已有完整信息) | |
| if (needsImmediateRefresh && !requireRefreshSuccess) { | |
| try { | |
| logger.info(`🔄 尝试刷新 OpenAI 账户 ${createdAccount.id}`) | |
| await openaiAccountService.refreshAccountToken(createdAccount.id) | |
| logger.info(`✅ 刷新成功`) | |
| } catch (refreshError) { | |
| logger.warn(`⚠️ 刷新失败,但账户已创建: ${refreshError.message}`) | |
| } | |
| } | |
| logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`) | |
| return res.json({ | |
| success: true, | |
| data: createdAccount | |
| }) | |
| } catch (error) { | |
| logger.error('创建 OpenAI 账户失败:', error) | |
| return res.status(500).json({ | |
| success: false, | |
| message: '创建账户失败', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 更新 OpenAI 账户 | |
| router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| const updates = req.body | |
| // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt | |
| const mappedUpdates = mapExpiryField(updates, 'OpenAI', id) | |
| const { needsImmediateRefresh, requireRefreshSuccess } = mappedUpdates | |
| // 验证accountType的有效性 | |
| if ( | |
| mappedUpdates.accountType && | |
| !['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType) | |
| ) { | |
| return res | |
| .status(400) | |
| .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) | |
| } | |
| // 如果更新为分组类型,验证groupId | |
| if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) { | |
| return res.status(400).json({ error: 'Group ID is required for group type accounts' }) | |
| } | |
| // 获取账户当前信息以处理分组变更 | |
| const currentAccount = await openaiAccountService.getAccount(id) | |
| if (!currentAccount) { | |
| return res.status(404).json({ error: 'Account not found' }) | |
| } | |
| // 如果更新了 Refresh Token,需要验证其有效性 | |
| if (mappedUpdates.openaiOauth?.refreshToken && needsImmediateRefresh && requireRefreshSuccess) { | |
| // 先更新 token 信息 | |
| const tempUpdateData = {} | |
| if (mappedUpdates.openaiOauth.refreshToken) { | |
| tempUpdateData.refreshToken = mappedUpdates.openaiOauth.refreshToken | |
| } | |
| if (mappedUpdates.openaiOauth.accessToken) { | |
| tempUpdateData.accessToken = mappedUpdates.openaiOauth.accessToken | |
| } | |
| // 更新代理配置(如果有) | |
| if (mappedUpdates.proxy !== undefined) { | |
| tempUpdateData.proxy = mappedUpdates.proxy | |
| } | |
| // 临时更新账户以测试新的 token | |
| await openaiAccountService.updateAccount(id, tempUpdateData) | |
| try { | |
| logger.info(`🔄 验证更新的 OpenAI token (账户: ${id})`) | |
| // 尝试刷新 token(会使用账户配置的代理) | |
| await openaiAccountService.refreshAccountToken(id) | |
| // 获取刷新后的账户信息 | |
| const refreshedAccount = await openaiAccountService.getAccount(id) | |
| // 检查是否获取到了 ID Token | |
| if (!refreshedAccount.idToken || refreshedAccount.idToken === '') { | |
| // 恢复原始 token | |
| await openaiAccountService.updateAccount(id, { | |
| refreshToken: currentAccount.refreshToken, | |
| accessToken: currentAccount.accessToken, | |
| idToken: currentAccount.idToken | |
| }) | |
| return res.status(400).json({ | |
| success: false, | |
| message: '无法获取 ID Token,请检查 Refresh Token 是否有效', | |
| error: 'Invalid refresh token' | |
| }) | |
| } | |
| logger.success(`✅ Token 验证成功,继续更新账户信息`) | |
| } catch (refreshError) { | |
| // 刷新失败,恢复原始 token | |
| logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`) | |
| await openaiAccountService.updateAccount(id, { | |
| refreshToken: currentAccount.refreshToken, | |
| accessToken: currentAccount.accessToken, | |
| idToken: currentAccount.idToken, | |
| proxy: currentAccount.proxy | |
| }) | |
| // 构建详细的错误信息 | |
| const errorResponse = { | |
| success: false, | |
| message: '更新失败', | |
| error: refreshError.message | |
| } | |
| // 添加更详细的错误信息 | |
| if (refreshError.status) { | |
| errorResponse.errorCode = refreshError.status | |
| } | |
| if (refreshError.details) { | |
| errorResponse.errorDetails = refreshError.details | |
| } | |
| if (refreshError.code) { | |
| errorResponse.networkError = refreshError.code | |
| } | |
| // 提供更友好的错误提示 | |
| if (refreshError.message.includes('Refresh Token 无效')) { | |
| errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取' | |
| } else if (refreshError.message.includes('代理')) { | |
| errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息' | |
| } else if (refreshError.message.includes('过于频繁')) { | |
| errorResponse.suggestion = '请稍后再试,或更换代理 IP' | |
| } else if (refreshError.message.includes('连接')) { | |
| errorResponse.suggestion = '请检查网络连接和代理设置' | |
| } | |
| return res.status(400).json(errorResponse) | |
| } | |
| } | |
| // 处理分组的变更 | |
| if (mappedUpdates.accountType !== undefined) { | |
| // 如果之前是分组类型,需要从原分组中移除 | |
| if (currentAccount.accountType === 'group') { | |
| const oldGroup = await accountGroupService.getAccountGroup(id) | |
| if (oldGroup) { | |
| await accountGroupService.removeAccountFromGroup(id, oldGroup.id) | |
| } | |
| } | |
| // 如果新类型是分组,添加到新分组 | |
| if (mappedUpdates.accountType === 'group' && mappedUpdates.groupId) { | |
| await accountGroupService.addAccountToGroup(id, mappedUpdates.groupId, 'openai') | |
| } | |
| } | |
| // 准备更新数据 | |
| const updateData = { ...mappedUpdates } | |
| // 处理敏感数据加密 | |
| if (mappedUpdates.openaiOauth) { | |
| updateData.openaiOauth = mappedUpdates.openaiOauth | |
| // 编辑时不允许直接输入 ID Token,只能通过刷新获取 | |
| if (mappedUpdates.openaiOauth.accessToken) { | |
| updateData.accessToken = mappedUpdates.openaiOauth.accessToken | |
| } | |
| if (mappedUpdates.openaiOauth.refreshToken) { | |
| updateData.refreshToken = mappedUpdates.openaiOauth.refreshToken | |
| } | |
| if (mappedUpdates.openaiOauth.expires_in) { | |
| updateData.expiresAt = new Date( | |
| Date.now() + mappedUpdates.openaiOauth.expires_in * 1000 | |
| ).toISOString() | |
| } | |
| } | |
| // 更新账户信息 | |
| if (mappedUpdates.accountInfo) { | |
| updateData.accountId = mappedUpdates.accountInfo.accountId || currentAccount.accountId | |
| updateData.chatgptUserId = | |
| mappedUpdates.accountInfo.chatgptUserId || currentAccount.chatgptUserId | |
| updateData.organizationId = | |
| mappedUpdates.accountInfo.organizationId || currentAccount.organizationId | |
| updateData.organizationRole = | |
| mappedUpdates.accountInfo.organizationRole || currentAccount.organizationRole | |
| updateData.organizationTitle = | |
| mappedUpdates.accountInfo.organizationTitle || currentAccount.organizationTitle | |
| updateData.planType = mappedUpdates.accountInfo.planType || currentAccount.planType | |
| updateData.email = mappedUpdates.accountInfo.email || currentAccount.email | |
| updateData.emailVerified = | |
| mappedUpdates.accountInfo.emailVerified !== undefined | |
| ? mappedUpdates.accountInfo.emailVerified | |
| : currentAccount.emailVerified | |
| } | |
| const updatedAccount = await openaiAccountService.updateAccount(id, updateData) | |
| // 如果需要刷新但不强制成功(非关键更新) | |
| if (needsImmediateRefresh && !requireRefreshSuccess) { | |
| try { | |
| logger.info(`🔄 尝试刷新 OpenAI 账户 ${id}`) | |
| await openaiAccountService.refreshAccountToken(id) | |
| logger.info(`✅ 刷新成功`) | |
| } catch (refreshError) { | |
| logger.warn(`⚠️ 刷新失败,但账户信息已更新: ${refreshError.message}`) | |
| } | |
| } | |
| logger.success(`📝 Admin updated OpenAI account: ${id}`) | |
| return res.json({ success: true, data: updatedAccount }) | |
| } catch (error) { | |
| logger.error('❌ Failed to update OpenAI account:', error) | |
| return res.status(500).json({ error: 'Failed to update account', message: error.message }) | |
| } | |
| }) | |
| // 删除 OpenAI 账户 | |
| router.delete('/openai-accounts/:id', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| const account = await openaiAccountService.getAccount(id) | |
| if (!account) { | |
| return res.status(404).json({ | |
| success: false, | |
| message: '账户不存在' | |
| }) | |
| } | |
| // 自动解绑所有绑定的 API Keys | |
| const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai') | |
| // 如果账户在分组中,从分组中移除 | |
| if (account.accountType === 'group') { | |
| const group = await accountGroupService.getAccountGroup(id) | |
| if (group) { | |
| await accountGroupService.removeAccountFromGroup(id, group.id) | |
| } | |
| } | |
| await openaiAccountService.deleteAccount(id) | |
| let message = 'OpenAI账号已成功删除' | |
| if (unboundCount > 0) { | |
| message += `,${unboundCount} 个 API Key 已切换为共享池模式` | |
| } | |
| logger.success( | |
| `✅ 删除 OpenAI 账户成功: ${account.name} (ID: ${id}), unbound ${unboundCount} keys` | |
| ) | |
| return res.json({ | |
| success: true, | |
| message, | |
| unboundKeys: unboundCount | |
| }) | |
| } catch (error) { | |
| logger.error('删除 OpenAI 账户失败:', error) | |
| return res.status(500).json({ | |
| success: false, | |
| message: '删除账户失败', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 切换 OpenAI 账户状态 | |
| router.put('/openai-accounts/:id/toggle', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| const account = await redis.getOpenAiAccount(id) | |
| if (!account) { | |
| return res.status(404).json({ | |
| success: false, | |
| message: '账户不存在' | |
| }) | |
| } | |
| // 切换启用状态 | |
| account.enabled = !account.enabled | |
| account.updatedAt = new Date().toISOString() | |
| // TODO: 更新方法 | |
| // await redis.updateOpenAiAccount(id, account) | |
| logger.success( | |
| `✅ ${account.enabled ? '启用' : '禁用'} OpenAI 账户: ${account.name} (ID: ${id})` | |
| ) | |
| return res.json({ | |
| success: true, | |
| data: account | |
| }) | |
| } catch (error) { | |
| logger.error('切换 OpenAI 账户状态失败:', error) | |
| return res.status(500).json({ | |
| success: false, | |
| message: '切换账户状态失败', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 重置 OpenAI 账户状态(清除所有异常状态) | |
| router.post('/openai-accounts/:accountId/reset-status', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const result = await openaiAccountService.resetAccountStatus(accountId) | |
| logger.success(`✅ Admin reset status for OpenAI account: ${accountId}`) | |
| return res.json({ success: true, data: result }) | |
| } catch (error) { | |
| logger.error('❌ Failed to reset OpenAI account status:', error) | |
| return res.status(500).json({ error: 'Failed to reset status', message: error.message }) | |
| } | |
| }) | |
| // 切换 OpenAI 账户调度状态 | |
| router.put( | |
| '/openai-accounts/:accountId/toggle-schedulable', | |
| authenticateAdmin, | |
| async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const result = await openaiAccountService.toggleSchedulable(accountId) | |
| // 如果账号被禁用,发送webhook通知 | |
| if (!result.schedulable) { | |
| // 获取账号信息 | |
| const account = await redis.getOpenAiAccount(accountId) | |
| if (account) { | |
| await webhookNotifier.sendAccountAnomalyNotification({ | |
| accountId: account.id, | |
| accountName: account.name || 'OpenAI Account', | |
| platform: 'openai', | |
| status: 'disabled', | |
| errorCode: 'OPENAI_MANUALLY_DISABLED', | |
| reason: '账号已被管理员手动禁用调度', | |
| timestamp: new Date().toISOString() | |
| }) | |
| } | |
| } | |
| return res.json({ | |
| success: result.success, | |
| schedulable: result.schedulable, | |
| message: result.schedulable ? '已启用调度' : '已禁用调度' | |
| }) | |
| } catch (error) { | |
| logger.error('切换 OpenAI 账户调度状态失败:', error) | |
| return res.status(500).json({ | |
| success: false, | |
| message: '切换调度状态失败', | |
| error: error.message | |
| }) | |
| } | |
| } | |
| ) | |
| // 🌐 Azure OpenAI 账户管理 | |
| // 获取所有 Azure OpenAI 账户 | |
| router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { platform, groupId } = req.query | |
| let accounts = await azureOpenaiAccountService.getAllAccounts() | |
| // 根据查询参数进行筛选 | |
| if (platform && platform !== 'all' && platform !== 'azure_openai') { | |
| // 如果指定了其他平台,返回空数组 | |
| accounts = [] | |
| } | |
| // 如果指定了分组筛选 | |
| if (groupId && groupId !== 'all') { | |
| if (groupId === 'ungrouped') { | |
| // 筛选未分组账户 | |
| const filteredAccounts = [] | |
| for (const account of accounts) { | |
| const groups = await accountGroupService.getAccountGroups(account.id) | |
| if (!groups || groups.length === 0) { | |
| filteredAccounts.push(account) | |
| } | |
| } | |
| accounts = filteredAccounts | |
| } else { | |
| // 筛选特定分组的账户 | |
| const groupMembers = await accountGroupService.getGroupMembers(groupId) | |
| accounts = accounts.filter((account) => groupMembers.includes(account.id)) | |
| } | |
| } | |
| // 为每个账户添加使用统计信息和分组信息 | |
| const accountsWithStats = await Promise.all( | |
| accounts.map(async (account) => { | |
| try { | |
| const usageStats = await redis.getAccountUsageStats(account.id, 'openai') | |
| const groupInfos = await accountGroupService.getAccountGroups(account.id) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| groupInfos, | |
| usage: { | |
| daily: usageStats.daily, | |
| total: usageStats.total, | |
| averages: usageStats.averages | |
| } | |
| } | |
| } catch (error) { | |
| logger.debug(`Failed to get usage stats for Azure OpenAI account ${account.id}:`, error) | |
| try { | |
| const groupInfos = await accountGroupService.getAccountGroups(account.id) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| groupInfos, | |
| usage: { | |
| daily: { requests: 0, tokens: 0, allTokens: 0 }, | |
| total: { requests: 0, tokens: 0, allTokens: 0 }, | |
| averages: { rpm: 0, tpm: 0 } | |
| } | |
| } | |
| } catch (groupError) { | |
| logger.debug(`Failed to get group info for account ${account.id}:`, groupError) | |
| return { | |
| ...account, | |
| groupInfos: [], | |
| usage: { | |
| daily: { requests: 0, tokens: 0, allTokens: 0 }, | |
| total: { requests: 0, tokens: 0, allTokens: 0 }, | |
| averages: { rpm: 0, tpm: 0 } | |
| } | |
| } | |
| } | |
| } | |
| }) | |
| ) | |
| res.json({ | |
| success: true, | |
| data: accountsWithStats | |
| }) | |
| } catch (error) { | |
| logger.error('Failed to fetch Azure OpenAI accounts:', error) | |
| res.status(500).json({ | |
| success: false, | |
| message: 'Failed to fetch accounts', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 创建 Azure OpenAI 账户 | |
| router.post('/azure-openai-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { | |
| name, | |
| description, | |
| accountType, | |
| azureEndpoint, | |
| apiVersion, | |
| deploymentName, | |
| apiKey, | |
| supportedModels, | |
| proxy, | |
| groupId, | |
| groupIds, | |
| priority, | |
| isActive, | |
| schedulable | |
| } = req.body | |
| // 验证必填字段 | |
| if (!name) { | |
| return res.status(400).json({ | |
| success: false, | |
| message: 'Account name is required' | |
| }) | |
| } | |
| if (!azureEndpoint) { | |
| return res.status(400).json({ | |
| success: false, | |
| message: 'Azure endpoint is required' | |
| }) | |
| } | |
| if (!apiKey) { | |
| return res.status(400).json({ | |
| success: false, | |
| message: 'API key is required' | |
| }) | |
| } | |
| if (!deploymentName) { | |
| return res.status(400).json({ | |
| success: false, | |
| message: 'Deployment name is required' | |
| }) | |
| } | |
| // 验证 Azure endpoint 格式 | |
| if (!azureEndpoint.match(/^https:\/\/[\w-]+\.openai\.azure\.com$/)) { | |
| return res.status(400).json({ | |
| success: false, | |
| message: | |
| 'Invalid Azure OpenAI endpoint format. Expected: https://your-resource.openai.azure.com' | |
| }) | |
| } | |
| // 测试连接 | |
| try { | |
| const testUrl = `${azureEndpoint}/openai/deployments/${deploymentName}?api-version=${ | |
| apiVersion || '2024-02-01' | |
| }` | |
| await axios.get(testUrl, { | |
| headers: { | |
| 'api-key': apiKey | |
| }, | |
| timeout: 5000 | |
| }) | |
| } catch (testError) { | |
| if (testError.response?.status === 404) { | |
| logger.warn('Azure OpenAI deployment not found, but continuing with account creation') | |
| } else if (testError.response?.status === 401) { | |
| return res.status(400).json({ | |
| success: false, | |
| message: 'Invalid API key or unauthorized access' | |
| }) | |
| } | |
| } | |
| const account = await azureOpenaiAccountService.createAccount({ | |
| name, | |
| description, | |
| accountType: accountType || 'shared', | |
| azureEndpoint, | |
| apiVersion: apiVersion || '2024-02-01', | |
| deploymentName, | |
| apiKey, | |
| supportedModels, | |
| proxy, | |
| groupId, | |
| priority: priority || 50, | |
| isActive: isActive !== false, | |
| schedulable: schedulable !== false | |
| }) | |
| // 如果是分组类型,将账户添加到分组 | |
| if (accountType === 'group') { | |
| if (groupIds && groupIds.length > 0) { | |
| // 使用多分组设置 | |
| await accountGroupService.setAccountGroups(account.id, groupIds, 'azure_openai') | |
| } else if (groupId) { | |
| // 兼容单分组模式 | |
| await accountGroupService.addAccountToGroup(account.id, groupId, 'azure_openai') | |
| } | |
| } | |
| res.json({ | |
| success: true, | |
| data: account, | |
| message: 'Azure OpenAI account created successfully' | |
| }) | |
| } catch (error) { | |
| logger.error('Failed to create Azure OpenAI account:', error) | |
| res.status(500).json({ | |
| success: false, | |
| message: 'Failed to create account', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 更新 Azure OpenAI 账户 | |
| router.put('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| const updates = req.body | |
| // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt | |
| const mappedUpdates = mapExpiryField(updates, 'Azure OpenAI', id) | |
| const account = await azureOpenaiAccountService.updateAccount(id, mappedUpdates) | |
| res.json({ | |
| success: true, | |
| data: account, | |
| message: 'Azure OpenAI account updated successfully' | |
| }) | |
| } catch (error) { | |
| logger.error('Failed to update Azure OpenAI account:', error) | |
| res.status(500).json({ | |
| success: false, | |
| message: 'Failed to update account', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 删除 Azure OpenAI 账户 | |
| router.delete('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| // 自动解绑所有绑定的 API Keys | |
| const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'azure_openai') | |
| await azureOpenaiAccountService.deleteAccount(id) | |
| let message = 'Azure OpenAI账号已成功删除' | |
| if (unboundCount > 0) { | |
| message += `,${unboundCount} 个 API Key 已切换为共享池模式` | |
| } | |
| logger.success(`🗑️ Admin deleted Azure OpenAI account: ${id}, unbound ${unboundCount} keys`) | |
| res.json({ | |
| success: true, | |
| message, | |
| unboundKeys: unboundCount | |
| }) | |
| } catch (error) { | |
| logger.error('Failed to delete Azure OpenAI account:', error) | |
| res.status(500).json({ | |
| success: false, | |
| message: 'Failed to delete account', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 切换 Azure OpenAI 账户状态 | |
| router.put('/azure-openai-accounts/:id/toggle', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| const account = await azureOpenaiAccountService.getAccount(id) | |
| if (!account) { | |
| return res.status(404).json({ | |
| success: false, | |
| message: 'Account not found' | |
| }) | |
| } | |
| const newStatus = account.isActive === 'true' ? 'false' : 'true' | |
| await azureOpenaiAccountService.updateAccount(id, { isActive: newStatus }) | |
| res.json({ | |
| success: true, | |
| message: `Account ${newStatus === 'true' ? 'activated' : 'deactivated'} successfully`, | |
| isActive: newStatus === 'true' | |
| }) | |
| } catch (error) { | |
| logger.error('Failed to toggle Azure OpenAI account status:', error) | |
| res.status(500).json({ | |
| success: false, | |
| message: 'Failed to toggle account status', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 切换 Azure OpenAI 账户调度状态 | |
| router.put( | |
| '/azure-openai-accounts/:accountId/toggle-schedulable', | |
| authenticateAdmin, | |
| async (req, res) => { | |
| try { | |
| const { accountId } = req.params | |
| const result = await azureOpenaiAccountService.toggleSchedulable(accountId) | |
| // 如果账号被禁用,发送webhook通知 | |
| if (!result.schedulable) { | |
| // 获取账号信息 | |
| const account = await azureOpenaiAccountService.getAccount(accountId) | |
| if (account) { | |
| await webhookNotifier.sendAccountAnomalyNotification({ | |
| accountId: account.id, | |
| accountName: account.name || 'Azure OpenAI Account', | |
| platform: 'azure-openai', | |
| status: 'disabled', | |
| errorCode: 'AZURE_OPENAI_MANUALLY_DISABLED', | |
| reason: '账号已被管理员手动禁用调度', | |
| timestamp: new Date().toISOString() | |
| }) | |
| } | |
| } | |
| return res.json({ | |
| success: true, | |
| schedulable: result.schedulable, | |
| message: result.schedulable ? '已启用调度' : '已禁用调度' | |
| }) | |
| } catch (error) { | |
| logger.error('切换 Azure OpenAI 账户调度状态失败:', error) | |
| return res.status(500).json({ | |
| success: false, | |
| message: '切换调度状态失败', | |
| error: error.message | |
| }) | |
| } | |
| } | |
| ) | |
| // 健康检查单个 Azure OpenAI 账户 | |
| router.post('/azure-openai-accounts/:id/health-check', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| const healthResult = await azureOpenaiAccountService.healthCheckAccount(id) | |
| res.json({ | |
| success: true, | |
| data: healthResult | |
| }) | |
| } catch (error) { | |
| logger.error('Failed to perform health check:', error) | |
| res.status(500).json({ | |
| success: false, | |
| message: 'Failed to perform health check', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 批量健康检查所有 Azure OpenAI 账户 | |
| router.post('/azure-openai-accounts/health-check-all', authenticateAdmin, async (req, res) => { | |
| try { | |
| const healthResults = await azureOpenaiAccountService.performHealthChecks() | |
| res.json({ | |
| success: true, | |
| data: healthResults | |
| }) | |
| } catch (error) { | |
| logger.error('Failed to perform batch health check:', error) | |
| res.status(500).json({ | |
| success: false, | |
| message: 'Failed to perform batch health check', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 迁移 API Keys 以支持 Azure OpenAI | |
| router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => { | |
| try { | |
| const migratedCount = await azureOpenaiAccountService.migrateApiKeysForAzureSupport() | |
| res.json({ | |
| success: true, | |
| message: `Successfully migrated ${migratedCount} API keys for Azure OpenAI support` | |
| }) | |
| } catch (error) { | |
| logger.error('Failed to migrate API keys:', error) | |
| res.status(500).json({ | |
| success: false, | |
| message: 'Failed to migrate API keys', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 📋 获取统一Claude Code User-Agent信息 | |
| router.get('/claude-code-version', authenticateAdmin, async (req, res) => { | |
| try { | |
| const CACHE_KEY = 'claude_code_user_agent:daily' | |
| // 获取缓存的统一User-Agent | |
| const unifiedUserAgent = await redis.client.get(CACHE_KEY) | |
| const ttl = unifiedUserAgent ? await redis.client.ttl(CACHE_KEY) : 0 | |
| res.json({ | |
| success: true, | |
| userAgent: unifiedUserAgent, | |
| isActive: !!unifiedUserAgent, | |
| ttlSeconds: ttl, | |
| lastUpdated: unifiedUserAgent ? new Date().toISOString() : null | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Get unified Claude Code User-Agent error:', error) | |
| res.status(500).json({ | |
| success: false, | |
| message: 'Failed to get User-Agent information', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 🗑️ 清除统一Claude Code User-Agent缓存 | |
| router.post('/claude-code-version/clear', authenticateAdmin, async (req, res) => { | |
| try { | |
| const CACHE_KEY = 'claude_code_user_agent:daily' | |
| // 删除缓存的统一User-Agent | |
| await redis.client.del(CACHE_KEY) | |
| logger.info(`🗑️ Admin manually cleared unified Claude Code User-Agent cache`) | |
| res.json({ | |
| success: true, | |
| message: 'Unified User-Agent cache cleared successfully' | |
| }) | |
| } catch (error) { | |
| logger.error('❌ Clear unified User-Agent cache error:', error) | |
| res.status(500).json({ | |
| success: false, | |
| message: 'Failed to clear cache', | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // ==================== OpenAI-Responses 账户管理 API ==================== | |
| // 获取所有 OpenAI-Responses 账户 | |
| router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { platform, groupId } = req.query | |
| let accounts = await openaiResponsesAccountService.getAllAccounts(true) | |
| // 根据查询参数进行筛选 | |
| if (platform && platform !== 'openai-responses') { | |
| accounts = [] | |
| } | |
| // 根据分组ID筛选 | |
| if (groupId) { | |
| const group = await accountGroupService.getGroup(groupId) | |
| if (group && group.platform === 'openai' && group.memberIds && group.memberIds.length > 0) { | |
| accounts = accounts.filter((account) => group.memberIds.includes(account.id)) | |
| } else { | |
| accounts = [] | |
| } | |
| } | |
| // 处理额度信息、使用统计和绑定的 API Key 数量 | |
| const accountsWithStats = await Promise.all( | |
| accounts.map(async (account) => { | |
| try { | |
| // 检查是否需要重置额度 | |
| const today = redis.getDateStringInTimezone() | |
| if (account.lastResetDate !== today) { | |
| // 今天还没重置过,需要重置 | |
| await openaiResponsesAccountService.updateAccount(account.id, { | |
| dailyUsage: '0', | |
| lastResetDate: today, | |
| quotaStoppedAt: '' | |
| }) | |
| account.dailyUsage = '0' | |
| account.lastResetDate = today | |
| account.quotaStoppedAt = '' | |
| } | |
| // 检查并清除过期的限流状态 | |
| await openaiResponsesAccountService.checkAndClearRateLimit(account.id) | |
| // 获取使用统计信息 | |
| let usageStats | |
| try { | |
| usageStats = await redis.getAccountUsageStats(account.id, 'openai-responses') | |
| } catch (error) { | |
| logger.debug( | |
| `Failed to get usage stats for OpenAI-Responses account ${account.id}:`, | |
| error | |
| ) | |
| usageStats = { | |
| daily: { requests: 0, tokens: 0, allTokens: 0 }, | |
| total: { requests: 0, tokens: 0, allTokens: 0 }, | |
| monthly: { requests: 0, tokens: 0, allTokens: 0 } | |
| } | |
| } | |
| // 计算绑定的API Key数量(支持 responses: 前缀) | |
| const allKeys = await redis.getAllApiKeys() | |
| let boundCount = 0 | |
| for (const key of allKeys) { | |
| // 检查是否绑定了该账户(包括 responses: 前缀) | |
| if ( | |
| key.openaiAccountId === account.id || | |
| key.openaiAccountId === `responses:${account.id}` | |
| ) { | |
| boundCount++ | |
| } | |
| } | |
| // 调试日志:检查绑定计数 | |
| if (boundCount > 0) { | |
| logger.info(`OpenAI-Responses account ${account.id} has ${boundCount} bound API keys`) | |
| } | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| boundApiKeysCount: boundCount, | |
| usage: { | |
| daily: usageStats.daily, | |
| total: usageStats.total, | |
| monthly: usageStats.monthly | |
| } | |
| } | |
| } catch (error) { | |
| logger.error(`Failed to process OpenAI-Responses account ${account.id}:`, error) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| boundApiKeysCount: 0, | |
| usage: { | |
| daily: { requests: 0, tokens: 0, allTokens: 0 }, | |
| total: { requests: 0, tokens: 0, allTokens: 0 }, | |
| monthly: { requests: 0, tokens: 0, allTokens: 0 } | |
| } | |
| } | |
| } | |
| }) | |
| ) | |
| res.json({ success: true, data: accountsWithStats }) | |
| } catch (error) { | |
| logger.error('Failed to get OpenAI-Responses accounts:', error) | |
| res.status(500).json({ success: false, message: error.message }) | |
| } | |
| }) | |
| // 创建 OpenAI-Responses 账户 | |
| router.post('/openai-responses-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const account = await openaiResponsesAccountService.createAccount(req.body) | |
| const formattedAccount = formatAccountExpiry(account) | |
| res.json({ success: true, data: formattedAccount }) | |
| } catch (error) { | |
| logger.error('Failed to create OpenAI-Responses account:', error) | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 更新 OpenAI-Responses 账户 | |
| router.put('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| const updates = req.body | |
| // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt | |
| const mappedUpdates = mapExpiryField(updates, 'OpenAI-Responses', id) | |
| // 验证priority的有效性(1-100) | |
| if (mappedUpdates.priority !== undefined) { | |
| const priority = parseInt(mappedUpdates.priority) | |
| if (isNaN(priority) || priority < 1 || priority > 100) { | |
| return res.status(400).json({ | |
| success: false, | |
| message: 'Priority must be a number between 1 and 100' | |
| }) | |
| } | |
| mappedUpdates.priority = priority.toString() | |
| } | |
| const result = await openaiResponsesAccountService.updateAccount(id, mappedUpdates) | |
| if (!result.success) { | |
| return res.status(400).json(result) | |
| } | |
| res.json({ success: true, ...result }) | |
| } catch (error) { | |
| logger.error('Failed to update OpenAI-Responses account:', error) | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 删除 OpenAI-Responses 账户 | |
| router.delete('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| const account = await openaiResponsesAccountService.getAccount(id) | |
| if (!account) { | |
| return res.status(404).json({ | |
| success: false, | |
| message: 'Account not found' | |
| }) | |
| } | |
| // 自动解绑所有绑定的 API Keys | |
| const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai-responses') | |
| // 检查是否在分组中 | |
| const groups = await accountGroupService.getAllGroups() | |
| for (const group of groups) { | |
| if (group.platform === 'openai' && group.memberIds && group.memberIds.includes(id)) { | |
| await accountGroupService.removeMemberFromGroup(group.id, id) | |
| logger.info(`Removed OpenAI-Responses account ${id} from group ${group.id}`) | |
| } | |
| } | |
| const result = await openaiResponsesAccountService.deleteAccount(id) | |
| let message = 'OpenAI-Responses账号已成功删除' | |
| if (unboundCount > 0) { | |
| message += `,${unboundCount} 个 API Key 已切换为共享池模式` | |
| } | |
| logger.success(`🗑️ Admin deleted OpenAI-Responses account: ${id}, unbound ${unboundCount} keys`) | |
| res.json({ | |
| success: true, | |
| ...result, | |
| message, | |
| unboundKeys: unboundCount | |
| }) | |
| } catch (error) { | |
| logger.error('Failed to delete OpenAI-Responses account:', error) | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 切换 OpenAI-Responses 账户调度状态 | |
| router.put( | |
| '/openai-responses-accounts/:id/toggle-schedulable', | |
| authenticateAdmin, | |
| async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| const result = await openaiResponsesAccountService.toggleSchedulable(id) | |
| if (!result.success) { | |
| return res.status(400).json(result) | |
| } | |
| // 仅在停止调度时发送通知 | |
| if (!result.schedulable) { | |
| await webhookNotifier.sendAccountEvent('account.status_changed', { | |
| accountId: id, | |
| platform: 'openai-responses', | |
| schedulable: result.schedulable, | |
| changedBy: 'admin', | |
| action: 'stopped_scheduling' | |
| }) | |
| } | |
| res.json(result) | |
| } catch (error) { | |
| logger.error('Failed to toggle OpenAI-Responses account schedulable status:', error) | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }) | |
| } | |
| } | |
| ) | |
| // 切换 OpenAI-Responses 账户激活状态 | |
| router.put('/openai-responses-accounts/:id/toggle', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| const account = await openaiResponsesAccountService.getAccount(id) | |
| if (!account) { | |
| return res.status(404).json({ | |
| success: false, | |
| message: 'Account not found' | |
| }) | |
| } | |
| const newActiveStatus = account.isActive === 'true' ? 'false' : 'true' | |
| await openaiResponsesAccountService.updateAccount(id, { | |
| isActive: newActiveStatus | |
| }) | |
| res.json({ | |
| success: true, | |
| isActive: newActiveStatus === 'true' | |
| }) | |
| } catch (error) { | |
| logger.error('Failed to toggle OpenAI-Responses account status:', error) | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 重置 OpenAI-Responses 账户限流状态 | |
| router.post( | |
| '/openai-responses-accounts/:id/reset-rate-limit', | |
| authenticateAdmin, | |
| async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| await openaiResponsesAccountService.updateAccount(id, { | |
| rateLimitedAt: '', | |
| rateLimitStatus: '', | |
| status: 'active', | |
| errorMessage: '' | |
| }) | |
| logger.info(`🔄 Admin manually reset rate limit for OpenAI-Responses account ${id}`) | |
| res.json({ | |
| success: true, | |
| message: 'Rate limit reset successfully' | |
| }) | |
| } catch (error) { | |
| logger.error('Failed to reset OpenAI-Responses account rate limit:', error) | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }) | |
| } | |
| } | |
| ) | |
| // 重置 OpenAI-Responses 账户状态(清除所有异常状态) | |
| router.post('/openai-responses-accounts/:id/reset-status', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| const result = await openaiResponsesAccountService.resetAccountStatus(id) | |
| logger.success(`✅ Admin reset status for OpenAI-Responses account: ${id}`) | |
| return res.json({ success: true, data: result }) | |
| } catch (error) { | |
| logger.error('❌ Failed to reset OpenAI-Responses account status:', error) | |
| return res.status(500).json({ error: 'Failed to reset status', message: error.message }) | |
| } | |
| }) | |
| // 手动重置 OpenAI-Responses 账户的每日使用量 | |
| router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| await openaiResponsesAccountService.updateAccount(id, { | |
| dailyUsage: '0', | |
| lastResetDate: redis.getDateStringInTimezone(), | |
| quotaStoppedAt: '' | |
| }) | |
| logger.success(`✅ Admin manually reset daily usage for OpenAI-Responses account ${id}`) | |
| res.json({ | |
| success: true, | |
| message: 'Daily usage reset successfully' | |
| }) | |
| } catch (error) { | |
| logger.error('Failed to reset OpenAI-Responses account usage:', error) | |
| res.status(500).json({ | |
| success: false, | |
| error: error.message | |
| }) | |
| } | |
| }) | |
| // 🤖 Droid 账户管理 | |
| // 生成 Droid OAuth 授权链接 | |
| router.post('/droid-accounts/generate-auth-url', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { proxy } = req.body || {} | |
| const deviceAuth = await startDeviceAuthorization(proxy || null) | |
| const sessionId = crypto.randomUUID() | |
| const expiresAt = new Date(Date.now() + deviceAuth.expiresIn * 1000).toISOString() | |
| await redis.setOAuthSession(sessionId, { | |
| deviceCode: deviceAuth.deviceCode, | |
| userCode: deviceAuth.userCode, | |
| verificationUri: deviceAuth.verificationUri, | |
| verificationUriComplete: deviceAuth.verificationUriComplete, | |
| interval: deviceAuth.interval, | |
| proxy: proxy || null, | |
| createdAt: new Date().toISOString(), | |
| expiresAt | |
| }) | |
| logger.success('🤖 生成 Droid 设备码授权信息成功', { sessionId }) | |
| return res.json({ | |
| success: true, | |
| data: { | |
| sessionId, | |
| userCode: deviceAuth.userCode, | |
| verificationUri: deviceAuth.verificationUri, | |
| verificationUriComplete: deviceAuth.verificationUriComplete, | |
| expiresIn: deviceAuth.expiresIn, | |
| interval: deviceAuth.interval, | |
| instructions: [ | |
| '1. 使用下方验证码进入授权页面并确认访问权限。', | |
| '2. 在授权页面登录 Factory / Droid 账户并点击允许。', | |
| '3. 回到此处点击“完成授权”完成凭证获取。' | |
| ] | |
| } | |
| }) | |
| } catch (error) { | |
| const message = | |
| error instanceof WorkOSDeviceAuthError ? error.message : error.message || '未知错误' | |
| logger.error('❌ 生成 Droid 设备码授权失败:', message) | |
| return res.status(500).json({ error: 'Failed to start Droid device authorization', message }) | |
| } | |
| }) | |
| // 交换 Droid 授权码 | |
| router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res) => { | |
| const { sessionId, proxy } = req.body || {} | |
| try { | |
| if (!sessionId) { | |
| return res.status(400).json({ error: 'Session ID is required' }) | |
| } | |
| const oauthSession = await redis.getOAuthSession(sessionId) | |
| if (!oauthSession) { | |
| return res.status(400).json({ error: 'Invalid or expired OAuth session' }) | |
| } | |
| if (oauthSession.expiresAt && 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' }) | |
| } | |
| if (!oauthSession.deviceCode) { | |
| await redis.deleteOAuthSession(sessionId) | |
| return res.status(400).json({ error: 'OAuth session missing device code, please retry' }) | |
| } | |
| const proxyConfig = proxy || oauthSession.proxy || null | |
| const tokens = await pollDeviceAuthorization(oauthSession.deviceCode, proxyConfig) | |
| await redis.deleteOAuthSession(sessionId) | |
| logger.success('🤖 成功获取 Droid 访问令牌', { sessionId }) | |
| return res.json({ success: true, data: { tokens } }) | |
| } catch (error) { | |
| if (error instanceof WorkOSDeviceAuthError) { | |
| if (error.code === 'authorization_pending' || error.code === 'slow_down') { | |
| const oauthSession = await redis.getOAuthSession(sessionId) | |
| const expiresAt = oauthSession?.expiresAt ? new Date(oauthSession.expiresAt) : null | |
| const remainingSeconds = | |
| expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime()) | |
| ? Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000)) | |
| : null | |
| return res.json({ | |
| success: false, | |
| pending: true, | |
| error: error.code, | |
| message: error.message, | |
| retryAfter: error.retryAfter || Number(oauthSession?.interval) || 5, | |
| expiresIn: remainingSeconds | |
| }) | |
| } | |
| if (error.code === 'expired_token') { | |
| await redis.deleteOAuthSession(sessionId) | |
| return res.status(400).json({ | |
| error: 'Device code expired', | |
| message: '授权已过期,请重新生成设备码并再次授权' | |
| }) | |
| } | |
| logger.error('❌ Droid 授权失败:', error.message) | |
| return res.status(500).json({ | |
| error: 'Failed to exchange Droid authorization code', | |
| message: error.message, | |
| errorCode: error.code | |
| }) | |
| } | |
| logger.error('❌ 交换 Droid 授权码失败:', error) | |
| return res.status(500).json({ | |
| error: 'Failed to exchange Droid authorization code', | |
| message: error.message | |
| }) | |
| } | |
| }) | |
| // 获取所有 Droid 账户 | |
| router.get('/droid-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const accounts = await droidAccountService.getAllAccounts() | |
| const allApiKeys = await redis.getAllApiKeys() | |
| // 添加使用统计 | |
| const accountsWithStats = await Promise.all( | |
| accounts.map(async (account) => { | |
| try { | |
| const usageStats = await redis.getAccountUsageStats(account.id, 'droid') | |
| let groupInfos = [] | |
| try { | |
| groupInfos = await accountGroupService.getAccountGroups(account.id) | |
| } catch (groupError) { | |
| logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError) | |
| groupInfos = [] | |
| } | |
| const groupIds = groupInfos.map((group) => group.id) | |
| const boundApiKeysCount = allApiKeys.reduce((count, key) => { | |
| const binding = key.droidAccountId | |
| if (!binding) { | |
| return count | |
| } | |
| if (binding === account.id) { | |
| return count + 1 | |
| } | |
| if (binding.startsWith('group:')) { | |
| const groupId = binding.substring('group:'.length) | |
| if (groupIds.includes(groupId)) { | |
| return count + 1 | |
| } | |
| } | |
| return count | |
| }, 0) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| schedulable: account.schedulable === 'true', | |
| boundApiKeysCount, | |
| groupInfos, | |
| usage: { | |
| daily: usageStats.daily, | |
| total: usageStats.total, | |
| averages: usageStats.averages | |
| } | |
| } | |
| } catch (error) { | |
| logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return { | |
| ...formattedAccount, | |
| boundApiKeysCount: 0, | |
| groupInfos: [], | |
| usage: { | |
| daily: { tokens: 0, requests: 0 }, | |
| total: { tokens: 0, requests: 0 }, | |
| averages: { rpm: 0, tpm: 0 } | |
| } | |
| } | |
| } | |
| }) | |
| ) | |
| return res.json({ success: true, data: accountsWithStats }) | |
| } catch (error) { | |
| logger.error('Failed to get Droid accounts:', error) | |
| return res.status(500).json({ error: 'Failed to get Droid accounts', message: error.message }) | |
| } | |
| }) | |
| // 创建 Droid 账户 | |
| router.post('/droid-accounts', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { accountType: rawAccountType = 'shared', groupId, groupIds } = req.body | |
| const normalizedAccountType = rawAccountType || 'shared' | |
| if (!['shared', 'dedicated', 'group'].includes(normalizedAccountType)) { | |
| return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' }) | |
| } | |
| const normalizedGroupIds = Array.isArray(groupIds) | |
| ? groupIds.filter((id) => typeof id === 'string' && id.trim()) | |
| : [] | |
| if ( | |
| normalizedAccountType === 'group' && | |
| normalizedGroupIds.length === 0 && | |
| (!groupId || typeof groupId !== 'string' || !groupId.trim()) | |
| ) { | |
| return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' }) | |
| } | |
| const accountPayload = { | |
| ...req.body, | |
| accountType: normalizedAccountType | |
| } | |
| delete accountPayload.groupId | |
| delete accountPayload.groupIds | |
| const account = await droidAccountService.createAccount(accountPayload) | |
| if (normalizedAccountType === 'group') { | |
| try { | |
| if (normalizedGroupIds.length > 0) { | |
| await accountGroupService.setAccountGroups(account.id, normalizedGroupIds, 'droid') | |
| } else if (typeof groupId === 'string' && groupId.trim()) { | |
| await accountGroupService.addAccountToGroup(account.id, groupId, 'droid') | |
| } | |
| } catch (groupError) { | |
| logger.error(`Failed to attach Droid account ${account.id} to groups:`, groupError) | |
| return res.status(500).json({ | |
| error: 'Failed to bind Droid account to groups', | |
| message: groupError.message | |
| }) | |
| } | |
| } | |
| logger.success(`Created Droid account: ${account.name} (${account.id})`) | |
| const formattedAccount = formatAccountExpiry(account) | |
| return res.json({ success: true, data: formattedAccount }) | |
| } catch (error) { | |
| logger.error('Failed to create Droid account:', error) | |
| return res.status(500).json({ error: 'Failed to create Droid account', message: error.message }) | |
| } | |
| }) | |
| // 更新 Droid 账户 | |
| router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| const updates = { ...req.body } | |
| // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt | |
| const mappedUpdates = mapExpiryField(updates, 'Droid', id) | |
| const { accountType: rawAccountType, groupId, groupIds } = mappedUpdates | |
| if (rawAccountType && !['shared', 'dedicated', 'group'].includes(rawAccountType)) { | |
| return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' }) | |
| } | |
| if ( | |
| rawAccountType === 'group' && | |
| (!groupId || typeof groupId !== 'string' || !groupId.trim()) && | |
| (!Array.isArray(groupIds) || groupIds.length === 0) | |
| ) { | |
| return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' }) | |
| } | |
| const currentAccount = await droidAccountService.getAccount(id) | |
| if (!currentAccount) { | |
| return res.status(404).json({ error: 'Droid account not found' }) | |
| } | |
| const normalizedGroupIds = Array.isArray(groupIds) | |
| ? groupIds.filter((gid) => typeof gid === 'string' && gid.trim()) | |
| : [] | |
| const hasGroupIdsField = Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds') | |
| const hasGroupIdField = Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupId') | |
| const targetAccountType = rawAccountType || currentAccount.accountType || 'shared' | |
| delete mappedUpdates.groupId | |
| delete mappedUpdates.groupIds | |
| if (rawAccountType) { | |
| mappedUpdates.accountType = targetAccountType | |
| } | |
| const account = await droidAccountService.updateAccount(id, mappedUpdates) | |
| try { | |
| if (currentAccount.accountType === 'group' && targetAccountType !== 'group') { | |
| await accountGroupService.removeAccountFromAllGroups(id) | |
| } else if (targetAccountType === 'group') { | |
| if (hasGroupIdsField) { | |
| if (normalizedGroupIds.length > 0) { | |
| await accountGroupService.setAccountGroups(id, normalizedGroupIds, 'droid') | |
| } else { | |
| await accountGroupService.removeAccountFromAllGroups(id) | |
| } | |
| } else if (hasGroupIdField && typeof groupId === 'string' && groupId.trim()) { | |
| await accountGroupService.setAccountGroups(id, [groupId], 'droid') | |
| } | |
| } | |
| } catch (groupError) { | |
| logger.error(`Failed to update Droid account ${id} groups:`, groupError) | |
| return res.status(500).json({ | |
| error: 'Failed to update Droid account groups', | |
| message: groupError.message | |
| }) | |
| } | |
| if (targetAccountType === 'group') { | |
| try { | |
| account.groupInfos = await accountGroupService.getAccountGroups(id) | |
| } catch (groupFetchError) { | |
| logger.debug(`Failed to fetch group infos for Droid account ${id}:`, groupFetchError) | |
| } | |
| } | |
| return res.json({ success: true, data: account }) | |
| } catch (error) { | |
| logger.error(`Failed to update Droid account ${req.params.id}:`, error) | |
| return res.status(500).json({ error: 'Failed to update Droid account', message: error.message }) | |
| } | |
| }) | |
| // 切换 Droid 账户调度状态 | |
| router.put('/droid-accounts/:id/toggle-schedulable', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| const account = await droidAccountService.getAccount(id) | |
| if (!account) { | |
| return res.status(404).json({ error: 'Droid account not found' }) | |
| } | |
| const currentSchedulable = account.schedulable === true || account.schedulable === 'true' | |
| const newSchedulable = !currentSchedulable | |
| await droidAccountService.updateAccount(id, { schedulable: newSchedulable ? 'true' : 'false' }) | |
| const updatedAccount = await droidAccountService.getAccount(id) | |
| const actualSchedulable = updatedAccount | |
| ? updatedAccount.schedulable === true || updatedAccount.schedulable === 'true' | |
| : newSchedulable | |
| if (!actualSchedulable) { | |
| await webhookNotifier.sendAccountAnomalyNotification({ | |
| accountId: account.id, | |
| accountName: account.name || 'Droid Account', | |
| platform: 'droid', | |
| status: 'disabled', | |
| errorCode: 'DROID_MANUALLY_DISABLED', | |
| reason: '账号已被管理员手动禁用调度', | |
| timestamp: new Date().toISOString() | |
| }) | |
| } | |
| logger.success( | |
| `🔄 Admin toggled Droid account schedulable status: ${id} -> ${ | |
| actualSchedulable ? 'schedulable' : 'not schedulable' | |
| }` | |
| ) | |
| return res.json({ success: true, schedulable: actualSchedulable }) | |
| } catch (error) { | |
| logger.error('❌ Failed to toggle Droid account schedulable status:', error) | |
| return res | |
| .status(500) | |
| .json({ error: 'Failed to toggle schedulable status', message: error.message }) | |
| } | |
| }) | |
| // 获取单个 Droid 账户详细信息 | |
| router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| // 获取账户基本信息 | |
| const account = await droidAccountService.getAccount(id) | |
| if (!account) { | |
| return res.status(404).json({ | |
| error: 'Not Found', | |
| message: 'Droid account not found' | |
| }) | |
| } | |
| // 获取使用统计信息 | |
| let usageStats | |
| try { | |
| usageStats = await redis.getAccountUsageStats(account.id, 'droid') | |
| } catch (error) { | |
| logger.debug(`Failed to get usage stats for Droid account ${account.id}:`, error) | |
| usageStats = { | |
| daily: { tokens: 0, requests: 0, allTokens: 0 }, | |
| total: { tokens: 0, requests: 0, allTokens: 0 }, | |
| averages: { rpm: 0, tpm: 0 } | |
| } | |
| } | |
| // 获取分组信息 | |
| let groupInfos = [] | |
| try { | |
| groupInfos = await accountGroupService.getAccountGroups(account.id) | |
| } catch (error) { | |
| logger.debug(`Failed to get group infos for Droid account ${account.id}:`, error) | |
| groupInfos = [] | |
| } | |
| // 获取绑定的 API Key 数量 | |
| const allApiKeys = await redis.getAllApiKeys() | |
| const groupIds = groupInfos.map((group) => group.id) | |
| const boundApiKeysCount = allApiKeys.reduce((count, key) => { | |
| const binding = key.droidAccountId | |
| if (!binding) { | |
| return count | |
| } | |
| if (binding === account.id) { | |
| return count + 1 | |
| } | |
| if (binding.startsWith('group:')) { | |
| const groupId = binding.substring('group:'.length) | |
| if (groupIds.includes(groupId)) { | |
| return count + 1 | |
| } | |
| } | |
| return count | |
| }, 0) | |
| // 获取解密的 API Keys(用于管理界面) | |
| let decryptedApiKeys = [] | |
| try { | |
| decryptedApiKeys = await droidAccountService.getDecryptedApiKeyEntries(id) | |
| } catch (error) { | |
| logger.debug(`Failed to get decrypted API keys for Droid account ${account.id}:`, error) | |
| decryptedApiKeys = [] | |
| } | |
| // 返回完整的账户信息,包含实际的 API Keys | |
| const accountDetails = { | |
| ...account, | |
| // 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt | |
| expiresAt: account.subscriptionExpiresAt || null, | |
| schedulable: account.schedulable === 'true', | |
| boundApiKeysCount, | |
| groupInfos, | |
| // 包含实际的 API Keys(用于管理界面) | |
| apiKeys: decryptedApiKeys.map((entry) => ({ | |
| key: entry.key, | |
| id: entry.id, | |
| usageCount: entry.usageCount || 0, | |
| lastUsedAt: entry.lastUsedAt || null, | |
| status: entry.status || 'active', // 使用实际的状态,默认为 active | |
| errorMessage: entry.errorMessage || '', // 包含错误信息 | |
| createdAt: entry.createdAt || null | |
| })), | |
| usage: { | |
| daily: usageStats.daily, | |
| total: usageStats.total, | |
| averages: usageStats.averages | |
| } | |
| } | |
| return res.json({ | |
| success: true, | |
| data: accountDetails | |
| }) | |
| } catch (error) { | |
| logger.error(`Failed to get Droid account ${req.params.id}:`, error) | |
| return res.status(500).json({ | |
| error: 'Failed to get Droid account', | |
| message: error.message | |
| }) | |
| } | |
| }) | |
| // 删除 Droid 账户 | |
| router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| await droidAccountService.deleteAccount(id) | |
| return res.json({ success: true, message: 'Droid account deleted successfully' }) | |
| } catch (error) { | |
| logger.error(`Failed to delete Droid account ${req.params.id}:`, error) | |
| return res.status(500).json({ error: 'Failed to delete Droid account', message: error.message }) | |
| } | |
| }) | |
| // 刷新 Droid 账户 token | |
| router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req, res) => { | |
| try { | |
| const { id } = req.params | |
| const result = await droidAccountService.refreshAccessToken(id) | |
| return res.json({ success: true, data: result }) | |
| } catch (error) { | |
| logger.error(`Failed to refresh Droid account token ${req.params.id}:`, error) | |
| return res.status(500).json({ error: 'Failed to refresh token', message: error.message }) | |
| } | |
| }) | |
| module.exports = router | |